"""DOCX building helpers for the KeluargaMart audit document.

Design notes
------------
* Every heading uses a real Word Heading style so the navigation pane and the
  TOC field work.
* Tables use a repeating header row and a fixed layout so long inventories stay
  readable across page breaks.
* Wide inventories (data dictionary, API index, route index) are emitted in
  landscape sections.
* A "dry run" mode records figure/table captions without touching the document,
  so the List of Figures / List of Tables in the front matter can be generated
  in a second pass.
"""
from __future__ import annotations

import struct
from copy import deepcopy
from pathlib import Path

from docx import Document
from docx.enum.section import WD_ORIENT, WD_SECTION
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor, Emu

ACCENT = RGBColor(0x1F, 0x3A, 0x5F)
MUTED = RGBColor(0x55, 0x5F, 0x6D)
WARN = RGBColor(0x9B, 0x2C, 0x2C)
OK = RGBColor(0x1E, 0x6B, 0x3A)
INFO = RGBColor(0x1F, 0x4E, 0x79)

BODY_FONT = "Calibri"
MONO_FONT = "Consolas"


def png_size(path: Path) -> tuple[int, int]:
    """Read PNG dimensions from the IHDR chunk (no Pillow dependency)."""
    with open(path, "rb") as fh:
        head = fh.read(24)
    if head[:8] != b"\x89PNG\r\n\x1a\n":
        raise ValueError(f"not a PNG: {path}")
    w, h = struct.unpack(">II", head[16:24])
    return w, h


def _shade(cell, hex_fill: str) -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    shd = OxmlElement("w:shd")
    shd.set(qn("w:val"), "clear")
    shd.set(qn("w:color"), "auto")
    shd.set(qn("w:fill"), hex_fill)
    tc_pr.append(shd)


def _repeat_header(row) -> None:
    tr_pr = row._tr.get_or_add_trPr()
    tbl_header = OxmlElement("w:tblHeader")
    tbl_header.set(qn("w:val"), "true")
    tr_pr.append(tbl_header)


def _cant_split(row) -> None:
    tr_pr = row._tr.get_or_add_trPr()
    cant = OxmlElement("w:cantSplit")
    tr_pr.append(cant)


def _cell_margins(table, top=40, bottom=40, left=80, right=80) -> None:
    tbl_pr = table._tbl.tblPr
    mar = OxmlElement("w:tblCellMar")
    for tag, val in (("top", top), ("left", left), ("bottom", bottom), ("right", right)):
        el = OxmlElement(f"w:{tag}")
        el.set(qn("w:w"), str(val))
        el.set(qn("w:type"), "dxa")
        mar.append(el)
    tbl_pr.append(mar)


def _field(paragraph, instr: str) -> None:
    run = paragraph.add_run()
    fld_begin = OxmlElement("w:fldChar")
    fld_begin.set(qn("w:fldCharType"), "begin")
    run._r.append(fld_begin)
    instr_el = OxmlElement("w:instrText")
    instr_el.set(qn("xml:space"), "preserve")
    instr_el.text = instr
    run._r.append(instr_el)
    fld_sep = OxmlElement("w:fldChar")
    fld_sep.set(qn("w:fldCharType"), "separate")
    run._r.append(fld_sep)
    text = OxmlElement("w:t")
    text.text = "1"
    run._r.append(text)
    fld_end = OxmlElement("w:fldChar")
    fld_end.set(qn("w:fldCharType"), "end")
    run._r.append(fld_end)


