"""Schema-ordered Markdown for the AI handoff final-report body.

`data.json` is the report's single source of truth; this module renders its
subtrees as Markdown a reading agent can skim — headings for structure, tables
for uniform row sets, prose for narrative fields.

Property order comes from the schema, which is authored in reading order (a
decision draft reads `context -> decision -> consequences`). Serialising the
same subtree with `json.dumps(sort_keys=True)` would put `alternativesConsidered`
first and `decision` last, which is why order is taken from the schema rather
than from the mapping.

Order is a hint, never a gate: a key the schema does not mention still renders,
it just sorts after the ones the schema names. Nothing in `data.json` is
dropped except `userNarrative`, which belongs to the human HTML.
"""
from __future__ import annotations

import re
from typing import Any, Iterable, Sequence

from okstra_ctl.md_table import to_cell_text


# Markdown has no `#######`; deeper structures degrade to a bold label so the
# heading tree stays parseable instead of emitting an invalid level.
MAX_HEADING_LEVEL = 6
# Past these, a row set reads better as one block per row than as a table that
# no longer aligns: 8 columns is roughly a terminal width, and a cell longer
# than 160 characters is prose that a `<br>`-folded cell would bury.
TABLE_MAX_COLUMNS = 8
TABLE_MAX_CELL = 160
# A scalar this long stops being a field value and starts being a paragraph.
INLINE_MAX_CHARS = 140

EMPTY_MARKER = "_none_"

# The human HTML owns this field; the AI Markdown carries the structured facts
# it was written from, so repeating it here would duplicate the whole report.
HUMAN_ONLY_KEYS = frozenset({"userNarrative"})

_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
# A bare token (path, id, enum value, command) reads better fenced; prose does
# not. Whitespace is the discriminator, so this pattern deliberately has none.
_TOKEN_RE = re.compile(r"^[\w./:#@+-]+$")

_ACRONYMS = {
    "adr": "ADR",
    "api": "API",
    "cli": "CLI",
    "ci": "CI",
    "id": "ID",
    "ids": "IDs",
    "pr": "PR",
    "sql": "SQL",
    "url": "URL",
    "usd": "USD",
}

# Row identity for block-form lists. A row heading reads `E-001 — <what it is>`,
# so the identifier and the human name are looked up separately: an id alone
# ("1") does not say what the block holds, and a name alone loses the handle
# every cross-reference in the report cites.
_ROW_IDENT_KEYS = ("id", "rowId", "number", "stage", "stageNumber", "key")
# Naming fields only. A prose field like `summary` is deliberately absent: it
# would be truncated into the heading and then printed again in full below it.
_ROW_NAME_KEYS = (
    "title",
    "name",
    "slug",
    "label",
    # `role` outranks `agent` because several rows of one execution audit share
    # an agent ("Claude Code") and only the role tells them apart in a heading.
    "role",
    "agent",
    "worker",
    "command",
    "path",
)
# Long enough to identify the row, short enough to stay one heading line.
ROW_NAME_MAX_CHARS = 80
# A short token list (`P-001, P-002, …`) reads as one line; past this width an
# item is content that deserves its own bullet.
INLINE_ITEM_MAX_CHARS = 32


def humanise(key: str) -> str:
    """`stageMap` -> `Stage Map`, keeping known acronyms upper-case."""
    spaced = _WORD_BOUNDARY_RE.sub(" ", str(key)).replace("_", " ").replace("-", " ")
    words = spaced.split()
    if not words:
        return str(key)
    return " ".join(
        _ACRONYMS.get(word.lower(), word[:1].upper() + word[1:]) for word in words
    )


def label_key(label: str) -> str:
    """라벨 대조용 정규화 표기.

    `humanise` 는 `_ACRONYMS` 표에 있는 낱말만 대문자로 올린다(`ticketId` ->
    `Ticket ID`). 그 표는 저작자에게 전달되지 않으므로 `Ticket Id` 라고 쓰면
    글자 하나 때문에 리포트 전체가 되돌아온다. 대소문자와 낱말 구분 문자를
    지운 표기로 맞춰, 약어 표를 모르고도 쓸 수 있게 한다.
    """
    return re.sub(r"[^a-z0-9]", "", str(label).lower())


