#!/usr/bin/env python3
"""probe_jasmin_print_layout_catalog

Probe Jasmin API for print-layout/report catalog endpoints.

Read-only by default. It searches likely Jasmin REST/OData endpoints for the
print model labels visible in the UI, e.g. "Fatura de Mercadorias" and
"Talão de Fatura". The goal is to discover whether the API exposes an internal
layout/report identifier that ClientFlow can use when printing invoices.

Optional --probe-print-post tests POST /billing/invoices/{id}/print with JSON
bodies containing the candidate labels. This should not create documents, but it
may mark the invoice as printed/reprinted in Jasmin, so it requires
--confirm-invoice.
"""
from __future__ import annotations

import argparse
import asyncio
import csv
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any

import httpx
from sqlalchemy import text

from app.db import engine
from app.jasmin_client import JasminClient

OUT_PREFIX = "/tmp/clientflow_jasmin_print_layout_catalog_probe"
POINT_TO_MM = 25.4 / 72.0
A4_PT = (595.0, 842.0)

DEFAULT_LABELS = [
    "Fatura de Mercadorias",
    "Fatura de Mercadorias (totais de linha sem imposto)",
    "Fatura de Serviços",
    "Fatura de Serviços (totais de linha sem imposto)",
    "Talão de Fatura",
    "Talão de Fatura com Dados de Entrega",
]

CATALOG_ENDPOINTS = [
    # Metadata roots.
    "/$metadata",
    "/businessCore/$metadata",
    "/billing/$metadata",
    "/billingCore/$metadata",
    "/sales/$metadata",
    "/salesCore/$metadata",
    "/platform/$metadata",

    # Likely print/report catalogs.
    "/businessCore/printLayouts",
    "/businessCore/printLayouts/odata",
    "/businessCore/printFormats",
    "/businessCore/printFormats/odata",
    "/businessCore/printSettings",
    "/businessCore/printSettings/odata",
    "/businessCore/printingSettings",
    "/businessCore/printingSettings/odata",
    "/businessCore/printingPreferences",
    "/businessCore/printingPreferences/odata",
    "/businessCore/reportSettings",
    "/businessCore/reportSettings/odata",
    "/businessCore/reportTemplates",
    "/businessCore/reportTemplates/odata",
    "/businessCore/reports",
    "/businessCore/reports/odata",
    "/businessCore/documentLayouts",
    "/businessCore/documentLayouts/odata",
    "/businessCore/documentPrintLayouts",
    "/businessCore/documentPrintLayouts/odata",
    "/businessCore/printLayoutSelections",
    "/businessCore/printLayoutSelections/odata",
    "/businessCore/reporting/printLayouts",
    "/businessCore/reporting/printLayouts/odata",
    "/businessCore/reporting/reports",
    "/businessCore/reporting/reports/odata",

    # Platform/reporting guesses.
    "/platform/printLayouts",
    "/platform/printLayouts/odata",
    "/platform/reports",
    "/platform/reports/odata",
    "/platform/reportTemplates",
    "/platform/reportTemplates/odata",
    "/platform/reporting/printLayouts",
    "/platform/reporting/printLayouts/odata",

    # Billing-specific guesses.
    "/billing/printLayouts",
    "/billing/printLayouts/odata",
    "/billing/reportTemplates",
    "/billing/reportTemplates/odata",
    "/billing/reports",
    "/billing/reports/odata",
    "/billing/invoicePrintLayouts",
    "/billing/invoicePrintLayouts/odata",
    "/billing/invoiceReports",
    "/billing/invoiceReports/odata",
    "/billing/documentPrintLayouts",
    "/billing/documentPrintLayouts/odata",
    "/billing/documentTypes",
    "/billing/documentTypes/odata",
    "/billing/invoiceTypes",
    "/billing/invoiceTypes/odata",
    "/billingCore/printLayouts",
    "/billingCore/printLayouts/odata",
    "/billingCore/reports",
    "/billingCore/reports/odata",
    "/billingCore/documentTypes",
    "/billingCore/documentTypes/odata",
    "/billingCore/invoiceTypes",
    "/billingCore/invoiceTypes/odata",

    # Sales catalogs may include report names seen in ORC payloads.
    "/sales/printLayouts",
    "/sales/printLayouts/odata",
    "/sales/reports",
    "/sales/reports/odata",
    "/sales/documentPrintLayouts",
    "/sales/documentPrintLayouts/odata",
    "/salesCore/printLayouts",
    "/salesCore/printLayouts/odata",
    "/salesCore/reports",
    "/salesCore/reports/odata",
]


