"""将标题、段落、分页、表格与本地图片写入 .docx（python-docx）。"""

from __future__ import annotations

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

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor

MAX_BLOCKS = 200
MAX_IMAGE_BYTES = 25 * 1024 * 1024
MAX_TABLE_ROWS = 80
MAX_TABLE_COLS = 20
MAX_CELL_CHARS = 8000


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):
                s = rr.strip()
                if s:
                    try:
                        bc["rows"] = json.loads(s)
                    except json.JSONDecodeError:
                        return None, f"blocks[{i}]: table.rows is not valid JSON"
        out.append(bc)
    return out, None


def _hex_rgb(color_hex: str) -> RGBColor | None:
    h = color_hex.strip().lstrip("#")
    if len(h) != 6:
        return None
    try:
        return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
    except ValueError:
        return None


def _parse_alignment(raw: Any) -> WD_ALIGN_PARAGRAPH | None:
    if raw is None:
        return None
    s = str(raw).strip().lower()
    m = {
        "left": WD_ALIGN_PARAGRAPH.LEFT,
        "center": WD_ALIGN_PARAGRAPH.CENTER,
        "right": WD_ALIGN_PARAGRAPH.RIGHT,
        "justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
    }
    return m.get(s)


def _heading_pt(level: int) -> float:
    return {1: 22.0, 2: 16.0, 3: 14.0, 4: 12.0, 5: 11.0, 6: 11.0}.get(
        max(1, min(9, level)), 11.0
    )


def _ensure_rfonts(rPr: Any) -> Any:
    el = rPr.find(qn("w:rFonts"))
    if el is None:
        el = OxmlElement("w:rFonts")
        rPr.insert(0, el)
    return el


def ensure_paragraph_style_rPr(style: Any) -> Any:
    """
    段落样式 ``style.element`` 上可能没有 ``w:rPr``（新建 ``Document()`` 的 **Normal** 常见），
    此时 ``style.element.rPr`` 为 ``None``，若再访问 ``.rFonts`` 会 **AttributeError**。
    本函数保证返回可用的 ``rPr`` 元素（供 ``_ensure_rfonts`` / 东亚字体等）。
    """
    se = style.element
    rPr = getattr(se, "rPr", None)
    if rPr is None:
        rPr = OxmlElement("w:rPr")
        se.append(rPr)
    return rPr


def set_paragraph_style_east_asia_font(
    style: Any,
    east_asia: str,
    *,
    latin: str | None = None,
) -> None:
    """在段落**样式**上设置 ``w:eastAsia``（含补全 ``w:rPr``），避免手写 ``.element.rPr.rFonts`` 踩空。"""
    ea = east_asia.strip()
    if not ea:
        return
    lat = (latin or "").strip() or None
    base = lat or ea
    rPr = ensure_paragraph_style_rPr(style)
    rf = _ensure_rfonts(rPr)
    rf.set(qn("w:ascii"), base)
    rf.set(qn("w:hAnsi"), base)
    rf.set(qn("w:eastAsia"), ea)
    rf.set(qn("w:cs"), ea)
    try:
        style.font.name = base
    except (AttributeError, TypeError):
        pass


def _apply_run_font(
    run: Any,
    *,
    east_asia: str | None,
    latin: str | None,
    size_pt: float | None,
    bold: bool | None,
    rgb: RGBColor | None,
) -> None:
    if east_asia or latin:
        base = latin or east_asia or "Calibri"
        run.font.name = base
        if east_asia:
            rPr = run._element.get_or_add_rPr()
            rf = _ensure_rfonts(rPr)
            rf.set(qn("w:ascii"), base)
            rf.set(qn("w:hAnsi"), base)
            rf.set(qn("w:eastAsia"), east_asia)
            rf.set(qn("w:cs"), east_asia)
    if size_pt is not None:
        run.font.size = Pt(size_pt)
    if bold is not None:
        run.font.bold = bold
    if rgb is not None:
        run.font.color.rgb = rgb


def _format_paragraph(
    para: Any,
    *,
    east_asia: str | None,
    latin: str | None,
    size_pt: float | None,
    bold: bool | None,
    rgb: RGBColor | None,
    alignment: WD_ALIGN_PARAGRAPH | None,
) -> None:
    if alignment is not None:
        para.alignment = alignment
    if not para.runs:
        run = para.add_run("")
    else:
        run = None
    targets = list(para.runs) if para.runs else []
    if run is not None:
        targets = [run]
    for r in targets:
        _apply_run_font(
            r,
            east_asia=east_asia,
            latin=latin,
            size_pt=size_pt,
            bold=bold,
            rgb=rgb,
        )


@dataclass
class _DocDefaults:
    east_asia: str | None = None
    latin: str | None = None
    color: RGBColor | None = None
    body_pt: float = 10.5