class SchemaIndex:
    """Property-order lookup over the final-report schema.

    Resolves `$ref` and flattens `allOf` / `oneOf` / `anyOf` branches into one
    ordered key list, because the schema uses all three to compose task blocks
    and a reader needs a single order regardless of which branch matched.
    """

    def __init__(self, schema: Any) -> None:
        self._defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
        self._schema = schema if isinstance(schema, dict) else {}

    def resolve(self, node: Any) -> dict:
        seen: set[str] = set()
        while isinstance(node, dict) and "$ref" in node:
            ref = str(node["$ref"])
            if ref in seen:
                return {}
            seen.add(ref)
            node = self._defs.get(ref.rsplit("/", 1)[-1], {})
        return node if isinstance(node, dict) else {}

    def _branches(self, node: Any) -> list[dict]:
        resolved = self.resolve(node)
        branches: list[dict] = []
        pending = [resolved]
        seen: set[int] = set()
        while pending:
            branch = pending.pop(0)
            identity = id(branch)
            if identity in seen:
                continue
            seen.add(identity)
            branches.append(branch)
            for keyword in ("allOf", "oneOf", "anyOf"):
                pending.extend(
                    self.resolve(candidate)
                    for candidate in branch.get(keyword) or ()
                )
        return branches

    def key_order(self, node: Any) -> list[str]:
        order: list[str] = []
        for branch in self._branches(node):
            for key in branch.get("properties") or ():
                if key not in order:
                    order.append(key)
        return order

    def child(self, node: Any, key: str) -> dict:
        candidates: list[dict] = []
        for branch in self._branches(node):
            candidate = (branch.get("properties") or {}).get(key)
            if candidate is not None:
                candidates.append(self.resolve(candidate))
        return max(candidates, key=_schema_detail, default={})

    def item(self, node: Any) -> dict:
        candidates: list[dict] = []
        for branch in self._branches(node):
            if "items" in branch:
                candidates.append(self.resolve(branch["items"]))
        return max(candidates, key=_schema_detail, default={})

    def keys_for_label(self, label: str) -> list[str]:
        """전체 스키마에서 사람이 읽는 표기가 일치하는 고유 키 후보."""
        wanted = label_key(label)
        found: set[str] = set()
        pending: list[Any] = [self._schema]
        seen: set[int] = set()
        while pending:
            node = pending.pop()
            if isinstance(node, list):
                pending.extend(node)
                continue
            if not isinstance(node, dict) or id(node) in seen:
                continue
            seen.add(id(node))
            for key in (node.get("properties") or {}):
                if label_key(humanise(key)) == wanted:
                    found.add(key)
            pending.extend(node.values())
        return sorted(found)

    def schema_for_key(self, key: str) -> dict:
        """전체 스키마에서 이 키를 가장 구체적으로 설명하는 후보."""
        found: list[dict] = []
        pending: list[Any] = [self._schema]
        seen: set[int] = set()
        while pending:
            node = pending.pop()
            if isinstance(node, list):
                pending.extend(node)
                continue
            if not isinstance(node, dict) or id(node) in seen:
                continue
            seen.add(id(node))
            candidate = (node.get("properties") or {}).get(key)
            if isinstance(candidate, dict):
                found.append(self.resolve(candidate))
            pending.extend(node.values())
        return max(found, key=_schema_detail, default={})


def _schema_detail(node: dict) -> tuple[int, int]:
    """구체적인 분기 스키마가 빈 호환 스키마보다 먼저 선택되게 한다."""
    structural = sum(
        1 for key in ("properties", "items", "required", "enum", "const", "type")
        if key in node
    )
    return structural, len(str(node))


def _is_scalar(value: Any) -> bool:
    return value is None or isinstance(value, (str, int, float, bool))


def _is_empty(value: Any) -> bool:
    if value is None:
        return True
    if isinstance(value, (list, dict, str)):
        return len(value) == 0
    return False


def _is_inline(value: Any) -> bool:
    """True when the value fits on one `- **Label**: value` line."""
    if not _is_scalar(value):
        return False
    if isinstance(value, str):
        return "\n" not in value and len(value) <= INLINE_MAX_CHARS
    return True


def scalar_text(value: Any) -> str:
    if value is None:
        return EMPTY_MARKER
    if isinstance(value, bool):
        return f"`{str(value).lower()}`"
    if isinstance(value, (int, float)):
        return f"`{value}`"
    text = str(value).strip()
    if not text:
        return EMPTY_MARKER
    return f"`{text}`" if _TOKEN_RE.match(text) else text