def _s(value: Any) -> str:
    return str(value or "").strip()


def _json_default(value: Any) -> str:
    try:
        return value.isoformat()  # type: ignore[attr-defined]
    except Exception:
        return str(value)


def _looks_a4(w: float, h: float, tolerance_pt: float = 20.0) -> bool:
    return any(abs(a - A4_PT[0]) <= tolerance_pt and abs(b - A4_PT[1]) <= tolerance_pt for a, b in ((w, h), (h, w)))


def _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
    latin = data.decode("latin-1", errors="ignore")
    pattern = re.compile(r"/(MediaBox|CropBox)\s*\[\s*([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s*\]", re.I)
    out: list[dict[str, Any]] = []
    seen: set[tuple[str, float, float, float, float]] = set()
    for m in pattern.finditer(latin):
        kind = m.group(1)
        x0, y0, x1, y1 = (float(m.group(i)) for i in range(2, 6))
        key = (kind.lower(), x0, y0, x1, y1)
        if key in seen:
            continue
        seen.add(key)
        w, h = abs(x1 - x0), abs(y1 - y0)
        out.append({
            "kind": kind,
            "width_pt": round(w, 2),
            "height_pt": round(h, 2),
            "width_mm": round(w * POINT_TO_MM, 1),
            "height_mm": round(h * POINT_TO_MM, 1),
            "looks_a4": _looks_a4(w, h),
        })
    return out


def _pdf_info(data: bytes, content_type: str) -> dict[str, Any]:
    info: dict[str, Any] = {
        "content_type": content_type,
        "bytes": len(data),
        "sha256_12": hashlib.sha256(data).hexdigest()[:12] if data else "",
        "is_pdf_header": data[:5] == b"%PDF-",
        "boxes": [],
        "format_warning": "",
    }
    if not data:
        info["format_warning"] = "empty_response"
        return info
    if not info["is_pdf_header"]:
        info["format_warning"] = "not_pdf_header"
        info["body_preview"] = data[:500].decode("utf-8", errors="replace")
        return info
    boxes = _extract_pdf_boxes(data)
    info["boxes"] = boxes
    if boxes and not any(b.get("looks_a4") for b in boxes if _s(b.get("kind")).lower() == "mediabox"):
        info["format_warning"] = "non_a4_or_unexpected_mediabox"
    return info


def _box_summary(info: dict[str, Any]) -> str:
    boxes = info.get("boxes") or []
    if not boxes:
        return f"content_type={info.get('content_type')} bytes={info.get('bytes')} warning={info.get('format_warning')}"
    b = boxes[0]
    return f"{b.get('kind')} {b.get('width_mm')}x{b.get('height_mm')}mm a4={b.get('looks_a4')} warning={info.get('format_warning') or ''}"


def _query_local_invoice(value: str) -> dict[str, Any] | None:
    if not value:
        return None
    with engine.begin() as conn:
        row = conn.execute(text("""
            SELECT id::text AS local_doc_id, external_id, document_number, document_type,
                   serie, series_number, payload, created_at
            FROM commercial_documents
            WHERE system = 'jasmin'
              AND document_kind = 'invoice'
              AND (document_number = :v OR external_id = :v OR id::text = :v)
            ORDER BY created_at DESC
            LIMIT 1
        """), {"v": value}).mappings().first()
    return dict(row) if row else None


async def _raw_request(
    client: JasminClient,
    method: str,
    path: str,
    *,
    params: dict[str, Any] | None = None,
    json_body: Any = None,
    accept: str = "application/json",
) -> tuple[int, bytes, str, dict[str, str]]:
    token = await client.get_token()
    url = f"{client.api_root}/{path.lstrip('/')}"
    headers = {"Authorization": f"Bearer {token}", "Accept": accept}
    if json_body is not None:
        headers["Content-Type"] = "application/json"
    async with httpx.AsyncClient(timeout=client.timeout, follow_redirects=True) as hc:
        response = await hc.request(method.upper(), url, params=params or {}, json=json_body, headers=headers)
    return response.status_code, response.content, response.headers.get("content-type", ""), {
        "allow": response.headers.get("allow", ""),
        "content-disposition": response.headers.get("content-disposition", ""),
        "location": response.headers.get("location", ""),
    }