def docx_compose(
    docx_path: Path,
    blocks: list[dict[str, Any]],
    *,
    policy_check: Callable[[Path], None],
    append: bool = False,
    default_east_asia_font: str | None = None,
    default_latin_font: str | None = None,
    default_color_hex: str | None = None,
    default_body_font_pt: float | None = None,
) -> dict[str, Any]:
    """
    ``blocks`` 元素 ``type``:

    - ``heading``: ``text``, ``level`` (1–9)；可选 ``font_size_pt``、``bold``、``center``、
      ``alignment`` (left|center|right|justify)、``east_asia_font``、``latin_font``、``color_hex``
    - ``paragraph``: ``text``；可选 ``center``、``alignment``、``font_size_pt``、``bold``、
      ``east_asia_font``、``latin_font``、``color_hex``
    - ``table``: ``rows`` 为二维字符串数组（矩形）；可选 ``header_row``（首行加粗）、
      ``table_style``（默认 ``Table Grid``）、``caption``（表下说明段落）
    - ``image``: ``path``；可选 ``width_cm``、``caption``
    - ``page_break``: 分页

    文档级默认（中文正文推荐）：传入 ``default_east_asia_font='宋体'`` 与 ``default_color_hex='000000'``，
    会为标题/段落/表格单元格设置 **w:eastAsia**（东亚字体）及颜色；比手写 ``script_run`` 更可靠。
    """
    path = docx_path.expanduser().resolve()
    policy_check(path)
    if path.suffix.lower() != ".docx":
        return {"ok": False, "error": "path must end with .docx"}

    nb, err = _norm_blocks(blocks)
    if err:
        return {"ok": False, "error": err}
    assert nb is not None

    ea = (default_east_asia_font or "").strip() or None
    lat = (default_latin_font or "").strip() or None
    ch = (default_color_hex or "").strip() or None
    rgb_d = _hex_rgb(ch) if ch else None
    body_pt = float(default_body_font_pt) if default_body_font_pt is not None else 10.5
    body_pt = max(6.0, min(72.0, body_pt))
    defaults = _DocDefaults(
        east_asia=ea, latin=lat, color=rgb_d, body_pt=body_pt
    )

    path.parent.mkdir(parents=True, exist_ok=True)
    opened_existing = append and path.is_file()
    if opened_existing:
        doc = Document(str(path))
    else:
        doc = Document()

    images_added = 0
    tables_added = 0

    def _blk_str(b: dict[str, Any], key: str, default: str | None = None) -> str | None:
        v = b.get(key)
        if v is None:
            return default
        s = str(v).strip()
        return s if s else default

    for i, b in enumerate(nb):
        t = str(b.get("type") or "").strip().lower()
        if t == "heading":
            lvl = int(b.get("level", 1))
            lvl = max(1, min(9, lvl))
            para = doc.add_heading(str(b.get("text") or ""), level=lvl)
            fs = b.get("font_size_pt")
            size_pt = float(fs) if fs is not None else _heading_pt(lvl)
            size_pt = max(6.0, min(72.0, size_pt))
            bold = b.get("bold")
            if bold is None:
                bold = True
            else:
                bold = bool(bold)
            ea_b = _blk_str(b, "east_asia_font") or defaults.east_asia
            lat_b = _blk_str(b, "latin_font") or defaults.latin
            ch_b = _blk_str(b, "color_hex")
            rgb = _hex_rgb(ch_b) if ch_b else defaults.color
            al = None
            if bool(b.get("center")):
                al = WD_ALIGN_PARAGRAPH.CENTER
            if al is None:
                al = _parse_alignment(b.get("alignment"))
            _format_paragraph(
                para,
                east_asia=ea_b,
                latin=lat_b,
                size_pt=size_pt,
                bold=bold,
                rgb=rgb,
                alignment=al,
            )
        elif t == "paragraph":
            para = doc.add_paragraph(str(b.get("text") or ""))
            fs = b.get("font_size_pt")
            size_pt = float(fs) if fs is not None else defaults.body_pt
            size_pt = max(6.0, min(72.0, size_pt))
            bold_v = b.get("bold")
            bold = bool(bold_v) if bold_v is not None else False
            ea_b = _blk_str(b, "east_asia_font") or defaults.east_asia
            lat_b = _blk_str(b, "latin_font") or defaults.latin
            ch_b = _blk_str(b, "color_hex")
            rgb = _hex_rgb(ch_b) if ch_b else defaults.color
            al = None
            if bool(b.get("center")):
                al = WD_ALIGN_PARAGRAPH.CENTER
            if al is None:
                al = _parse_alignment(b.get("alignment"))
            if al is None and defaults.east_asia:
                al = WD_ALIGN_PARAGRAPH.JUSTIFY
            _format_paragraph(
                para,
                east_asia=ea_b,
                latin=lat_b,
                size_pt=size_pt,
                bold=bold,
                rgb=rgb,
                alignment=al,
            )
        elif t == "page_break":
            doc.add_page_break()
        elif t == "table":
            raw_rows = b.get("rows")
            if not isinstance(raw_rows, list) or not raw_rows:
                return {
                    "ok": False,
                    "error": (
                        f"blocks[{i}]: table.rows must be a non-empty array of row arrays; "
                        "remove the table block or add at least one data row"
                    ),
                }
            rows_norm: list[list[str]] = []
            for ri, row in enumerate(raw_rows):
                if not isinstance(row, (list, tuple)):
                    return {
                        "ok": False,
                        "error": f"blocks[{i}]: table.rows[{ri}] must be array",
                    }
                rows_norm.append([str(c) for c in row])
            nrows = len(rows_norm)
            ncols = len(rows_norm[0])
            if nrows > MAX_TABLE_ROWS or ncols > MAX_TABLE_COLS or ncols < 1:
                return {
                    "ok": False,
                    "error": f"blocks[{i}]: table size {nrows}x{ncols} out of range",
                }
            for ri, row in enumerate(rows_norm):
                if len(row) != ncols:
                    return {
                        "ok": False,
                        "error": f"blocks[{i}]: ragged row at index {ri}",
                    }
                for ci, cell in enumerate(row):
                    if len(cell) > MAX_CELL_CHARS:
                        return {
                            "ok": False,
                            "error": f"blocks[{i}]: cell too long at ({ri},{ci})",
                        }
            style_name = _blk_str(b, "table_style") or "Table Grid"
            tbl = doc.add_table(rows=nrows, cols=ncols)
            try:
                tbl.style = style_name
            except (ValueError, KeyError):
                tbl.style = "Table Grid"
            header_row = bool(b.get("header_row", False))
            ch_tb = _blk_str(b, "color_hex")
            rgb_tbl = _hex_rgb(ch_tb) if ch_tb else defaults.color
            ea_tbl = _blk_str(b, "east_asia_font") or defaults.east_asia
            lat_tbl = _blk_str(b, "latin_font") or defaults.latin
            for ri, row_data in enumerate(rows_norm):
                row_cells = tbl.rows[ri].cells
                is_header = header_row and ri == 0
                for ci, text in enumerate(row_data):
                    cell = row_cells[ci]
                    cell.text = ""
                    cp = cell.paragraphs[0]
                    cp.add_run(text)
                    _format_paragraph(
                        cp,
                        east_asia=ea_tbl,
                        latin=lat_tbl,
                        size_pt=defaults.body_pt,
                        bold=True if is_header else False,
                        rgb=rgb_tbl,
                        alignment=WD_ALIGN_PARAGRAPH.CENTER
                        if is_header
                        else WD_ALIGN_PARAGRAPH.LEFT,
                    )
            cap = b.get("caption")
            if cap:
                cap_p = doc.add_paragraph(str(cap))
                _format_paragraph(
                    cap_p,
                    east_asia=defaults.east_asia,
                    latin=defaults.latin,
                    size_pt=defaults.body_pt,
                    bold=False,
                    rgb=defaults.color,
                    alignment=WD_ALIGN_PARAGRAPH.CENTER,
                )
            tables_added += 1
        elif t == "image":
            ip = Path(str(b.get("path") or "")).expanduser().resolve()
            policy_check(ip)
            if not ip.is_file():
                return {"ok": False, "error": f"image not found: {ip}"}
            sz = ip.stat().st_size
            if sz > MAX_IMAGE_BYTES:
                return {
                    "ok": False,
                    "error": f"image too large ({sz} bytes > {MAX_IMAGE_BYTES})",
                }
            # 默认略大于原 16cm，上限接近 A4 可打印宽度；高分辨率 PNG 需配合较大宽度以免被缩糊
            w_cm = float(b.get("width_cm", 17.0))
            w_cm = max(3.0, min(19.5, w_cm))
            doc.add_picture(str(ip), width=Cm(w_cm))
            cap = b.get("caption")
            if cap:
                p = doc.add_paragraph(str(cap))
                p.alignment = WD_ALIGN_PARAGRAPH.CENTER
                if defaults.east_asia or defaults.color:
                    _format_paragraph(
                        p,
                        east_asia=defaults.east_asia,
                        latin=defaults.latin,
                        size_pt=defaults.body_pt,
                        bold=False,
                        rgb=defaults.color,
                        alignment=WD_ALIGN_PARAGRAPH.CENTER,
                    )
            images_added += 1
        else:
            return {"ok": False, "error": f"blocks[{i}]: unknown type {t!r}"}

    doc.save(str(path))
    return {
        "ok": True,
        "path": str(path),
        "append_mode": append,
        "opened_existing": opened_existing,
        "blocks_applied": len(nb),
        "images_added": images_added,
        "tables_added": tables_added,
        "defaults_applied": bool(ea or lat or rgb_d),
    }