def heading(level: int, text: str) -> str:
    if level <= MAX_HEADING_LEVEL:
        return f"{'#' * level} {text}"
    return f"**{text}**"


def _join_blocks(blocks: Iterable[str]) -> str:
    return "\n\n".join(block for block in blocks if block and block.strip())


def _visible_keys(
    value: dict, node: Any, index: SchemaIndex, skip: Iterable[str] = ()
) -> list[str]:
    hidden = HUMAN_ONLY_KEYS | set(skip)
    known = [key for key in index.key_order(node) if key in value]
    rest = [key for key in value if key not in known]
    return [key for key in (*known, *rest) if key not in hidden]


def _first_present(
    row: dict, keys: Iterable[str], limit: int | None = None
) -> tuple[str, str]:
    """The first key in *keys* carrying a usable scalar, as ``(key, text)``."""
    for key in keys:
        candidate = row.get(key)
        if candidate is None or not _is_scalar(candidate):
            continue
        text = " ".join(str(candidate).split())
        if not text:
            continue
        if limit is not None and len(text) > limit:
            text = text[: limit - 1].rstrip() + "…"
        return key, text
    return "", ""


def _row_label(row: Any, position: int) -> tuple[str, set[str]]:
    """Heading text for a block-form row, and the keys it already shows.

    The caller drops those keys from the body: repeating `Stage: 1` under a
    `#### 1 — …` heading costs a line and tells the reader nothing new.
    """
    if not isinstance(row, dict):
        return f"Item {position}", set()
    ident_key, ident = _first_present(row, _ROW_IDENT_KEYS)
    name_key, name = _first_present(row, _ROW_NAME_KEYS, limit=ROW_NAME_MAX_CHARS)
    label = " — ".join(part for part in (ident, name) if part)
    if not label:
        return f"Item {position}", set()
    consumed = {key for key in (ident_key, name_key) if key}
    # A truncated name still needs its full text in the body.
    if name and row.get(name_key) is not None and name.endswith("…"):
        consumed.discard(name_key)
    return label, consumed


def _table_columns(
    rows: Sequence[dict], node: Any, index: SchemaIndex
) -> list[str] | None:
    """Column order if *rows* render as a table, else ``None``.

    A table has to stay aligned to beat block form, so every cell must be a
    short scalar. One prose field is enough to send the whole set to blocks.
    """
    if len(rows) < 2:
        return None
    columns: list[str] = []
    for key in index.key_order(index.item(node)):
        if key not in HUMAN_ONLY_KEYS and any(key in row for row in rows):
            columns.append(key)
    for row in rows:
        for key in row:
            if key not in columns and key not in HUMAN_ONLY_KEYS:
                columns.append(key)
    if not columns or len(columns) > TABLE_MAX_COLUMNS:
        return None
    for row in rows:
        for key in columns:
            cell = row.get(key)
            if not _is_scalar(cell) or len(to_cell_text(cell)) > TABLE_MAX_CELL:
                return None
    return columns


def _render_table(rows: Sequence[dict], columns: Sequence[str]) -> str:
    header = "| " + " | ".join(humanise(column) for column in columns) + " |"
    rule = "|" + "|".join(" --- " for _ in columns) + "|"
    body = [
        "| " + " | ".join(to_cell_text(row.get(column)) for column in columns) + " |"
        for row in rows
    ]
    return "\n".join([header, rule, *body])


def _inline_sequence(value: Sequence[Any]) -> str | None:
    """One comma-joined line if every item is a short token, else ``None``."""
    parts: list[str] = []
    for item in value:
        if item is None or not _is_scalar(item):
            return None
        text = str(item).strip()
        if not text or len(text) > INLINE_ITEM_MAX_CHARS or not _TOKEN_RE.match(text):
            return None
        parts.append(scalar_text(item))
    return ", ".join(parts) if parts else None


def _inline_field(value: Any) -> str | None:
    """The one-line form of a mapping field, or ``None`` if it needs a section."""
    if _is_empty(value) and not isinstance(value, (int, float)):
        return EMPTY_MARKER
    if _is_inline(value):
        return scalar_text(value)
    if isinstance(value, list):
        return _inline_sequence(value)
    return None