class Builder:
    def __init__(self, out_path: Path, dry: bool = False):
        self.out = out_path
        self.dry = dry
        self.doc = Document() if not dry else None
        self.figures: list[tuple[str, str]] = []
        self.tables: list[tuple[str, str]] = []
        self.headings: list[tuple[int, str, str]] = []  # (level, text, bookmark)
        self._bm_id = 1000
        self._after_section = False
        self.fig_n = 0
        self.tab_n = 0
        self._landscape = False
        if not dry:
            self._setup()
            self._metadata()

    # ---------------------------------------------------------------- setup
    def _setup(self) -> None:
        doc = self.doc
        sec = doc.sections[0]
        sec.page_width = Inches(8.5)
        sec.page_height = Inches(11)
        sec.left_margin = Inches(0.9)
        sec.right_margin = Inches(0.9)
        sec.top_margin = Inches(0.85)
        sec.bottom_margin = Inches(0.8)

        normal = doc.styles["Normal"]
        normal.font.name = BODY_FONT
        normal.font.size = Pt(10)
        normal.paragraph_format.space_after = Pt(6)
        normal.paragraph_format.line_spacing = 1.12

        for name, size, color, before, after in (
            ("Heading 1", 20, ACCENT, 18, 8),
            ("Heading 2", 14, ACCENT, 14, 5),
            ("Heading 3", 11.5, MUTED, 10, 3),
            ("Heading 4", 10.5, MUTED, 8, 2),
        ):
            st = doc.styles[name]
            st.font.name = BODY_FONT
            st.font.size = Pt(size)
            st.font.bold = True
            st.font.color.rgb = color
            st.paragraph_format.space_before = Pt(before)
            st.paragraph_format.space_after = Pt(after)
            st.paragraph_format.keep_with_next = True

        for name in ("List Bullet", "List Number"):
            st = doc.styles[name]
            st.font.name = BODY_FONT
            st.font.size = Pt(10)
            st.paragraph_format.space_after = Pt(1)
            st.paragraph_format.space_before = Pt(0)
            st.paragraph_format.line_spacing = 1.05

        # Header / footer
        hdr = sec.header.paragraphs[0]
        hdr.text = "KeluargaMart Monorepo — Complete Technical & Operational Reference"
        hdr.alignment = WD_ALIGN_PARAGRAPH.RIGHT
        for run in hdr.runs:
            run.font.size = Pt(8)
            run.font.color.rgb = MUTED
        ftr = sec.footer.paragraphs[0]
        ftr.alignment = WD_ALIGN_PARAGRAPH.CENTER
        run = ftr.add_run("KeluargaMart • v1.0 • ")
        run.font.size = Pt(8)
        run.font.color.rgb = MUTED
        _field(ftr, "PAGE")
        run2 = ftr.add_run(" / ")
        run2.font.size = Pt(8)
        run2.font.color.rgb = MUTED
        _field(ftr, "NUMPAGES")

    def _metadata(self) -> None:
        cp = self.doc.core_properties
        cp.title = "KeluargaMart Monorepo — Complete Documentation"
        cp.subject = ("Technical, operational and business reference for the KeluargaMart "
                      "monorepo at commit 288b6ce")
        cp.author = "KeluargaMart repository audit"
        cp.last_modified_by = "docs/docgen/build_doc.py"
        cp.category = "Technical documentation"
        cp.keywords = ("KeluargaMart; monorepo; Go; Vue; Nuxt; PostgreSQL; RBAC; audit; "
                       "database dictionary; API reference")
        cp.comments = ("Generated from the repository at commit "
                       "288b6cea77c3aa8c77e314f0bbef48d7bf35828e. "
                       "Regenerate with python3 docs/docgen/build_doc.py")
        cp.revision = 1

    # ------------------------------------------------------------ headings
    def _heading(self, text: str, level: int, want_break: bool = False) -> None:
        if self.dry:
            return
        para = self.doc.add_heading(text, level=level)
        if want_break:
            para.paragraph_format.page_break_before = True
        self._bm_id += 1
        name = f"_Toc{self._bm_id}"
        start = OxmlElement("w:bookmarkStart")
        start.set(qn("w:id"), str(self._bm_id))
        start.set(qn("w:name"), name)
        end = OxmlElement("w:bookmarkEnd")
        end.set(qn("w:id"), str(self._bm_id))
        para._p.insert(0, start)
        para._p.append(end)
        if level <= 2:
            self.headings.append((level, text, name))
        return para

    def h1(self, text: str, page_break: bool = True) -> None:
        """Top-level heading, starting a new page unless one just started.

        A page break is expressed as `page_break_before` on the heading itself
        rather than as an empty break paragraph: if the heading already lands at
        the top of a page (because the previous section ended there), Word and
        LibreOffice collapse it instead of emitting a blank page.
        """
        if self.dry:
            return
        para = self._heading(text, 1, want_break=page_break and not self._after_section)
        self._after_section = False

    def h2(self, text: str) -> None:
        self._heading(text, 2)

    def h3(self, text: str) -> None:
        self._heading(text, 3)

    def h4(self, text: str) -> None:
        self._heading(text, 4)

    # --------------------------------------------------------------- prose
    def p(self, text: str, *, italic: bool = False, size: float | None = None,
          color: RGBColor | None = None, space_after: float | None = None,
          align=None) -> None:
        if self.dry:
            return
        para = self.doc.add_paragraph()
        run = para.add_run(text)
        run.italic = italic
        if size:
            run.font.size = Pt(size)
        if color:
            run.font.color.rgb = color
        if space_after is not None:
            para.paragraph_format.space_after = Pt(space_after)
        if align == "center":
            para.alignment = WD_ALIGN_PARAGRAPH.CENTER
        return para

    def rich(self, parts: list[tuple[str, dict]]) -> None:
        """Paragraph from (text, {bold,italic,mono,color}) tuples."""
        if self.dry:
            return
        para = self.doc.add_paragraph()
        for text, fmt in parts:
            run = para.add_run(text)
            run.bold = fmt.get("bold", False)
            run.italic = fmt.get("italic", False)
            if fmt.get("mono"):
                run.font.name = MONO_FONT
                run.font.size = Pt(8.8)
            if fmt.get("color"):
                run.font.color.rgb = fmt["color"]
        return para

    def bullets(self, items: list[str], style: str = "List Bullet") -> None:
        if self.dry:
            return
        for item in items:
            self.doc.add_paragraph(item, style=style)

    def numbered(self, items: list[str]) -> None:
        self.bullets(items, style="List Number")

    def code(self, text: str, caption: str | None = None) -> None:
        if caption:
            self.tab_n += 1
            self.tables.append((f"Table {self.tab_n}", caption))
        if self.dry:
            return
        for line in text.rstrip("\n").split("\n"):
            para = self.doc.add_paragraph()
            para.paragraph_format.space_after = Pt(0)
            para.paragraph_format.space_before = Pt(0)
            para.paragraph_format.left_indent = Inches(0.15)
            run = para.add_run(line if line else " ")
            run.font.name = MONO_FONT
            run.font.size = Pt(8.2)
            run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x1A)
        if caption:
            self.p(f"Table {self.tab_n}. {caption}", italic=True, size=8.5,
                   color=MUTED, space_after=10)

    def note(self, label: str, text: str, kind: str = "note") -> None:
        """Callout paragraph. kind: note | verified | inferred | gap | warning | recommendation"""
        colors = {
            "note": INFO,
            "verified": OK,
            "inferred": MUTED,
            "gap": WARN,
            "warning": WARN,
            "recommendation": ACCENT,
        }
        if self.dry:
            return
        para = self.doc.add_paragraph()
        para.paragraph_format.left_indent = Inches(0.15)
        para.paragraph_format.space_after = Pt(8)
        r1 = para.add_run(f"{label}: ")
        r1.bold = True
        r1.font.color.rgb = colors.get(kind, INFO)
        r1.font.size = Pt(9.5)
        r2 = para.add_run(text)
        r2.font.size = Pt(9.5)
        p_pr = para._p.get_or_add_pPr()
        borders = OxmlElement("w:pBdr")
        left = OxmlElement("w:left")
        left.set(qn("w:val"), "single")
        left.set(qn("w:sz"), "18")
        left.set(qn("w:space"), "6")
        left.set(qn("w:color"), "%02X%02X%02X" % (
            colors.get(kind, INFO)[0], colors.get(kind, INFO)[1], colors.get(kind, INFO)[2]))
        borders.append(left)
        p_pr.append(borders)

    # -------------------------------------------------------------- tables
    def table(self, headers: list[str], rows: list[list[str]], *,
              caption: str | None = None, widths: list[float] | None = None,
              font_size: float = 8.2, header_fill: str = "1F3A5F") -> None:
        if caption:
            self.tab_n += 1
            self.tables.append((f"Table {self.tab_n}", caption))
        if self.dry:
            return
        ncols = len(headers)
        table = self.doc.add_table(rows=1, cols=ncols)
        table.style = "Table Grid"
        table.alignment = WD_TABLE_ALIGNMENT.CENTER
        table.autofit = False
        _cell_margins(table)
        hdr = table.rows[0]
        _repeat_header(hdr)
        for i, text in enumerate(headers):
            cell = hdr.cells[i]
            cell.text = ""
            para = cell.paragraphs[0]
            run = para.add_run(str(text))
            run.bold = True
            run.font.size = Pt(font_size)
            run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
            para.paragraph_format.space_after = Pt(0)
            _shade(cell, header_fill)
        for row_data in rows:
            row = table.add_row()
            _cant_split(row)
            for i, value in enumerate(row_data):
                cell = row.cells[i]
                cell.text = ""
                para = cell.paragraphs[0]
                run = para.add_run("" if value is None else str(value))
                run.font.size = Pt(font_size)
                para.paragraph_format.space_after = Pt(0)
        if widths:
            # Scale the declared widths so the row always fits the printable
            # area: portrait 8.5 - 2*0.9 = 6.7in, landscape 11 - 2*0.6 = 9.8in.
            avail = 9.8 if self._landscape else 6.7
            total = sum(widths)
            scale = avail / total if total > avail else 1.0
            widths = [w * scale for w in widths]
            for row in table.rows:
                for i, w in enumerate(widths):
                    if i < len(row.cells):
                        row.cells[i].width = Inches(w)
            tbl_w = OxmlElement("w:tblW")
            tbl_w.set(qn("w:w"), str(int(sum(widths) * 1440)))
            tbl_w.set(qn("w:type"), "dxa")
            table._tbl.tblPr.append(tbl_w)
        if caption:
            self.p(f"Table {self.tab_n}. {caption}", italic=True, size=8.5,
                   color=MUTED, space_after=10)

    # ------------------------------------------------------------- figures
    def figure(self, path: Path, caption: str, width: float | None = None,
               note: str | None = None) -> None:
        """Embed a figure with its caption.

        Width is clamped so the image plus its caption always fit on one page:
        a tall image is scaled down rather than pushed onto a page of its own,
        which otherwise leaves an image-only page and an orphaned caption.
        """
        if not path.is_absolute():
            path = (Path(__file__).resolve().parent / path).resolve()
        if not path.exists():
            raise FileNotFoundError(f"figure missing: {path}")
        self.fig_n += 1
        self.figures.append((f"Figure {self.fig_n}", caption))
        if self.dry:
            return
        pw, ph = png_size(path)
        max_w = 9.0 if self._landscape else 6.6
        # usable height: page height minus margins, caption, and the heading above
        max_h = 5.9 if self._landscape else 7.4
        if width is None:
            width = max_w
        width = min(width, max_w)
        if ph / pw * width > max_h:
            width = max_h * pw / ph
        para = self.doc.add_paragraph()
        para.alignment = WD_ALIGN_PARAGRAPH.CENTER
        para.paragraph_format.space_before = Pt(4)
        para.paragraph_format.space_after = Pt(2)
        para.paragraph_format.keep_with_next = True
        para.add_run().add_picture(str(path), width=Inches(width))
        cap = self.p(f"Figure {self.fig_n}. {caption}", italic=True, size=8.5,
                     color=MUTED, space_after=2, align="center")
        if note:
            self.p(note, size=8.5, color=MUTED, space_after=10)

    # ------------------------------------------------------------- layout
    def landscape(self) -> None:
        if self.dry or self._landscape:
            return
        sec = self.doc.add_section(WD_SECTION.NEW_PAGE)
        sec.orientation = WD_ORIENT.LANDSCAPE
        sec.page_width = Inches(11)
        sec.page_height = Inches(8.5)
        sec.left_margin = Inches(0.6)
        sec.right_margin = Inches(0.6)
        sec.top_margin = Inches(0.7)
        sec.bottom_margin = Inches(0.6)
        self._landscape = True
        self._after_section = True

    def portrait(self) -> None:
        if self.dry or not self._landscape:
            return
        sec = self.doc.add_section(WD_SECTION.NEW_PAGE)
        sec.orientation = WD_ORIENT.PORTRAIT
        sec.page_width = Inches(8.5)
        sec.page_height = Inches(11)
        sec.left_margin = Inches(0.9)
        sec.right_margin = Inches(0.9)
        sec.top_margin = Inches(0.85)
        sec.bottom_margin = Inches(0.8)
        self._landscape = False
        self._after_section = True

    def page_break(self) -> None:
        if not self.dry:
            self.doc.add_page_break()

    def toc(self, entries=None) -> None:
        """Static, hyperlinked table of contents.

        A TOC *field* renders empty until Word refreshes it; this writes real
        entries with right-aligned dot leaders and internal hyperlinks so the
        document is navigable the moment it is opened, in Word and in
        LibreOffice alike.
        """
        if self.dry:
            return
        from docx.enum.text import WD_TAB_ALIGNMENT, WD_TAB_LEADER
        if not entries:
            para = self.doc.add_paragraph()
            _field(para, r'TOC \o "1-2" \h \z \u')
            return
        for level, text, bookmark, page in entries:
            para = self.doc.add_paragraph()
            fmt = para.paragraph_format
            fmt.space_after = Pt(0)
            fmt.space_before = Pt(2.5 if level == 1 else 0)
            fmt.keep_together = True
            fmt.left_indent = Inches(0.0 if level == 1 else 0.25)
            fmt.tab_stops.add_tab_stop(Inches(6.6), WD_TAB_ALIGNMENT.RIGHT,
                                       WD_TAB_LEADER.DOTS)
            link = OxmlElement("w:hyperlink")
            link.set(qn("w:anchor"), bookmark)
            run = OxmlElement("w:r")
            r_pr = OxmlElement("w:rPr")
            if level == 1:
                b = OxmlElement("w:b")
                r_pr.append(b)
            color = OxmlElement("w:color")
            color.set(qn("w:val"), "1F3A5F" if level == 1 else "333333")
            r_pr.append(color)
            run.append(r_pr)
            t = OxmlElement("w:t")
            t.text = text
            t.set(qn("xml:space"), "preserve")
            run.append(t)
            link.append(run)
            para._p.append(link)
            tail = para.add_run("\t" + str(page))
            tail.font.size = Pt(9 if level == 1 else 8.6)
            tail.bold = level == 1

    def save(self) -> None:
        if self.dry:
            return
        self.out.parent.mkdir(parents=True, exist_ok=True)
        self.doc.save(str(self.out))
