"""使用 **ReportLab** 按块拼装 PDF（复杂版式、表格、插图）。需 ``pip install aicd[pdf]``（含 reportlab）。"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Callable

PolicyCheck = Callable[[Path], None]

MAX_BLOCKS = 200
MAX_TABLE_ROWS = 60
MAX_TABLE_COLS = 16
MAX_IMAGE_BYTES = 25 * 1024 * 1024


def _norm_blocks(raw: Any) -> tuple[list[dict[str, Any]] | None, str | None]:
    if not isinstance(raw, list):
        return None, "blocks must be a JSON array"
    if len(raw) > MAX_BLOCKS:
        return None, f"at most {MAX_BLOCKS} blocks"
    out: list[dict[str, Any]] = []
    for i, b in enumerate(raw):
        if not isinstance(b, dict):
            return None, f"blocks[{i}] must be object"
        bc = dict(b)
        if str(bc.get("type") or "").strip().lower() == "table":
            rr = bc.get("rows")
            if isinstance(rr, str) and rr.strip():
                try:
                    bc["rows"] = json.loads(rr)
                except json.JSONDecodeError:
                    return None, f"blocks[{i}]: table.rows is not valid JSON"
        out.append(bc)
    return out, None


def _page_size(name: str) -> Any:
    from reportlab.lib import pagesizes

    n = (name or "a4").strip().lower()
    m = {
        "a4": pagesizes.A4,
        "letter": pagesizes.letter,
        "a3": pagesizes.A3,
    }
    return m.get(n, pagesizes.A4)


def pdf_compose(
    pdf_path: Path,
    blocks: list[dict[str, Any]],
    *,
    policy_check: PolicyCheck,
    page_size: str = "a4",
    margin_cm: float = 2.0,
    cjk_font_path: str | None = None,
    title: str | None = None,
) -> dict[str, Any]:
    try:
        from reportlab.lib import colors
        from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import cm as rl_cm
        from reportlab.pdfbase import pdfmetrics
        from reportlab.pdfbase.ttfonts import TTFont
        from reportlab.platypus import (
            Image as RLImage,
            PageBreak,
            Paragraph,
            SimpleDocTemplate,
            Spacer,
            Table,
            TableStyle,
        )
    except ImportError:
        return {
            "ok": False,
            "error": "reportlab not installed",
            "hint": 'pip install "aicd[pdf]"  （将安装 reportlab 与可选 docx2pdf）',
        }

    normed, err = _norm_blocks(blocks)
    if err:
        return {"ok": False, "error": err}

    dst = pdf_path.expanduser().resolve()
    policy_check(dst)
    policy_check(dst.parent)
    if dst.suffix.lower() != ".pdf":
        return {"ok": False, "error": "path must end with .pdf"}

    margin = max(0.5, float(margin_cm)) * rl_cm
    pagesize = _page_size(page_size)

    body_font = "Helvetica"
    body_name = "Helvetica"
    bold_font = "Helvetica-Bold"
    if cjk_font_path:
        fp = Path(cjk_font_path).expanduser().resolve()
        policy_check(fp)
        if not fp.is_file():
            return {"ok": False, "error": f"cjk_font_path not found: {fp}"}
        body_name = "AicdCJK"
        pdfmetrics.registerFont(TTFont(body_name, str(fp)))
        body_font = body_name
        bold_font = body_name

    styles = getSampleStyleSheet()
    styles.add(
        ParagraphStyle(
            name="BodyCustom",
            parent=styles["Normal"],
            fontName=body_font,
            fontSize=11,
            leading=14,
            alignment=TA_JUSTIFY,
        )
    )
    for lvl in range(1, 7):
        pt = {1: 18, 2: 15, 3: 13, 4: 12, 5: 11, 6: 11}[lvl]
        styles.add(
            ParagraphStyle(
                name=f"H{lvl}Custom",
                parent=styles["Heading1"],
                fontName=bold_font,
                fontSize=pt,
                leading=pt + 2,
                spaceAfter=6,
            )
        )

    def _align(s: str | None) -> int:
        m = {
            "left": TA_LEFT,
            "center": TA_CENTER,
            "right": TA_RIGHT,
            "justify": TA_JUSTIFY,
        }
        return m.get((s or "left").strip().lower(), TA_LEFT)

    story: list[Any] = []
    if title and str(title).strip():
        te = str(title).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
        story.append(
            Paragraph(
                te,
                ParagraphStyle(
                    name="DocTitleX",
                    fontName=bold_font,
                    fontSize=18,
                    leading=22,
                    spaceAfter=12,
                    alignment=TA_CENTER,
                ),
            )
        )

    for i, b in enumerate(normed or []):
        typ = str(b.get("type") or "").strip().lower()
        if typ == "heading":
            lvl = int(b.get("level") or 1)
            lvl = max(1, min(6, lvl))
            txt = str(b.get("text") or "")
            esc = txt.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
            story.append(Paragraph(esc, styles[f"H{lvl}Custom"]))
        elif typ == "paragraph":
            txt = str(b.get("text") or "")
            esc = txt.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace(
                "\n", "<br/>"
            )
            fs = b.get("font_size_pt")
            ps = ParagraphStyle(
                name=f"P{i}",
                parent=styles["BodyCustom"],
                fontName=body_font,
                fontSize=float(fs) if fs is not None else 11,
                leading=(float(fs) if fs is not None else 11) + 3,
                alignment=_align(b.get("alignment")),
            )
            story.append(Paragraph(esc, ps))
            story.append(Spacer(1, float(b.get("space_after_pt") or 6)))
        elif typ == "spacer":
            story.append(Spacer(1, float(b.get("height_pt") or 12)))
        elif typ == "page_break":
            story.append(PageBreak())
        elif typ == "image":
            ip = str(b.get("path") or "").strip()
            if not ip:
                return {"ok": False, "error": f"blocks[{i}]: image needs path"}
            pth = Path(ip).expanduser().resolve()
            policy_check(pth)
            if not pth.is_file():
                return {"ok": False, "error": f"image not found: {pth}"}
            sz = pth.stat().st_size
            if sz > MAX_IMAGE_BYTES:
                return {"ok": False, "error": f"image too large: {sz} bytes"}
            wcm = b.get("width_cm")
            kw: dict[str, Any] = {}
            if wcm is not None:
                kw["width"] = float(wcm) * rl_cm
            img = RLImage(str(pth), **kw)
            ha = str(b.get("hAlign") or "center").lower()
            if ha in ("center", "left", "right"):
                img.hAlign = ha.upper()
            story.append(img)
            cap = b.get("caption")
            if cap:
                c = str(cap).replace("&", "&amp;").replace("<", "&lt;")
                story.append(Paragraph(f"<i>{c}</i>", styles["BodyCustom"]))
            story.append(Spacer(1, 8))
        elif typ == "table":
            rows = b.get("rows")
            if not isinstance(rows, list) or not rows:
                return {"ok": False, "error": f"blocks[{i}]: table.rows required"}
            if len(rows) > MAX_TABLE_ROWS:
                return {"ok": False, "error": f"table: at most {MAX_TABLE_ROWS} rows"}
            ncol = max(len(r) for r in rows if isinstance(r, (list, tuple)))
            if ncol > MAX_TABLE_COLS:
                return {"ok": False, "error": f"table: at most {MAX_TABLE_COLS} cols"}
            cells: list[list[str]] = []
            for r in rows:
                if not isinstance(r, (list, tuple)):
                    return {"ok": False, "error": "table rows must be arrays"}
                row = [str(x) for x in r]
                while len(row) < ncol:
                    row.append("")
                cells.append(row[:ncol])
            data = [
                [
                    Paragraph(
                        c.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"),
                        styles["BodyCustom"],
                    )
                    for c in row
                ]
                for row in cells
            ]
            cw = b.get("col_widths")
            t_kw: dict[str, Any] = {}
            if isinstance(cw, list) and cw and len(cw) == ncol:
                total = sum(float(x) for x in cw if float(x) > 0) or 1.0
                avail = pagesize[0] - 2 * margin
                t_kw["colWidths"] = [avail * (float(w) / total) for w in cw]
            tbl = Table(data, **t_kw)
            st_cmds: list[Any] = [
                ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
                ("VALIGN", (0, 0), (-1, -1), "TOP"),
            ]
            if b.get("header_row"):
                st_cmds.append(("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey))
            tbl.setStyle(TableStyle(st_cmds))
            story.append(tbl)
            story.append(Spacer(1, 10))
        else:
            return {"ok": False, "error": f"blocks[{i}]: unknown type {typ!r}"}

    dst.parent.mkdir(parents=True, exist_ok=True)
    doc = SimpleDocTemplate(
        str(dst),
        pagesize=pagesize,
        leftMargin=margin,
        rightMargin=margin,
        topMargin=margin,
        bottomMargin=margin,
    )
    try:
        doc.build(story)
    except Exception as e:  # noqa: BLE001
        return {"ok": False, "error": repr(e), "hint": "CJK text may need cjk_font_path (TTF/OTF)."}
    if not dst.is_file() or dst.stat().st_size == 0:
        return {"ok": False, "error": "empty PDF"}
    return {"ok": True, "pdf_path": str(dst), "engine": "reportlab", "block_count": len(normed or [])}
