"""Declarative house-style dressing for build-corpus Word exports.

Two pieces, both opt-in via markdown frontmatter:

* **Table theming** -- coloured header row, hairline borders, zebra banding,
  cell margins and a content-aware column-width pass. Label/value (2-column)
  tables get a bold left column and reversed banding; matrix tables get a
  filled header row.
* **Header/footer chrome** -- left/right header text and a centered footer line
  with ``Page X of Y`` fields, both supporting ``{version}`` / ``{date}`` /
  ``{title}`` / ``{jurisdiction}`` substitution so a version bump is a one-line
  frontmatter change instead of a manual XML edit.

A project declares its look once in frontmatter::

    ---
    house_style: futurville-asp
    style_vars:
      version: v7.3
      date: June 2026
    ---

or overrides any field inline (``table.header_fill: "862633"`` etc.). Built-in
profiles cover the FuturVille ASP (maroon), FuturVille external (navy) and
Oliizoi (charcoal) looks. All measurements are emitted as integers and the
final ``sanitize_package`` pass guarantees schema-valid output.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field, replace
from pathlib import Path

try:  # pragma: no cover - exercised via package import
    from docx import Document
    from docx.oxml import OxmlElement
    from docx.oxml.ns import qn
    from docx.shared import RGBColor, Twips
except Exception:  # pragma: no cover
    Document = None  # type: ignore


# --------------------------------------------------------------------------- #
# Profile definition
# --------------------------------------------------------------------------- #
@dataclass
class HouseStyle:
    name: str = "custom"
    # table theme
    header_fill: str = "862633"        # matrix-table header row fill
    header_text: str = "FFFFFF"        # matrix-table header text colour
    label_color: str = "862633"        # bold left-column colour (label/value)
    border_color: str = "C7CED6"
    band_fill: str = "F5F7F9"          # zebra
    border_sz: int = 4                 # eighths of a point
    cell_margin: tuple[int, int, int, int] = (54, 110, 54, 110)  # top,left,bottom,right (dxa)
    label_value_split: float = 0.30    # left-column fraction for 2-col label/value tables
    band: bool = True
    # header/footer chrome (templated; {version} {date} {title} {jurisdiction})
    header_left: str | None = None
    header_right: str | None = None
    footer_center: str | None = None
    page_numbers: bool = True
    # words that mark a 2-col table as a real header table (not label/value)
    header_words: tuple[str, ...] = (
        "policy", "use", "setback", "dwelling type", "system", "phase",
        "land use area", "district / phase", "mdp policy area", "principle",
        "metric", "standard", "requirement", "stage",
    )


BUILTIN_PROFILES: dict[str, HouseStyle] = {
    "futurville-asp": HouseStyle(
        name="futurville-asp",
        header_fill="862633", header_text="FFFFFF", label_color="862633",
        border_color="C7CED6", band_fill="F5F7F9",
    ),
    "futurville-navy": HouseStyle(
        name="futurville-navy",
        header_fill="1B3A5C", header_text="FFFFFF", label_color="2A7D6B",
        border_color="C7CED6", band_fill="F3F7F6",
    ),
    "oliizoi": HouseStyle(
        name="oliizoi",
        header_fill="2C3E50", header_text="FFFFFF", label_color="425B6C",
        border_color="CBD5DD", band_fill="F4F6F8",
    ),
    # PRT (Planetary Regenerative Trust) navy baseline. A shipped starting point;
    # callers override any field via frontmatter or config house_style_values
    # (BC-0.9-11) — the palette is not locked in source.
    "prt": HouseStyle(
        name="prt",
        header_fill="1B3A5C", header_text="FFFFFF", label_color="2A7D6B",
        border_color="C7CED6", band_fill="F5F4EF",
    ),
}


def _as_bool(v: str | bool | None, default: bool) -> bool:
    if isinstance(v, bool):
        return v
    if v is None:
        return default
    return str(v).strip().lower() in {"1", "true", "yes", "on"}


def resolve_house_style(values: dict[str, str]) -> tuple[HouseStyle | None, dict[str, str]]:
    """Build a HouseStyle from flattened frontmatter ``values`` (or None).

    The house-style layer activates **only** when an explicit ``house_style:``
    key is present, so documents using the legacy ``header_left`` / ``footer_left``
    frontmatter-chrome path are never touched. With a profile selected, optional
    ``table.*`` / ``header_left`` / ``header_right`` / ``footer_center`` /
    ``page_numbers`` keys refine it, and ``style_vars.*`` feed substitution.

    Returns ``(style, style_vars)``.
    """
    name = values.get("house_style")
    if not name:
        return None, {}
    base = BUILTIN_PROFILES.get(name.strip().lower())
    style = replace(base) if base else HouseStyle(name=name.strip())

    overrides: dict = {}
    mapping = {
        "table.header_fill": ("header_fill", str),
        "table.header_text": ("header_text", str),
        "table.label_color": ("label_color", str),
        "table.border_color": ("border_color", str),
        "table.band_fill": ("band_fill", str),
        "table.border_sz": ("border_sz", int),
        "table.label_value_split": ("label_value_split", float),
        "table.band": ("band", lambda v: _as_bool(v, True)),
        "header_left": ("header_left", str),
        "header_right": ("header_right", str),
        "footer_center": ("footer_center", str),
        "page_numbers": ("page_numbers", lambda v: _as_bool(v, True)),
    }
    for key, (attr, cast) in mapping.items():
        if key in values:
            try:
                overrides[attr] = cast(values[key])
            except (TypeError, ValueError):
                pass
    if overrides:
        style = replace(style, **overrides)

    style_vars = {k.split(".", 1)[1]: v for k, v in values.items()
                  if k.startswith("style_vars.")}
    return style, style_vars


# --------------------------------------------------------------------------- #
# Fix C: WCAG luminance contrast guard for header fill vs header text
# --------------------------------------------------------------------------- #
def _rel_luminance(hex6: str) -> float:
    """WCAG 2.1 relative luminance of a ``RRGGBB`` colour."""
    h = (hex6 or "").strip().lstrip("#")
    if len(h) != 6:
        return 0.0
    try:
        r, g, b = (int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
    except ValueError:
        return 0.0
    def _lin(c: float) -> float:
        return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
    return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)


def _contrast_ratio(fill_hex: str, text_hex: str) -> float:
    """WCAG 2.1 contrast ratio (1.0 .. 21.0) between two colours."""
    a = _rel_luminance(fill_hex)
    b = _rel_luminance(text_hex)
    hi, lo = (a, b) if a >= b else (b, a)
    return (hi + 0.05) / (lo + 0.05)


def evaluate_header_contrast(
    style: HouseStyle, auto_contrast: bool = False
) -> tuple[HouseStyle, list[dict]]:
    """Check header text/fill contrast after override merge (Fix C).

    Returns ``(style, warnings)``. Below WCAG AA (4.5:1) a ``contrast`` warning
    is produced. With ``auto_contrast`` the header text is flipped to whichever of
    white/black clears 4.5:1 (respecting user intent by default: warn-only).
    """
    ratio = _contrast_ratio(style.header_fill, style.header_text)
    if ratio >= 4.5:
        return style, []
    if auto_contrast:
        white_r = _contrast_ratio(style.header_fill, "FFFFFF")
        black_r = _contrast_ratio(style.header_fill, "000000")
        best = "FFFFFF" if white_r >= black_r else "000000"
        style = replace(style, header_text=best)
        return style, [{
            "type": "contrast",
            "reason": (
                f"header text/fill ratio {ratio:.1f}:1 below WCAG AA 4.5:1 — "
                f"auto-inverted header text to #{best} "
                f"({max(white_r, black_r):.1f}:1)"
            ),
        }]
    return style, [{
        "type": "contrast",
        "reason": f"header text/fill ratio {ratio:.1f}:1 below WCAG AA 4.5:1",
    }]


# --------------------------------------------------------------------------- #
# Low-level OOXML helpers (integer measurements only)
# --------------------------------------------------------------------------- #
def _el(tag: str):
    return OxmlElement(tag)


def _set_fixed_layout(t):
    tblPr = t._tbl.tblPr
    for e in tblPr.findall(qn("w:tblLayout")):
        tblPr.remove(e)
    layout = _el("w:tblLayout")
    layout.set(qn("w:type"), "fixed")
    tblPr.append(layout)


def _set_tblW(t, total: int):
    tblPr = t._tbl.tblPr
    for e in tblPr.findall(qn("w:tblW")):
        tblPr.remove(e)
    w = _el("w:tblW")
    w.set(qn("w:w"), str(int(total)))
    w.set(qn("w:type"), "dxa")
    tblPr.append(w)


def _set_borders(t, color: str, sz: int):
    tblPr = t._tbl.tblPr
    for e in tblPr.findall(qn("w:tblBorders")):
        tblPr.remove(e)
    tb = _el("w:tblBorders")
    for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
        e = _el("w:" + side)
        e.set(qn("w:val"), "single")
        e.set(qn("w:sz"), str(int(sz)))
        e.set(qn("w:space"), "0")
        e.set(qn("w:color"), color)
        tb.append(e)
    tblPr.append(tb)


def _shade(cell, hex_fill: str):
    tcPr = cell._tc.get_or_add_tcPr()
    for e in tcPr.findall(qn("w:shd")):
        tcPr.remove(e)
    sh = _el("w:shd")
    sh.set(qn("w:val"), "clear")
    sh.set(qn("w:color"), "auto")
    sh.set(qn("w:fill"), hex_fill)
    tcPr.append(sh)


def _margins(cell, margins: tuple[int, int, int, int]):
    top, left, bottom, right = margins
    tcPr = cell._tc.get_or_add_tcPr()
    for e in tcPr.findall(qn("w:tcMar")):
        tcPr.remove(e)
    m = _el("w:tcMar")
    for tag, v in (("top", top), ("left", left), ("bottom", bottom), ("right", right)):
        e = _el("w:" + tag)
        e.set(qn("w:w"), str(int(v)))
        e.set(qn("w:type"), "dxa")
        m.append(e)
    tcPr.append(m)


def _set_grid(t, widths: list[int]):
    _set_fixed_layout(t)
    _set_tblW(t, sum(widths))
    grid = t._tbl.find(qn("w:tblGrid"))
    if grid is None:
        grid = _el("w:tblGrid")
        t._tbl.insert(0, grid)
    for gc in list(grid.findall(qn("w:gridCol"))):
        grid.remove(gc)
    for w in widths:
        gc = _el("w:gridCol")
        gc.set(qn("w:w"), str(int(w)))
        grid.append(gc)
    for r in t.rows:
        cells = r.cells
        for ci, w in enumerate(widths):
            if ci < len(cells):
                try:
                    cells[ci].width = Twips(int(w))
                except Exception:
                    pass


def _content_widths(t, total: int) -> list[int]:
    nc = len(t.columns)
    maxlen = [1] * nc
    for r in t.rows:
        cells = r.cells
        for ci in range(min(nc, len(cells))):
            length = len(cells[ci].text.strip())
            if length > maxlen[ci]:
                maxlen[ci] = length
    clipped = [min(max(x, 4), 58) for x in maxlen]
    s = sum(clipped) or 1
    widths = [max(int(total * x / s), 1100) for x in clipped]
    drift = total - sum(widths)
    widths[widths.index(max(widths))] += drift
    return widths


def _para_mark_rpr(p):
    """Return the paragraph-mark ``<w:rPr>`` inside ``<w:pPr>``, creating both if
    absent. The paragraph-mark rPr is the run-property default that Word applies
    to *new* runs typed into the paragraph — setting it is what makes a future
    edit inherit the header colour instead of falling back to docDefaults.

    rPr is appended last in pPr (its schema position for table-cell paragraphs,
    which never carry sectPr/pPrChange), so it always sorts after a ``<w:jc>``
    inserted by :func:`_set_paragraph_alignment`.
    """
    pPr = p._p.get_or_add_pPr()
    rPr = pPr.find(qn("w:rPr"))
    if rPr is None:
        rPr = _el("w:rPr")
        pPr.append(rPr)
    return rPr


def _set_para_mark_color(p, text_hex: str, bold: bool = True) -> None:
    """Set a paragraph-level default run colour (and bold) on the paragraph mark."""
    rPr = _para_mark_rpr(p)
    if bold and rPr.find(qn("w:b")) is None:
        rPr.insert(0, _el("w:b"))
    for c in rPr.findall(qn("w:color")):
        rPr.remove(c)
    col = _el("w:color")
    col.set(qn("w:val"), text_hex)
    rPr.append(col)


def _cell_has_explicit_jc(cell) -> bool:
    """True if any paragraph in the cell already carries an explicit ``<w:jc>``
    (e.g. a Markdown column-alignment separator, Fix E) — author intent the
    content classifier must not overwrite."""
    for p in cell.paragraphs:
        pPr = p._p.find(qn("w:pPr"))
        if pPr is not None and pPr.find(qn("w:jc")) is not None:
            return True
    return False


def _set_paragraph_alignment(p, align: str) -> None:
    """Write ``<w:jc w:val=...>`` onto a paragraph, overriding style inheritance.

    ``jc`` is inserted before any paragraph-mark ``<w:rPr>`` so pPr child order
    stays schema-valid (``jc`` precedes ``rPr``). ``align`` is an OOXML jc value
    (``left`` / ``center`` / ``right`` / ``both``); ``None``/empty is a no-op.
    """
    if not align:
        return
    pPr = p._p.get_or_add_pPr()
    for e in pPr.findall(qn("w:jc")):
        pPr.remove(e)
    jc = _el("w:jc")
    jc.set(qn("w:val"), align)
    rPr = pPr.find(qn("w:rPr"))
    if rPr is not None:
        rPr.addprevious(jc)
    else:
        pPr.append(jc)


def _style_header_cell(cell, fill: str, text_hex: str):
    _shade(cell, fill)
    for p in cell.paragraphs:
        # Fix A: paragraph-level default run colour so runs Word inserts later
        # inherit the header colour rather than docDefaults near-black.
        _set_para_mark_color(p, text_hex, bold=True)
        runs = p.runs
        if runs:
            # Backward compat: existing runs keep run-level colour.
            for run in runs:
                run.font.color.rgb = RGBColor.from_string(text_hex)
                run.font.bold = True
        else:
            # Fix B: a header paragraph with zero runs gets an explicit empty run
            # carrying the header rPr, so a subsequent Word edit inherits it.
            run = p.add_run("")
            run.font.color.rgb = RGBColor.from_string(text_hex)
            run.font.bold = True


def _bold_label_cell(cell, color_hex: str):
    for p in cell.paragraphs:
        for run in p.runs:
            run.font.bold = True
            run.font.color.rgb = RGBColor.from_string(color_hex)


# --------------------------------------------------------------------------- #
# Public dressing entry points (operate on an open python-docx Document)
# --------------------------------------------------------------------------- #
# --- Fix D: content-aware cell justification classifier ------------------- #
# A full-cell currency amount ($1,000 / $140M) right-aligns; a bare number,
# percent or numeric range centers; a short equation-like token centers; prose
# — including prose with a ``$`` MID-string (the critical false-positive case)
# — stays left. Matched against the whole trimmed cell so "… — $3M+" never
# right-aligns.
_RE_CURRENCY_FULL = re.compile(r"^[-+]?\$\s?[\d,]+(?:\.\d+)?\s*[MBKmbk]?$")
_RE_PERCENT = re.compile(r"^[-+]?[\d,]+(?:\.\d+)?\s*%$")
_RE_NUMERIC = re.compile(r"^[-+]?[\d,]+(?:\.\d+)?$")
_RE_RANGE = re.compile(
    r"^\$?\s?[\d,]+(?:\.\d+)?\s*[-–—]\s*\$?\s?[\d,]+(?:\.\d+)?$"
)
_RE_MATHY = re.compile(r"[=*×·^]")


def _classify_cell_alignment(
    text: str, is_header: bool, is_label_column: bool, is_totals_row: bool = False
) -> str:
    """Return an OOXML ``jc`` value for a cell from its content and position.

    Position beats content: a header cell centers (or left-aligns when long),
    a label column stays left. Otherwise content decides: full-cell currency →
    right; pure number / percent / range → center; short equation → center;
    everything else (prose, incl. mid-string ``$``) → left.
    """
    t = (text or "").strip()
    if is_header:
        return "left" if len(t) > 30 else "center"
    if is_label_column:
        return "left"
    if not t:
        return "left"
    if _RE_CURRENCY_FULL.match(t):
        return "right"
    if _RE_PERCENT.match(t) or _RE_NUMERIC.match(t) or _RE_RANGE.match(t):
        return "center"
    if len(t) <= 30 and _RE_MATHY.search(t):
        return "center"
    return "left"


# --- Fix F: detect author-owned explicit table meta ----------------------- #
# Styles build-corpus itself applies to an *unstyled* table; a table wearing one
# of these is not "explicit meta". Anything else (a real named style, author
# column widths, or an author cell fill) means the author dressed it — skip it.
_DEFAULT_TBL_STYLES = {
    "", "tablegrid", "table grid", "tablenormal", "normal table",
    "lightlist", "lightlistaccent1", "lightlist-accent1",
}


def _table_has_explicit_meta(t) -> bool:
    """True when a table carries author-supplied structure dress_tables must not
    clobber: a non-default ``w:tblStyle``, non-uniform explicit ``w:gridCol``
    widths, or any real ``w:shd`` fill on a cell. Any one signal is enough."""
    tbl = t._tbl
    tblPr = tbl.tblPr
    if tblPr is not None:
        st = tblPr.find(qn("w:tblStyle"))
        if st is not None:
            val = (st.get(qn("w:val")) or "").strip().lower()
            if val and val not in _DEFAULT_TBL_STYLES:
                return True
    grid = tbl.find(qn("w:tblGrid"))
    if grid is not None:
        widths = [gc.get(qn("w:w")) for gc in grid.findall(qn("w:gridCol"))]
        widths = [w for w in widths if w]
        if len(set(widths)) > 1:
            return True
    for shd in tbl.iter(qn("w:shd")):
        fill = (shd.get(qn("w:fill")) or "").strip().lower()
        if fill and fill not in {"auto", "ffffff"}:
            return True
    return False


def _set_repeat_header(row) -> None:
    """Mark a row as a repeating header (``<w:trPr><w:tblHeader w:val="true"/>``)
    so Word repeats it at the top of each page the table spans (Fix H)."""
    trPr = row._tr.get_or_add_trPr()
    for e in trPr.findall(qn("w:tblHeader")):
        trPr.remove(e)
    th = _el("w:tblHeader")
    th.set(qn("w:val"), "true")
    trPr.insert(0, th)


def dress_tables(
    document,
    style: HouseStyle,
    *,
    auto_justify: bool = True,
    respect_explicit_meta: bool = True,
    repeat_header: bool = True,
) -> dict:
    """Apply the table theme to each unstyled table in ``document``.

    Tables carrying author-supplied explicit meta are skipped when
    ``respect_explicit_meta`` (Fix F). Dressed tables get a content-aware
    justification pass unless ``auto_justify`` is False (Fix D). Returns
    ``{"tables_dressed": int, "tables_skipped_explicit_meta": int}``.
    """
    sec = document.sections[0]
    usable = int((sec.page_width - sec.left_margin - sec.right_margin) / Twips(1))
    dressed = 0
    skipped = 0
    for t in document.tables:
        if respect_explicit_meta and _table_has_explicit_meta(t):
            skipped += 1
            continue
        nc = len(t.columns)
        row0 = [c.text.strip() for c in t.rows[0].cells] if t.rows else []
        is_label_value = nc == 2 and (not row0 or row0[0].lower() not in style.header_words)
        _set_borders(t, style.border_color, style.border_sz)
        for r in t.rows:
            for c in r.cells:
                _margins(c, style.cell_margin)
        if is_label_value:
            left = int(usable * style.label_value_split)
            _set_grid(t, [left, usable - left])
            for r in t.rows:
                _bold_label_cell(r.cells[0], style.label_color)
            if style.band:
                for ri, r in enumerate(t.rows):
                    if ri % 2 == 1:
                        for c in r.cells:
                            _shade(c, style.band_fill)
        else:
            _set_grid(t, _content_widths(t, usable))
            if t.rows:
                for c in t.rows[0].cells:
                    _style_header_cell(c, style.header_fill, style.header_text)
                if repeat_header:
                    # Fix H: matrix header row repeats across page breaks.
                    _set_repeat_header(t.rows[0])
            if style.band:
                for ri, r in enumerate(t.rows[1:], start=1):
                    if ri % 2 == 0:
                        for c in r.cells:
                            _shade(c, style.band_fill)
        if auto_justify:
            # Final pass: write an explicit <w:jc> onto every cell paragraph so
            # nothing inherits the template Normal style's jc="both" (Fix D).
            # A cell that already carries an explicit jc was aligned by the author
            # (Markdown column-alignment separator, Fix E) — leave it untouched.
            for ri, r in enumerate(t.rows):
                is_header = (not is_label_value) and ri == 0
                for ci, c in enumerate(r.cells):
                    if _cell_has_explicit_jc(c):
                        continue
                    align = _classify_cell_alignment(
                        c.text.strip(), is_header, is_label_column=(ci == 0)
                    )
                    for p in c.paragraphs:
                        _set_paragraph_alignment(p, align)
        dressed += 1
    return {"tables_dressed": dressed, "tables_skipped_explicit_meta": skipped}


def _subst(text: str | None, variables: dict[str, str]) -> str | None:
    if not text:
        return text
    def repl(m):
        return variables.get(m.group(1), m.group(0))
    return re.sub(r"\{([a-zA-Z0-9_]+)\}", repl, text)


def _add_page_of_field(paragraph):
    """Append 'Page X of Y' using PAGE / NUMPAGES fields."""
    def field(instr: str):
        run = paragraph.add_run()
        b = _el("w:fldChar"); b.set(qn("w:fldCharType"), "begin"); run._r.append(b)
        run2 = paragraph.add_run()
        it = _el("w:instrText"); it.set(qn("xml:space"), "preserve"); it.text = f" {instr} "
        run2._r.append(it)
        run3 = paragraph.add_run()
        e = _el("w:fldChar"); e.set(qn("w:fldCharType"), "end"); run3._r.append(e)
    paragraph.add_run("Page ")
    field("PAGE")
    paragraph.add_run(" of ")
    field("NUMPAGES")


def apply_chrome(document, style: HouseStyle, variables: dict[str, str]) -> dict:
    """Apply header (left/right) and footer (centered + page numbers) chrome."""
    from docx.enum.text import WD_TAB_ALIGNMENT
    report = {"header": False, "footer": False}
    sec = document.sections[0]
    hl = _subst(style.header_left, variables)
    hr = _subst(style.header_right, variables)
    fc = _subst(style.footer_center, variables)

    if hl or hr:
        p = sec.header.paragraphs[0]
        p.clear()
        usable = int((sec.page_width - sec.left_margin - sec.right_margin) / Twips(1))
        if hr:
            # Use the tab-stops API so <w:tabs> is inserted at the correct
            # schema position in pPr (before spacing/ind/jc/rPr), not appended.
            p.paragraph_format.tab_stops.clear_all()
            p.paragraph_format.tab_stops.add_tab_stop(Twips(usable), WD_TAB_ALIGNMENT.RIGHT)
        if hl:
            p.add_run(hl)
        if hr:
            p.add_run("\t" + hr)
        report["header"] = True

    if fc or style.page_numbers:
        p = sec.footer.paragraphs[0]
        p.clear()
        from docx.enum.text import WD_ALIGN_PARAGRAPH
        p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        if fc:
            p.add_run(fc)
            if style.page_numbers:
                p.add_run("    ")
        if style.page_numbers:
            _add_page_of_field(p)
        report["footer"] = True
    return report


# --------------------------------------------------------------------------- #
# House-style default TABLE design (md -> docx inline applicator path)
# --------------------------------------------------------------------------- #
# Distinct from the ``dress_tables`` post-pass above: this layer supplies the
# **default** table-meta design applied to an *unstyled* table (one with neither
# a ``build-corpus:table`` meta nor a ``table-style`` comment) during md -> docx
# emission, via ``docx_exporter.resolve_table_meta`` -> ``apply_table_meta``.
#
# The ``table`` block uses the SAME shape consumed by
# ``table_meta.parse_table_meta`` / ``docx_exporter.apply_table_meta`` (DD-6), so
# a single applicator handles both explicit and defaulted tables::
#
#     {
#       "style":   <str|null>,
#       "borders": "<style>,<size>,<color>",   # OOXML units (size in eighths-pt)
#       "shade":   {"header": "<hex>"},         # header-row-only fill (or a plain
#                                               # "<hex>" string to shade ALL cells)
#       "pad":     {"top": <twips>, "left": ..., "bottom": ..., "right": ...}
#     }
#
# Gating is mandatory: the default applies ONLY when frontmatter declares a
# ``house_style``. A plain, no-frontmatter md stays an unstyled plain table
# (preserves the sanitizer-only contract). Precedence:
# explicit meta > table-style comment > house default > plain.

# Each entry's ``table`` block is a canonical-shaped table-meta default.
HOUSE_STYLES: dict[str, dict] = {
    # Futurville / Authority-Sans-Pro real-estate house style: maroon header
    # shading with a thin maroon border and comfortable cell padding. The shade
    # is HEADER-ONLY (object form) so only the first row is dressed maroon and
    # the body rows stay clean chrome — the intended "maroon header + chrome".
    "futurville-asp": {
        "table": {
            "style": "Table Grid",
            "borders": "single,4,8B1A1A",
            "shade": {"header": "8B1A1A"},
            "pad": {"top": 40, "left": 108, "bottom": 40, "right": 108},
        },
    },
}


def house_table_default(house_style: str | None) -> dict | None:
    """Return the canonical table-meta default for a house style, or None.

    Returns ``None`` when ``house_style`` is falsy or unknown — callers then
    leave the table plain (sanitizer-only contract).
    """
    if not house_style:
        return None
    entry = HOUSE_STYLES.get(str(house_style).strip())
    if not entry:
        return None
    table = entry.get("table")
    if not isinstance(table, dict):
        return None
    # Return a shallow copy with all canonical keys present so the applicator
    # never sees a missing key.
    shade = table.get("shade")
    return {
        "style": table.get("style"),
        "cols": list(table.get("cols") or []),
        "borders": table.get("borders"),
        # Copy the shade dict so a caller mutating it cannot corrupt the
        # module-level house-style definition.
        "shade": dict(shade) if isinstance(shade, dict) else shade,
        "pad": dict(table["pad"]) if isinstance(table.get("pad"), dict) else None,
    }
