"""Shared table-meta contract for build-corpus round-trips.

This module is the single source of truth for the *carrier format* that lets
table design (style, column widths, borders, shading, cell padding) survive a
``docx -> md -> docx`` round trip, and lets a lone ``.md`` re-export those
properties when converted back to Word.

The carrier is an HTML comment emitted immediately above each table in the
generated Markdown::

    <!-- build-corpus:table id="<slug>" meta={<json>} -->

``meta`` is a compact JSON object with a fixed shape::

    {
      "style":   <str|null>,        # Word table style id (e.g. "LightShading-Accent1")
      "cols":    [{"w": <int>}, …], # column widths in twips (1/20 pt)
      "borders": <str|null>,        # "style,size,color" e.g. "single,4,000000"
      "shade":   <str|dict|null>,   # see "Shade forms" below
      "pad":     {"top": <int>, "left": <int>, "bottom": <int>, "right": <int>} | null
    }

Shade forms (two accepted, both round-trip):
  * **String form** — ``"8B1A1A"`` — applies the fill to **EVERY cell** in the
    table (the historical, back-compatible behavior).
  * **Object form** — ``{"header": "8B1A1A", "body": "F2E8E8"}`` — applies
    ``header`` to the **first row only**; ``body`` (optional) to the remaining
    rows. When ``body`` is omitted, only the header row is shaded and body rows
    are left undressed. This is the "maroon header + chrome" look. Either key may
    be omitted; an object with neither resolves to no shading.

Units:
  * widths and padding are in **twips** (twentieths of a point), matching OOXML
    ``w:tblGrid/w:gridCol w:w`` and ``w:tcMar`` values directly.
  * shade hex values are 6-hex-digit RGB strings with **no** leading ``#``,
    matching the OOXML ``w:shd w:fill`` attribute directly.
  * borders are serialized as ``"<style>,<size>,<color>"`` where ``style`` is an
    OOXML border value (``single``/``double``/…), ``size`` is in eighths of a
    point (the OOXML ``w:sz`` unit), and ``color`` is a 6-hex-digit RGB string
    with no leading ``#``.

These are **pure functions** — no ``python-docx``/``lxml`` dependency — so the
import side (``exporter.py``) and the export side (``docx_exporter.py``) share
one contract and cannot drift.
"""

from __future__ import annotations

import html
import json
import re

__all__ = [
    "META_KEYS",
    "empty_meta",
    "serialize_table_meta",
    "parse_table_meta",
    "slug_for_table",
    "TABLE_META_COMMENT_RE",
    "LEGACY_TABLE_STYLE_COMMENT_RE",
]

# Canonical key order. ``parse_table_meta`` ALWAYS returns exactly these keys.
#   * ``tblLook`` (BC-0.9-08) — the table's ``<w:tblLook>`` attributes as an
#     ordered ``[[name, value], …]`` list, captured and re-applied verbatim so the
#     firstRow/firstColumn/… role flags survive docx -> md -> docx.
#   * ``cells`` (BC-0.9-10) — per-cell ``<w:cnfStyle>`` role bits, keyed
#     ``{"<row>": {"<col>": {"cnfStyle": [[name, value], …]}}}``.
META_KEYS = ("style", "cols", "borders", "shade", "pad", "tblLook", "cells")

PAD_KEYS = ("top", "left", "bottom", "right")

# New named-table carrier:  <!-- build-corpus:table id="slug" meta={...} -->
# ``meta`` is a JSON object; it can contain ``}`` so we match through the final
# ``-->`` and trim, rather than trying to balance braces in a regex.
TABLE_META_COMMENT_RE = re.compile(
    r'<!--\s*build-corpus:table(?!-style)\b(?P<body>.*?)-->',
    re.IGNORECASE | re.DOTALL,
)

# Legacy carrier (still emitted/parsed for back-compat):
#   <!-- build-corpus:table-style name="StyleId" -->
LEGACY_TABLE_STYLE_COMMENT_RE = re.compile(
    r'<!--\s*build-corpus:table-style\b[^>]*\bname="(?P<name>[^"]*)"[^>]*-->',
    re.IGNORECASE,
)

_ID_ATTR_RE = re.compile(r'\bid="(?P<id>[^"]*)"', re.IGNORECASE)
_META_ATTR_RE = re.compile(r'\bmeta=(?P<meta>.*)$', re.IGNORECASE | re.DOTALL)


def empty_meta() -> dict:
    """Return a fresh meta dict with every canonical key present and empty."""
    return {
        "style": None, "cols": [], "borders": None, "shade": None, "pad": None,
        "tblLook": None, "cells": None,
    }


def _normalize_attr_pairs(raw) -> list[list[str]] | None:
    """Normalize an ordered ``[[name, value], …]`` OOXML-attribute list.

    Accepts a list of 2-item pairs (or a dict, whose insertion order is kept).
    Names are bare OOXML local names (``firstColumn``, ``val``, …); values are
    coerced to str. Order is preserved so the element re-serializes byte-identically.
    """
    items = []
    if isinstance(raw, dict):
        items = list(raw.items())
    elif isinstance(raw, list):
        for entry in raw:
            if isinstance(entry, (list, tuple)) and len(entry) == 2:
                items.append((entry[0], entry[1]))
    pairs: list[list[str]] = []
    for name, value in items:
        if not isinstance(name, str) or not name.strip():
            continue
        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", name.strip()):
            continue
        pairs.append([name.strip(), str(value)])
    return pairs or None