def _render_sequence(value: Sequence[Any], node: Any, index: SchemaIndex, level: int) -> str:
    if all(_is_inline(item) for item in value):
        return _inline_sequence(value) or "\n".join(
            f"- {scalar_text(item)}" for item in value
        )
    if all(isinstance(item, dict) for item in value):
        columns = _table_columns(value, node, index)
        if columns:
            return _render_table(value, columns)
    item_node = index.item(node)
    blocks: list[str] = []
    for position, item in enumerate(value, start=1):
        label, consumed = _row_label(item, position)
        blocks.append(heading(level, label))
        if isinstance(item, dict):
            blocks.append(
                _render_mapping(item, item_node, index, level + 1, skip=consumed)
            )
        else:
            blocks.append(
                render_value(item, node=item_node, index=index, level=level + 1)
            )
    return _join_blocks(blocks)


def _render_mapping(
    value: dict,
    node: Any,
    index: SchemaIndex,
    level: int,
    skip: Iterable[str] = (),
) -> str:
    blocks: list[str] = []
    bullets: list[str] = []
    for key in _visible_keys(value, node, index, skip):
        child = value[key]
        label = humanise(key)
        inline = _inline_field(child)
        if inline is not None:
            bullets.append(f"- **{label}**: {inline}")
            continue
        if bullets:
            blocks.append("\n".join(bullets))
            bullets = []
        blocks.append(heading(level, label))
        blocks.append(
            render_value(child, node=index.child(node, key), index=index, level=level + 1)
        )
    if bullets:
        blocks.append("\n".join(bullets))
    return _join_blocks(blocks)


def render_value(value: Any, *, node: Any, index: SchemaIndex, level: int) -> str:
    """Render one `data.json` subtree as a Markdown block."""
    if _is_empty(value) and not isinstance(value, (int, float)):
        return EMPTY_MARKER
    if _is_scalar(value):
        return scalar_text(value)
    if isinstance(value, list):
        return _render_sequence(value, node, index, level)
    if isinstance(value, dict):
        return _render_mapping(value, node, index, level)
    return scalar_text(value)


class ReportSections:
    """Template-facing view of `data.json` addressed by dotted path.

    Templates name the sections they want in the order a reading agent should
    meet them; `rest()` then sweeps whatever the template did not name, so a
    field added to the schema reaches the Markdown without a template edit.
    """

    def __init__(self, data: dict, schema: Any) -> None:
        self._data = data
        self._index = SchemaIndex(schema)
        self._schema = schema
        self._rendered: set[str] = set()

    def _walk(self, path: str) -> tuple[Any, Any, bool]:
        value: Any = self._data
        node: Any = self._schema
        for part in path.split("."):
            if not isinstance(value, dict) or part not in value:
                return None, {}, False
            node = self._index.child(node, part)
            value = value[part]
        return value, node, True

    def has(self, path: str) -> bool:
        value, _, found = self._walk(path)
        return found and not _is_empty(value)

    def section(self, path: str, level: int = 3) -> str:
        self._rendered.add(path)
        value, node, found = self._walk(path)
        if not found:
            return EMPTY_MARKER
        return render_value(value, node=node, index=self._index, level=level)

    def _is_claimed(self, path: str) -> bool:
        """True once *path* — or anything under it — has been rendered.

        A template that renders `implementationPlanning.stageMap` has claimed
        part of `implementationPlanning`; a later `rest("")` sweep must not
        emit the whole block a second time.
        """
        return any(
            rendered == path or rendered.startswith(f"{path}.")
            for rendered in self._rendered
        )

    def rest(self, prefix: str, level: int = 3) -> str:
        """Every child of *prefix* no `section()` call has already rendered."""
        container, node, found = (
            self._walk(prefix) if prefix else (self._data, self._schema, True)
        )
        if not found or not isinstance(container, dict):
            return ""
        blocks: list[str] = []
        for key in _visible_keys(container, node, self._index):
            path = f"{prefix}.{key}" if prefix else key
            if self._is_claimed(path) or _is_empty(container[key]):
                continue
            self._rendered.add(path)
            blocks.append(heading(level, humanise(key)))
            blocks.append(
                render_value(
                    container[key],
                    node=self._index.child(node, key),
                    index=self._index,
                    level=level + 1,
                )
            )
        return _join_blocks(blocks)

    def mark_rendered(self, *paths: str) -> str:
        """Claim paths the spine renders by hand so `rest()` skips them."""
        self._rendered.update(paths)
        return ""