def _decode_json_or_text(data: bytes, content_type: str) -> tuple[Any, str]:
    text_body = data.decode("utf-8", errors="replace")
    if "json" in (content_type or "").lower() or text_body.strip().startswith(("{", "[")):
        try:
            return json.loads(text_body), text_body
        except Exception:
            pass
    return None, text_body


def _flatten_strings(obj: Any, *, limit: int = 3000) -> list[str]:
    out: list[str] = []
    def walk(x: Any) -> None:
        if len(out) >= limit:
            return
        if isinstance(x, dict):
            for k, v in x.items():
                if isinstance(k, str):
                    out.append(k)
                walk(v)
        elif isinstance(x, list):
            for v in x:
                walk(v)
        elif isinstance(x, str):
            out.append(x)
    walk(obj)
    return out


def _label_matches(obj: Any, text_body: str, labels: list[str]) -> list[str]:
    haystack = "\n".join(_flatten_strings(obj)) if obj is not None else text_body
    haystack_l = haystack.lower()
    hits = []
    for label in labels:
        l = label.lower()
        # Exact display label or relaxed key words.
        if l in haystack_l:
            hits.append(label)
    return hits


def _extract_interesting_records(obj: Any, labels: list[str], *, max_records: int = 20) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    wanted = [x.lower() for x in labels] + ["fatura", "mercadorias", "serviços", "servicos", "talão", "talao", "invoice", "goods", "services", "receipt"]

    def interesting_text(x: Any) -> bool:
        if isinstance(x, dict):
            txt = json.dumps(x, ensure_ascii=False, default=_json_default).lower()
        else:
            txt = _s(x).lower()
        return any(w in txt for w in wanted)

    def compact_record(d: dict[str, Any]) -> dict[str, Any]:
        rx = re.compile(r"id|key|name|description|report|layout|print|template|document|serie|type|fiscal", re.I)
        out = {k: v for k, v in d.items() if rx.search(str(k)) and not isinstance(v, (list, dict))}
        if not out:
            out = {k: v for k, v in list(d.items())[:12] if not isinstance(v, (list, dict))}
        return out

    def walk(x: Any) -> None:
        if len(records) >= max_records:
            return
        if isinstance(x, dict):
            if interesting_text(x):
                records.append(compact_record(x))
            for v in x.values():
                walk(v)
        elif isinstance(x, list):
            for v in x:
                walk(v)
    walk(obj)
    return records


def _endpoint_params(path: str) -> dict[str, Any]:
    if "odata" in path.lower():
        return {"$top": 100}
    return {}