def _normalize_cells(raw) -> dict | None:
    """Normalize the per-cell ``cnfStyle`` map ``{row: {col: {cnfStyle: pairs}}}``."""
    if not isinstance(raw, dict):
        return None
    out: dict = {}
    for row_key, cols in raw.items():
        if not isinstance(cols, dict):
            continue
        row_out: dict = {}
        for col_key, cell in cols.items():
            if not isinstance(cell, dict):
                continue
            cnf = _normalize_attr_pairs(cell.get("cnfStyle"))
            if cnf:
                row_out[str(col_key)] = {"cnfStyle": cnf}
        if row_out:
            out[str(row_key)] = row_out
    return out or None


def _coerce_int(value) -> int | None:
    try:
        return int(round(float(value)))
    except (TypeError, ValueError):
        return None


def _normalize_cols(raw) -> list[dict]:
    cols: list[dict] = []
    if not isinstance(raw, list):
        return cols
    for entry in raw:
        width = None
        if isinstance(entry, dict):
            width = _coerce_int(entry.get("w"))
        else:
            width = _coerce_int(entry)
        cols.append({"w": width} if width is not None else {"w": None})
    return cols


def _normalize_shade_hex(raw) -> str | None:
    """Normalize one hex fill string, or None for empty/white/invalid."""
    if not isinstance(raw, str):
        return None
    value = raw.strip().lstrip("#")
    if not value or value.lower() in {"auto", "none", "ffffff"}:
        # 'auto'/empty carry no shading; white is treated as no-fill noise so a
        # plain table never gets a spurious shade attribute. Explicit white is
        # not meaningfully different from no fill for our purposes.
        return None
    if not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
        return None
    return value.upper()


def _normalize_shade(raw) -> str | dict | None:
    """Normalize the ``shade`` field.

    * A plain string normalizes to a hex fill applied to EVERY cell (back-compat).
    * An object ``{"header": <hex>, "body": <hex>?}`` normalizes to a dict with
      whichever of ``header``/``body`` carry a valid fill. ``header`` shades the
      first row only; ``body`` shades the remaining rows. An object with neither
      valid key resolves to ``None`` (no shading).
    """
    if isinstance(raw, dict):
        result: dict[str, str] = {}
        header = _normalize_shade_hex(raw.get("header"))
        body = _normalize_shade_hex(raw.get("body"))
        if header is not None:
            result["header"] = header
        if body is not None:
            result["body"] = body
        return result or None
    return _normalize_shade_hex(raw)


def _normalize_borders(raw) -> str | None:
    if not isinstance(raw, str):
        return None
    value = raw.strip()
    if not value or value.lower() in {"none", "nil"}:
        return None
    parts = [p.strip() for p in value.split(",")]
    style = parts[0] if parts and parts[0] else "single"
    size = _coerce_int(parts[1]) if len(parts) > 1 else None
    color = parts[2] if len(parts) > 2 and parts[2] else "000000"
    color = color.lstrip("#")
    if not re.fullmatch(r"[0-9A-Fa-f]{6}", color):
        color = "000000"
    size = size if size is not None else 4
    return f"{style},{size},{color.upper()}"


def _normalize_pad(raw) -> dict | None:
    if not isinstance(raw, dict):
        return None
    pad: dict[str, int] = {}
    for key in PAD_KEYS:
        coerced = _coerce_int(raw.get(key))
        if coerced is not None:
            pad[key] = coerced
    return pad or None


def normalize_meta(meta: dict | None) -> dict:
    """Return a canonical meta dict (all keys present, values normalized)."""
    result = empty_meta()
    if not isinstance(meta, dict):
        return result
    style = meta.get("style")
    if isinstance(style, str) and style.strip():
        result["style"] = style.strip()
    result["cols"] = _normalize_cols(meta.get("cols"))
    result["borders"] = _normalize_borders(meta.get("borders"))
    result["shade"] = _normalize_shade(meta.get("shade"))
    result["pad"] = _normalize_pad(meta.get("pad"))
    result["tblLook"] = _normalize_attr_pairs(meta.get("tblLook"))
    result["cells"] = _normalize_cells(meta.get("cells"))
    return result


