#!/usr/bin/env python3
"""probe_jasmin_invoice_print_template_models: test Jasmin invoice print with UI template model keys.

The Jasmin UI route /reporting/templates/list?listname=templates shows template model
keys such as Document, Slip, SlipWithShippingDetails and SummaryReport. Previous
probes tested human labels and report names; this one tests the internal-looking
model keys against /billing/invoices/{id}/print using GET query parameters by
default, and can optionally test POST JSON bodies behind an explicit confirmation.

Read-only by default: only GET /print and catalog/list routes are called. GET /print
may mark a document as printed/reprinted in some ERPs, so use a test invoice when
possible. POST /print is opt-in with --probe-post and --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
from urllib.parse import urlencode

import httpx
from sqlalchemy import text

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

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

DEFAULT_MODELS = [
    "Document",
    "DOCUMENT (1)",
    "Slip",
    "SlipWithShippingDetails",
    "SummaryReport",
    "List",
]

PRINT_PARAM_KEYS = [
    "model",
    "template",
    "templateKey",
    "templateModel",
    "printModel",
    "printTemplate",
    "printTemplateKey",
    "printTemplateModel",
    "layout",
    "layoutKey",
    "printLayout",
    "documentTemplate",
    "documentTemplateKey",
    "reportTemplate",
    "reportTemplateKey",
    "reportName",
    "printedReportName",
]

CATALOG_API_PATHS = [
    "/reporting/templates/list?listname=templates",
    "/reporting/templates/list",
    "/reporting/templates?listname=templates",
    "/reporting/templates",
    "/reporting/templates/odata?listname=templates",
    "/reporting/templates/odata",
    "/reporting/templateModels/list?listname=templates",
    "/reporting/templateModels",
    "/reporting/templates/Document",
    "/reporting/templates/Slip",
    "/reporting/templates/SlipWithShippingDetails",
]


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),
            "orientation": "landscape" if w > h else "portrait",
            "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[:800].decode("utf-8", errors="replace")
        return info
    boxes = _extract_pdf_boxes(data)
    info["boxes"] = boxes
    mediaboxes = [b for b in boxes if _s(b.get("kind")).lower() == "mediabox"]
    if mediaboxes and not any(b.get("looks_a4") for b in mediaboxes):
        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


def _split_path_query(path: str) -> tuple[str, dict[str, str]]:
    if "?" not in path:
        return path, {}
    base, query = path.split("?", 1)
    params: dict[str, str] = {}
    for part in query.split("&"):
        if not part:
            continue
        if "=" in part:
            k, v = part.split("=", 1)
        else:
            k, v = part, ""
        params[k] = v
    return base, params


async def _raw_request(
    client: JasminClient,
    method: str,
    path: str,
    *,
    params: dict[str, Any] | None = None,
    json_body: Any = None,
    accept: str = "application/json",
    scope: str = "api",
) -> tuple[int, bytes, str, dict[str, str], str]:
    token = await client.get_token()
    path_base, path_params = _split_path_query(path)
    merged = {**path_params, **(params or {})}
    if scope == "ui":
        url = f"{client.config.base_url.rstrip('/')}/{client.config.account}/{client.config.subscription}/{path_base.lstrip('/')}"
    else:
        url = f"{client.api_root}/{path_base.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=merged, 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", ""),
    }, str(response.url)


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 _search_hits(obj: Any, text_body: str, models: list[str]) -> list[str]:
    haystack = text_body
    if obj is not None:
        haystack = json.dumps(obj, ensure_ascii=False, default=_json_default)
    hl = haystack.lower()
    wanted = models + [
        "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",
    ]
    return [x for x in wanted if x.lower() in hl]


def _interesting_preview(obj: Any, text_body: str) -> str:
    if obj is None:
        return text_body[:1200]
    rx = re.compile(r"document|slip|summary|template|report|fatura|mercadorias|servi[cç]os|tal[aã]o|layout|print", re.I)
    records: list[Any] = []
    def walk(x: Any) -> None:
        if len(records) >= 25:
            return
        if isinstance(x, dict):
            txt = json.dumps(x, ensure_ascii=False, default=_json_default)
            if rx.search(txt):
                compact = {k: v for k, v in x.items() if rx.search(str(k)) or (isinstance(v, str) and rx.search(v))}
                records.append(compact or {k: v for k, v in list(x.items())[:10] if not isinstance(v, (list, dict))})
            for v in x.values():
                walk(v)
        elif isinstance(x, list):
            for v in x:
                walk(v)
    walk(obj)
    if records:
        return json.dumps(records[:10], ensure_ascii=False, default=_json_default)[:2000]
    return json.dumps(obj, ensure_ascii=False, default=_json_default)[:1200]


async def _probe_catalog_routes(client: JasminClient, models: list[str]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    route_specs: list[tuple[str, str]] = []
    for p in CATALOG_API_PATHS:
        route_specs.append(("api", p))
    # The exact browser route seen in the UI. This is likely a SPA route, but we test it because
    # it may still expose server-side JSON or useful hints in HTML/JS.
    route_specs.extend([
        ("ui", "/reporting/templates/list?listname=templates"),
        ("ui", "/reporting/templates/list"),
        ("ui", "/reporting/templates/Document"),
        ("ui", "/reporting/templates/Slip"),
        ("ui", "/reporting/templates/SlipWithShippingDetails"),
    ])
    for i, (scope, path) in enumerate(route_specs, start=1):
        try:
            status, content, content_type, headers, url = await _raw_request(client, "GET", path, accept="application/json,text/html", scope=scope)
            obj, text_body = _decode_json_or_text(content, content_type)
            hits = _search_hits(obj, text_body, models)
            row = {
                "scope": scope,
                "method": "GET",
                "path": path,
                "url": url,
                "status_code": status,
                "content_type": content_type,
                "bytes": len(content),
                "headers_subset": headers,
                "hits": hits,
                "preview": _interesting_preview(obj, text_body) 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"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: status={status} bytes={len(content)}{hit_txt}")
        except Exception as exc:
            rows.append({"scope": scope, "method": "GET", "path": path, "error": str(exc)})
            print(f"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: ERROR {exc}")
    return rows


def _get_variants(models: list[str]) -> list[dict[str, Any]]:
    variants: list[dict[str, Any]] = []
    for model in models:
        for key in PRINT_PARAM_KEYS:
            variants.append({"name": f"GET {key}={model}", "params": {key: model}})
        variants.append({"name": f"GET listname=templates&model={model}", "params": {"listname": "templates", "model": model}})
        variants.append({"name": f"GET listname=templates&template={model}", "params": {"listname": "templates", "template": model}})
    return variants


def _post_variants(models: list[str]) -> list[dict[str, Any]]:
    keys = ["model", "template", "templateKey", "printModel", "printTemplate", "layout", "printLayout"]
    variants: list[dict[str, Any]] = []
    for model in models:
        for key in keys:
            variants.append({"name": f"POST {key}={model}", "body": {key: model}})
        variants.append({"name": f"POST model+listname={model}", "body": {"listname": "templates", "model": model}})
    return variants


async def _probe_print_gets(client: JasminClient, invoice_id: str, models: 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 = _get_variants(models)[:max_variants]
    for i, v in enumerate(variants, start=1):
        try:
            status, content, content_type, headers, url = await _raw_request(
                client,
                "GET",
                f"/billing/invoices/{invoice_id}/print",
                params=v["params"],
                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_.-]+", "_", v["name"]).strip("_")[:120]
                (out_dir / f"get_{i:03d}_{safe}.pdf").write_bytes(content)
            rows.append({"name": v["name"], "params": v["params"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf})
            ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else ""
            print(f"GET {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}")
        except Exception as exc:
            rows.append({"name": v["name"], "params": v["params"], "error": str(exc)})
            print(f"GET {i:03d}/{len(variants)} {v['name']}: ERROR {exc}")
    return rows


async def _probe_print_posts(client: JasminClient, invoice_id: str, models: 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 = _post_variants(models)[:max_variants]
    for i, v in enumerate(variants, start=1):
        try:
            status, content, content_type, headers, url = 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_.-]+", "_", v["name"]).strip("_")[:120]
                (out_dir / f"post_{i:03d}_{safe}.pdf").write_bytes(content)
            rows.append({"name": v["name"], "body": v["body"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf})
            ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else ""
            print(f"POST {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}")
        except Exception as exc:
            rows.append({"name": v["name"], "body": v["body"], "error": str(exc)})
            print(f"POST {i:03d}/{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")

    catalog = result.get("catalog_probe") or []
    get_rows = result.get("print_get_probe") or []
    post_rows = result.get("print_post_probe") or []
    good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
    hits = [r for r in catalog if r.get("hits")]
    readable = [r for r in catalog if int(r.get("status_code") or 0) == 200]

    lines = [
        "# Probe Jasmin invoice print template model keys",
        "",
        f"- Invoice input: `{result.get('invoice_input')}`",
        f"- Invoice external id: `{result.get('invoice_external_id')}`",
        f"- Model keys tested: `{', '.join(result.get('models') or [])}`",
        f"- Catalog routes 200: `{len(readable)}`",
        f"- Catalog/model hits: `{len(hits)}`",
        f"- Print GET variants: `{len(get_rows)}`",
        f"- Print POST variants: `{len(post_rows)}`",
        f"- A4/OK variants: `{len(good)}`",
        "",
        "## Recomendação",
        result.get("recommendation") or "",
        "",
    ]
    if good:
        lines.append("## Variantes que devolveram A4")
        for r in good[:20]:
            detail = r.get("params") or r.get("body")
            lines.append(f"- `{r.get('name')}` `{detail}` → `{_box_summary(r.get('pdf') or {})}`")
        lines.append("")
    if hits:
        lines.append("## Catalog routes com hits")
        for r in hits[:20]:
            lines.append(f"- `{r.get('scope')} {r.get('path')}` status={r.get('status_code')} hits={r.get('hits')}")
            prev = (r.get("preview") or "")[:700].replace("\n", " ")
            if prev:
                lines.append(f"  - preview: `{prev}`")
        lines.append("")
    lines.append("## Primeiras variantes GET")
    for r in get_rows[:30]:
        lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
    if post_rows:
        lines.append("")
        lines.append("## Primeiras variantes POST")
        for r in post_rows[:30]:
            lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
    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", "name_or_path", "status_code", "content_type", "bytes", "warning", "a4", "hits", "summary"])
        writer.writeheader()
        for r in catalog:
            writer.writerow({
                "kind": "catalog",
                "name_or_path": f"{r.get('scope')} {r.get('path')}",
                "status_code": r.get("status_code"),
                "content_type": r.get("content_type"),
                "bytes": r.get("bytes"),
                "warning": r.get("error") or "",
                "a4": "",
                "hits": "; ".join(r.get("hits") or []),
                "summary": (r.get("preview") or "")[:300].replace("\n", " "),
            })
        for kind, rows in (("print_get", get_rows), ("print_post", post_rows)):
            for r in rows:
                p = r.get("pdf") or {}
                boxes = p.get("boxes") or []
                writer.writerow({
                    "kind": kind,
                    "name_or_path": r.get("name"),
                    "status_code": r.get("status_code"),
                    "content_type": p.get("content_type") or r.get("content_type"),
                    "bytes": p.get("bytes"),
                    "warning": p.get("format_warning") or r.get("error") or "",
                    "a4": any(b.get("looks_a4") for b in boxes),
                    "hits": "",
                    "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:
    models = [x.strip() for x in (args.models.split("|") if args.models else DEFAULT_MODELS) 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)}
    if not invoice_id:
        raise SystemExit("Indica --invoice ou --invoice-external-id")

    catalog_rows = [] if args.skip_catalog else await _probe_catalog_routes(client, models)
    get_rows = await _probe_print_gets(client, invoice_id, models, args.save_pdf_dir, args.max_get_variants)
    post_rows: list[dict[str, Any]] = []
    if args.probe_post:
        if args.confirm_invoice != args.invoice:
            raise SystemExit("Para --probe-post passa --confirm-invoice igual ao valor de --invoice.")
        post_rows = await _probe_print_posts(client, invoice_id, models, args.save_pdf_dir, args.max_post_variants)

    good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
    hits = [r for r in catalog_rows if r.get("hits")]
    recommendation = (
        "Encontrada variante de impressão A4. Adaptar ClientFlow para usar os parâmetros/body indicados no JSON/Markdown."
        if good else
        "Os modelos visíveis na UI foram testados por chave interna (Document/Slip/SlipWithShippingDetails), mas nenhuma variante devolveu A4. A seleção de modelo parece depender da configuração Jasmin ou de endpoint interno não exposto. Mantém bloqueio de PDF não-A4 no ClientFlow e muda o modelo padrão da série/tipo FA no Jasmin."
    )
    if hits and not good:
        recommendation += " Foram encontrados sinais de catálogo, mas ainda não houve parâmetro /print que selecionasse o modelo A4."

    result = {
        "resolved_at": datetime.utcnow().isoformat() + "Z",
        "invoice_input": args.invoice,
        "invoice_external_id": invoice_id,
        "models": models,
        "local_invoice": local or {},
        "remote_invoice": remote_invoice,
        "catalog_probe": catalog_rows,
        "print_get_probe": get_rows,
        "print_post_probe": post_rows,
        "recommendation": recommendation,
    }
    print("SUMMARY")
    print(f"catalog_routes={len(catalog_rows)} catalog_hits={len(hits)} get_variants={len(get_rows)} post_variants={len(post_rows)} a4_ok={len(good)}")
    print(recommendation)
    _write_outputs(result)
    return 0


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="Probe Jasmin /print using UI template model keys such as Document and Slip.")
    p.add_argument("--invoice", default="", help="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("--models", default="|".join(DEFAULT_MODELS), help="Pipe-separated model keys to test")
    p.add_argument("--skip-catalog", action="store_true", help="Skip /reporting/templates catalog route probes")
    p.add_argument("--max-get-variants", type=int, default=140)
    p.add_argument("--probe-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-post")
    p.add_argument("--max-post-variants", type=int, default=60)
    p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by print probes")
    return p


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


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