async def _probe_catalog(client: JasminClient, endpoints: list[str], labels: list[str], max_endpoints: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for i, path in enumerate(endpoints[:max_endpoints], start=1):
        params = _endpoint_params(path)
        try:
            status, content, content_type, headers_subset = await _raw_request(client, "GET", path, params=params, accept="application/json")
            obj, text_body = _decode_json_or_text(content, content_type)
            hits = _label_matches(obj, text_body, labels)
            records = _extract_interesting_records(obj, labels) if obj is not None and status < 500 else []
            row = {
                "path": path,
                "method": "GET",
                "params": params,
                "status_code": status,
                "content_type": content_type,
                "bytes": len(content),
                "headers_subset": headers_subset,
                "label_hits": hits,
                "interesting_records": records,
                "body_preview": text_body[:1500] if (hits or status in {200, 400, 405}) else text_body[:300],
            }
            rows.append(row)
            hit_txt = f" hits={hits}" if hits else ""
            print(f"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: status={status} bytes={len(content)}{hit_txt}")
        except Exception as exc:
            rows.append({"path": path, "method": "GET", "error": str(exc)})
            print(f"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: ERROR {exc}")
    return rows


def _print_post_bodies(labels: list[str]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    keys = ["printLayout", "layout", "layoutKey", "template", "templateKey", "reportName", "printedReportName"]
    for label in labels:
        for key in keys:
            out.append({"name": f"{key}={label}", "body": {key: label}})
    return out


async def _probe_print_post(client: JasminClient, invoice_id: str, labels: list[str], save_dir: str, max_variants: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    out_dir = Path(save_dir) if save_dir else None
    if out_dir:
        out_dir.mkdir(parents=True, exist_ok=True)
    variants = _print_post_bodies(labels)[:max_variants]
    for i, v in enumerate(variants, start=1):
        try:
            status, content, content_type, headers_subset = await _raw_request(
                client,
                "POST",
                f"/billing/invoices/{invoice_id}/print",
                json_body=v["body"],
                accept="application/pdf,application/json",
            )
            pdf = _pdf_info(content, content_type)
            if out_dir and status < 400 and pdf.get("is_pdf_header"):
                safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"post_{i:02d}_{v['name']}").strip("_")
                (out_dir / f"{safe}.pdf").write_bytes(content)
            rows.append({
                "name": v["name"],
                "body": v["body"],
                "status_code": status,
                "content_type": content_type,
                "headers_subset": headers_subset,
                "pdf": pdf,
            })
            print(f"POST {i:02d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}")
        except Exception as exc:
            rows.append({"name": v["name"], "body": v["body"], "error": str(exc)})
            print(f"POST {i:02d}/{len(variants)} {v['name']}: ERROR {exc}")
    return rows


def _write_outputs(result: dict[str, Any]) -> None:
    json_path = Path(OUT_PREFIX + ".json")
    md_path = Path(OUT_PREFIX + ".md")
    csv_path = Path(OUT_PREFIX + ".csv")
    json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")

    endpoints = result.get("catalog_endpoints") or []
    hits = [r for r in endpoints if r.get("label_hits")]
    readable = [r for r in endpoints if int(r.get("status_code") or 0) == 200]
    post_rows = result.get("print_post_probe") or []
    post_ok = [r for r in post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]

    lines = [
        "# Probe Jasmin print-layout/catalog API",
        "",
        f"- Resolved at: `{result.get('resolved_at')}`",
        f"- Labels procurados: `{', '.join(result.get('labels') or [])}`",
        f"- Endpoints testados: `{len(endpoints)}`",
        f"- Endpoints 200: `{len(readable)}`",
        f"- Endpoints com labels exatos: `{len(hits)}`",
        "",
        "## Resultado",
    ]
    if hits:
        lines.append("Foram encontrados labels visíveis em endpoints da API:")
        for r in hits[:20]:
            lines.append(f"- `{r.get('path')}` hits={r.get('label_hits')} status={r.get('status_code')}")
            for rec in (r.get("interesting_records") or [])[:5]:
                lines.append(f"  - `{json.dumps(rec, ensure_ascii=False, default=_json_default)}`")
    else:
        lines.append("Não foi encontrado catálogo público/readable com estes labels exatos. Pode existir endpoint interno não exposto, ou o seletor da UI não estar disponível na API pública usada pelo ClientFlow.")

    if post_rows:
        lines.extend(["", "## POST /print com body JSON", ""])
        if post_ok:
            lines.append("Encontrada variante POST que devolve A4/sem warning:")
            for r in post_ok[:10]:
                lines.append(f"- `{r.get('name')}` body=`{r.get('body')}` → `{_box_summary(r.get('pdf') or {})}`")
        else:
            lines.append("Nenhuma variante POST /print testada devolveu A4.")
        for r in post_rows[:12]:
            lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")

    lines.extend(["", "## Endpoints 200/405 mais relevantes", ""])
    for r in [x for x in endpoints if int(x.get("status_code") or 0) in {200, 400, 405}][:30]:
        lines.append(f"- `{r.get('method')} {r.get('path')}` status={r.get('status_code')} type={r.get('content_type')} bytes={r.get('bytes')} hits={r.get('label_hits')}")
    md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")

    with csv_path.open("w", encoding="utf-8", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=["kind", "path_or_name", "status_code", "content_type", "bytes", "label_hits", "warning", "summary"])
        writer.writeheader()
        for r in endpoints:
            writer.writerow({
                "kind": "endpoint",
                "path_or_name": r.get("path"),
                "status_code": r.get("status_code"),
                "content_type": r.get("content_type"),
                "bytes": r.get("bytes"),
                "label_hits": "; ".join(r.get("label_hits") or []),
                "warning": "",
                "summary": (r.get("body_preview") or "")[:300].replace("\n", " "),
            })
        for r in post_rows:
            p = r.get("pdf") or {}
            writer.writerow({
                "kind": "print_post",
                "path_or_name": r.get("name"),
                "status_code": r.get("status_code"),
                "content_type": p.get("content_type") or r.get("content_type"),
                "bytes": p.get("bytes"),
                "label_hits": "",
                "warning": p.get("format_warning"),
                "summary": _box_summary(p),
            })

    print(f"JSON: {json_path}")
    print(f"Markdown: {md_path}")
    print(f"CSV: {csv_path}")


async def _main_async(args: argparse.Namespace) -> int:
    labels = [x.strip() for x in (args.labels.split("|") if args.labels else DEFAULT_LABELS) if x.strip()]
    client = JasminClient()

    local = _query_local_invoice(args.invoice) if args.invoice else None
    invoice_id = _s(args.invoice_external_id or (local or {}).get("external_id") or args.invoice)
    remote_invoice: dict[str, Any] = {}
    if invoice_id:
        try:
            remote_raw = await client.get_invoice(invoice_id)
            remote_invoice = {
                "id": remote_raw.get("id"),
                "naturalKey": remote_raw.get("naturalKey"),
                "documentType": remote_raw.get("documentType"),
                "serie": remote_raw.get("serie"),
                "printedReportName": remote_raw.get("printedReportName"),
                "printLayout": remote_raw.get("printLayout"),
                "isPrinted": remote_raw.get("isPrinted"),
                "isReprinted": remote_raw.get("isReprinted"),
            }
            invoice_id = _s(remote_invoice.get("id") or invoice_id)
        except Exception as exc:
            remote_invoice = {"fetch_error": str(exc)}

    endpoints = list(dict.fromkeys(CATALOG_ENDPOINTS + [
        *( [
            f"/billing/invoices/{invoice_id}/printLayouts",
            f"/billing/invoices/{invoice_id}/printOptions",
            f"/billing/invoices/{invoice_id}/printSettings",
            f"/billing/invoices/{invoice_id}/availableReports",
            f"/billing/invoices/{invoice_id}/reports",
        ] if invoice_id else [] )
    ]))
    catalog = await _probe_catalog(client, endpoints, labels, args.max_endpoints)

    post_rows: list[dict[str, Any]] = []
    if args.probe_print_post:
        if not invoice_id:
            raise SystemExit("--probe-print-post exige --invoice ou --invoice-external-id")
        if args.confirm_invoice != args.invoice:
            raise SystemExit("Para testar POST /print, passa --confirm-invoice igual ao valor de --invoice.")
        post_rows = await _probe_print_post(client, invoice_id, labels, args.save_pdf_dir, args.max_print_post_variants)

    hits = [r for r in catalog if r.get("label_hits")]
    result = {
        "resolved_at": datetime.utcnow().isoformat() + "Z",
        "labels": labels,
        "invoice_input": args.invoice,
        "invoice_external_id": invoice_id,
        "local_invoice": local or {},
        "remote_invoice": remote_invoice,
        "catalog_endpoints": catalog,
        "print_post_probe": post_rows,
        "recommendation": (
            "Encontrado endpoint com labels/modelos de impressão. Usar os records no JSON para identificar a chave/id interna e fazer novo teste de impressão com esse id."
            if hits else
            "Não foi encontrado catálogo público com os labels. O seletor de modelo pode estar apenas na UI Jasmin ou noutro endpoint não documentado; manter bloqueio PDF não-A4 e corrigir configuração Jasmin, salvo se o suporte fornecer endpoint/id de layout."
        ),
    }
    print("SUMMARY")
    print(f"catalog_endpoints={len(catalog)} label_hits={len(hits)} print_post_variants={len(post_rows)}")
    print(result["recommendation"])
    _write_outputs(result)
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Probe Jasmin API for print-layout/report catalog endpoints.")
    p.add_argument("--invoice", default="", help="Optional invoice natural key/local id/external id, e.g. FA.FA2026.137")
    p.add_argument("--invoice-external-id", default="", help="Explicit Jasmin invoice GUID")
    p.add_argument("--labels", default="|".join(DEFAULT_LABELS), help="Pipe-separated labels to search for")
    p.add_argument("--max-endpoints", type=int, default=120)
    p.add_argument("--probe-print-post", action="store_true", help="Also POST JSON bodies to /billing/invoices/{id}/print; requires --confirm-invoice")
    p.add_argument("--confirm-invoice", default="", help="Must match --invoice for --probe-print-post")
    p.add_argument("--max-print-post-variants", type=int, default=30)
    p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by optional POST /print probe")
    return p


def main() -> int:
    return asyncio.run(_main_async(build_parser().parse_args()))


if __name__ == "__main__":
    raise SystemExit(main())