def _compact_meta_for_json(meta: dict) -> dict:
    """Drop empty values so the emitted JSON stays small and readable."""
    out: dict = {}
    if meta.get("style"):
        out["style"] = meta["style"]
    cols = [c for c in (meta.get("cols") or []) if c.get("w") is not None]
    if cols:
        out["cols"] = [{"w": c["w"]} for c in cols]
    if meta.get("borders"):
        out["borders"] = meta["borders"]
    shade = meta.get("shade")
    if isinstance(shade, dict):
        # Emit header/body in a stable order so serialize->parse round-trips.
        out["shade"] = {k: shade[k] for k in ("header", "body") if k in shade}
    elif shade:
        out["shade"] = shade
    if meta.get("pad"):
        out["pad"] = {k: meta["pad"][k] for k in PAD_KEYS if k in meta["pad"]}
    if meta.get("tblLook"):
        out["tblLook"] = [[n, v] for n, v in meta["tblLook"]]
    if meta.get("cells"):
        out["cells"] = {
            rk: {ck: {"cnfStyle": [[n, v] for n, v in cell["cnfStyle"]]}
                 for ck, cell in cols.items()}
            for rk, cols in meta["cells"].items()
        }
    return out


def serialize_table_meta(meta: dict | None, table_id: str | None = None) -> str:
    """Serialize a meta dict to the ``build-corpus:table`` HTML comment.

    The ``id`` attribute is the stable table name. ``meta`` is a compact JSON
    object (empty values omitted). The output round-trips through
    :func:`parse_table_meta` to an equal canonical dict.
    """
    canonical = normalize_meta(meta)
    compact = _compact_meta_for_json(canonical)
    meta_json = json.dumps(compact, separators=(",", ":"), ensure_ascii=False)
    # Neutralize the HTML-comment terminator so a free-text field (e.g. a Word
    # ``style`` id read verbatim from an untrusted .docx) that contains ``-->``
    # cannot close the carrier comment early — which would silently truncate the
    # JSON on re-parse (data loss) and leak trailing text into the Markdown body.
    # Every literal hyphen is emitted as its JSON unicode escape ``-``; this
    # remains valid JSON and ``json.loads`` decodes it back to ``-`` exactly, so
    # the round trip is lossless while no ``-->`` (or ``<!--``) can survive in the
    # embedded payload.
    meta_json = meta_json.replace("-", "\\u002d")
    id_part = ""
    if table_id:
        id_part = f' id="{html.escape(str(table_id), quote=True)}"'
    return f"<!-- build-corpus:table{id_part} meta={meta_json} -->"


def _parse_meta_json(meta_text: str) -> dict:
    text = meta_text.strip()
    # The regex captured everything after ``meta=`` up to (but not including)
    # the closing ``-->`` already stripped by the caller. Trim trailing space.
    if not text:
        return empty_meta()
    # Tolerate ``meta={...}`` where the JSON is the whole remainder.
    try:
        parsed = json.loads(text)
    except (ValueError, TypeError):
        return empty_meta()
    return normalize_meta(parsed)


def parse_table_meta(comment: str | None) -> dict:
    """Parse a table comment into a canonical meta dict.

    ALWAYS returns a dict with exactly the keys ``style, cols, borders, shade,
    pad`` (empty/None when absent). NEVER raises — malformed JSON, a legacy
    ``table-style`` comment, or unrelated text all degrade gracefully to a
    style-only or empty meta.
    """
    if not comment or not isinstance(comment, str):
        return empty_meta()

    match = TABLE_META_COMMENT_RE.search(comment)
    if match:
        body = match.group("body")
        meta_match = _META_ATTR_RE.search(body)
        if meta_match:
            return _parse_meta_json(meta_match.group("meta"))
        # ``build-corpus:table`` with an id but no meta -> empty meta.
        return empty_meta()

    legacy = LEGACY_TABLE_STYLE_COMMENT_RE.search(comment)
    if legacy:
        meta = empty_meta()
        name = html.unescape(legacy.group("name")).strip()
        meta["style"] = name or None
        return meta

    return empty_meta()


def table_id_from_comment(comment: str | None) -> str | None:
    """Extract the ``id`` attribute from a ``build-corpus:table`` comment, if any."""
    if not comment or not isinstance(comment, str):
        return None
    match = TABLE_META_COMMENT_RE.search(comment)
    if not match:
        return None
    id_match = _ID_ATTR_RE.search(match.group("body"))
    return html.unescape(id_match.group("id")) if id_match else None


def _slugify(text: str) -> str:
    text = text.strip().lower()
    text = re.sub(r"[^a-z0-9]+", "-", text)
    text = text.strip("-")
    return text


def slug_for_table(
    caption: str | None,
    index: int,
    used: set[str] | list[str] | None = None,
) -> str:
    """Return a stable, deduped slug for a table.

    * When ``caption`` is non-empty, derive the slug from it (lowercased,
      hyphenated). Otherwise fall back to a positional ``table-<n>`` slug
      (1-based, derived from ``index``).
    * ``used`` collects slugs already assigned in this document; the returned
      slug is guaranteed unique against it and is added to it (when ``used`` is a
      mutable set/list) so callers can keep passing the same collection.
    """
    if used is None:
        used_set: set[str] = set()
    elif isinstance(used, set):
        used_set = used
    else:
        used_set = set(used)

    base = _slugify(caption) if caption else ""
    if not base:
        base = f"table-{index + 1}"

    candidate = base
    suffix = 2
    while candidate in used_set:
        candidate = f"{base}-{suffix}"
        suffix += 1

    used_set.add(candidate)
    if isinstance(used, list) and candidate not in used:
        used.append(candidate)
    return candidate
