"""Common data transformations that carry no task-specific section order."""
from __future__ import annotations

import re
from collections.abc import Iterator


# 본문이 인용하지만 전용 섹션이 없는 행만 대장에 넣는다. 키 이름만으로
# 블록을 고르면 `evidence.primary` 의 내용과 다른 블록의 출처 칸이 섞인다.
#
# Source and confidence are separate columns because most blocks carry only one
# of the two: `evidence.primary` cites a file and never rates itself, while
# `evidence.secondary` rates itself and cites nothing. Folding them into one
# field is what made the ledger label every row "source · confidence" and then
# print a single value under it.
#
# `endStateCoverage` is deliberately absent. Its rows hold an id pair
# (`EB-001` covered by `R-001`) rather than a statement, so the ledger rendered
# them as an id with no body. Those ids anchor in the end-state section the
# base template renders from the brief and this coverage table together.
#
# The kind each row carries is a vocabulary key, not the words the reader sees:
# the labels live in the i18n `ledgerKind` table so they arrive in the reader's
# language. `evidence.primary` was previously stamped "unclassified", which
# named the renderer's own indecision rather than anything about the row.
_LEDGER_BLOCKS = (
    (("evidence", "primary"), "evidence", "source", "", "code-evidence"),
    (("evidence", "secondary"), "hypothesis", "", "confidence", "hypothesis"),
    (("analysisCommon", "confirmedFacts"), "statement", "", "", "confirmed-fact"),
    (("analysisCommon", "inferences"), "statement", "", "confidence", "inference"),
    (("analysisCommon", "unknowns"), "question", "reason", "", "unknown"),
    (("missingInformation",), "item", "risk", "", "missing-information"),
    (("followUpTasks",), "title", "reason", "", "follow-up"),
)

# The keys a row's identity may sit under. `agentActivity` numbers its rows
# `activityId`; a supersession entry names the answer it overturns by
# `clarificationId` and has no id of its own.
_ROW_ID_KEYS = ("id", "activityId", "clarificationId")


def _dig(data: dict, path: tuple[str, ...]) -> list:
    node: object = data
    for key in path:
        if not isinstance(node, dict):
            return []
        node = node.get(key)
    return node if isinstance(node, list) else []


def _joined(value: object) -> str:
    """One reader-facing string for a cell a block may fill with either a
    sentence or a list of citations. `str()` on a list prints Python's own
    repr — quotes, brackets and all — straight into the report.
    """
    if isinstance(value, (list, tuple)):
        return " · ".join(str(item) for item in value if item)
    return str(value or "")


def _ledger_row(
    row: dict, text_key: str, source_key: str, confidence_key: str, kind: str
) -> dict[str, object]:
    return {
        "id": row.get("id", ""),
        "kind": kind,
        "text": _joined(row.get(text_key)),
        "codeEvidence": row.get("currentCodeEvidence") or [],
        "source": _joined(row.get(source_key)) if source_key else "",
        "confidence": _joined(row.get(confidence_key)) if confidence_key else "",
    }


def _row_id(row: object) -> str | None:
    if not isinstance(row, dict):
        return None
    for key in _ROW_ID_KEYS:
        value = row.get(key)
        if isinstance(value, str) and value:
            return value
    return None


_ROW_TEXT_KEYS = (
    "subject",
    "check",
    "item",
    "action",
    "requiredWork",
    "statement",
    "summary",
    "need",
    "title",
    "commitment",
    "condition",
)


def _row_text(row: dict) -> str:
    """전용 섹션이 없는 행에서 독자가 읽을 한 줄을 고른다."""
    for key in _ROW_TEXT_KEYS:
        value = row.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def _task_block(data: dict) -> dict:
    from ..report_contract import TASK_TYPE_DATA_PROPERTY

    property_name = TASK_TYPE_DATA_PROPERTY.get(
        (data.get("header") or {}).get("taskType", "")
    )
    block = data.get(property_name) if property_name else None
    return block if isinstance(block, dict) else {}


def rows_at(data: dict, path: str) -> list[dict]:
    """Every row dict at a dotted path from the record root.

    A list along the way is walked implicitly — `implementationPlanning.
    optionCandidates.fileStructure` reaches the file rows of every candidate —
    so a template's `{% for %}` nesting and this path read the same rows.
    """
    nodes: list[object] = [data]
    for part in path.split("."):
        next_nodes: list[object] = []
        for node in nodes:
            if not isinstance(node, dict):
                continue
            value = node.get(part)
            if isinstance(value, list):
                next_nodes.extend(value)
            elif isinstance(value, dict):
                next_nodes.append(value)
        nodes = next_nodes
    return [node for node in nodes if isinstance(node, dict)]


def scoped_anchor_map(data: dict, path: str) -> dict[str, dict[str, str]]:
    """`{parent id: {child id: anchor}}` for rows that repeat under every parent.

    A direction's scope commitments are numbered `IC-001` … inside each
    direction, so the same id sits in every ranked option. One page-global
    `#id-IC-001` would land on whichever card came first; instead each card
    anchors its own rows as `id-<parent>-<child>` and links the ids its prose
    cites to those.
    """
    parent_path, _, child_key = path.rpartition(".")
    out: dict[str, dict[str, str]] = {}
    for parent in rows_at(data, parent_path):
        parent_id = _row_id(parent)
        children = parent.get(child_key)
        if not parent_id or not _anchorable(parent_id) or not isinstance(children, list):
            continue
        for child in children:
            child_id = _row_id(child)
            if child_id and _anchorable(child_id):
                out.setdefault(parent_id, {})[child_id] = f"id-{parent_id}-{child_id}"
    return out


def _scoped_ids(data: dict, scoped_fields: tuple[str, ...]) -> set[str]:
    found: set[str] = set()
    for path in scoped_fields:
        for children in scoped_anchor_map(data, path).values():
            found.update(children)
    return found


def _own_section_ids(data: dict, anchored_fields: tuple[str, ...]) -> set[str]:
    """Ids a section of the report renders under a page-global anchor.

    The clarification articles and the base template's activity table anchor
    their rows on every page; the task template anchors the rows of the
    fields its view declares. The ledger is the fallback home for a cited row,
    so it must not claim an id that has one — two elements with the same
    anchor send half the links to the wrong place.
    """
    found: set[str] = set()
    _collect_ids(data.get("clarificationItems", []), found)
    _collect_ids(data.get("agentActivity", []), found)
    for path in anchored_fields:
        for row in rows_at(data, path):
            row_id = _row_id(row)
            if row_id:
                found.add(row_id)
    return found


def _task_block_rows(value: object) -> Iterator[dict]:
    """Every dict carrying a row id anywhere in the task block, document order."""
    if isinstance(value, dict):
        if _row_id(value):
            yield value
        for nested in value.values():
            yield from _task_block_rows(nested)
    elif isinstance(value, list):
        for nested in value:
            yield from _task_block_rows(nested)


def _unanchored_rows(
    data: dict, owned: set[str], scoped: set[str]
) -> dict[str, dict]:
    """Task-block rows no section anchors, keyed by id, first occurrence wins.

    Their ids are still cited by prose — a validation check, a rollback step,
    a candidate's commitment — so the ledger gives each a line to land on.
    """
    rows: dict[str, dict] = {}
    for row in _task_block_rows(_task_block(data)):
        row_id = _row_id(row)
        if row_id in owned or row_id in scoped or row_id in rows:
            continue
        rows[row_id] = row
    return rows


def evidence_index(
    data: dict,
    anchored_fields: tuple[str, ...] = (),
    scoped_fields: tuple[str, ...] = (),
) -> dict[str, object]:
    """The rows the ledger carries, keyed by id.

    A row whose statement came out empty is dropped rather than listed: the
    ledger exists so a cited id resolves to something the reader can read, and
    an id above a blank line resolves to nothing.

    `anchored_fields` and `scoped_fields` are the view's declarations of what
    its template anchors (see `HumanReportView`). Every other task-block row
    with an id lands here, so a citation of it resolves to its own text.
    """
    owned = _own_section_ids(data, anchored_fields)
    scoped = _scoped_ids(data, scoped_fields)
    rows: dict[str, object] = {}
    for path, text_key, source_key, confidence_key, kind in _LEDGER_BLOCKS:
        for row in _dig(data, path):
            if not isinstance(row, dict):
                continue
            row_id = row.get("id")
            if not row_id or row_id in owned or row_id in rows:
                continue
            entry = _ledger_row(row, text_key, source_key, confidence_key, kind)
            if entry["text"]:
                rows[row_id] = entry
    for row_id, row in _unanchored_rows(data, owned, scoped).items():
        if row_id in rows:
            continue
        text = _row_text(row)
        if not text:
            continue
        rows[row_id] = {
            "id": row_id,
            "kind": "record-row",
            "text": text,
            "codeEvidence": row.get("currentCodeEvidence") or [],
            "source": "",
            "confidence": "",
        }
    for row_id, row in _cited_summary_rows(data).items():
        if row_id in owned or row_id in rows:
            continue
        text = _joined(row.get("summary"))
        if not text:
            continue
        rows[row_id] = {
            "id": row_id,
            "kind": "summary-point",
            "text": text,
            "codeEvidence": [],
            "source": _joined(row.get("source")),
            "confidence": "",
        }
    return rows


_SUMMARY_ID_RE = re.compile(r"\bP-\d{3,}\b")
_FINDING_ID_RE = re.compile(r"\b(F-\d{3,})\b")


def _cited_summary_rows(data: dict) -> dict[str, dict]:
    """`summary[]` rows some other field of the record cites by id.

    The summary is the AI-facing digest and does not render, but a writer
    who names `P-005` in prose sends the reader after it — 2026-09-05 audit:
    requirements-discovery and release-handoff pages carried such bare ids. A
    cited row lands in the ledger; an uncited one stays out, so the ledger does
    not duplicate the digest.
    """
    rows = {
        row["id"]: row
        for row in data.get("summary") or []
        if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]
    }
    if not rows:
        return {}
    cited: set[str] = set()

    def walk(node: object) -> None:
        if isinstance(node, dict):
            for value in node.values():
                walk(value)
        elif isinstance(node, list):
            for value in node:
                walk(value)
        elif isinstance(node, str):
            cited.update(match for match in _SUMMARY_ID_RE.findall(node) if match in rows)

    walk({key: value for key, value in data.items() if key != "summary"})
    return {row_id: row for row_id, row in rows.items() if row_id in cited}


def worker_finding_links(data: dict) -> dict[str, str]:
    """`F-NNN` → the promoted evidence row whose `sourceItems` cite it.

    A worker numbers its own findings `F-001` …; the record promotes the ones
    the workers agreed on into `evidence.primary[]`, each naming its sources as
    `<worker>:F-NNN`. Prose keeps citing the worker number — 15 to 20 times a
    report in the 2026-09-05 audit — so when exactly one evidence row carries
    that number, the citation links there. A number two rows share, or none
    does, stays plain text: a guess would send the reader to the wrong row.
    """
    homes: dict[str, set[str]] = {}
    for row in _dig(data, ("evidence", "primary")):
        if not isinstance(row, dict):
            continue
        row_id = row.get("id")
        if not isinstance(row_id, str) or not _anchorable(row_id):
            continue
        for item in row.get("sourceItems") or []:
            for finding in _FINDING_ID_RE.findall(str(item)):
                homes.setdefault(finding, set()).add(row_id)
    return {
        finding: f"#id-{next(iter(rows))}"
        for finding, rows in homes.items()
        if len(rows) == 1
    }


def _collect_ids(value: object, found: set[str]) -> None:
    if isinstance(value, dict):
        for key in _ROW_ID_KEYS:
            row_id = value.get(key)
            if isinstance(row_id, str) and row_id:
                found.add(row_id)
        for nested in value.values():
            _collect_ids(nested, found)
    elif isinstance(value, list):
        for nested in value:
            _collect_ids(nested, found)


def _anchorable(row_id: str) -> bool:
    """공백이나 경로 구분자가 있는 값은 HTML id 로 쓰지 않는다."""
    return bool(row_id) and " " not in row_id and "/" not in row_id


def end_state_ids(data: dict) -> list[str]:
    """The end-state ids this run judged, in `endStateCoverage` order."""
    out: list[str] = []
    for row in data.get("endStateCoverage") or []:
        row_id = _row_id(row)
        if row_id and _anchorable(row_id) and row_id not in out:
            out.append(row_id)
    return out


def anchor_index(
    data: dict,
    anchored_fields: tuple[str, ...] = (),
    scoped_fields: tuple[str, ...] = (),
) -> dict[str, str]:
    """Map every row a reader can reach to the anchor name that lands on it.

    Prose cites ids across section boundaries — a hotspot names a
    cross-verification finding, a quality row names a difference — so the
    target set spans the whole reader-facing report: the sections the view
    declares anchored, the clarifications, the activity table, the end-state
    section, and every row the ledger takes in.

    It stops there. `summary` is the AI-facing digest and does not render —
    only a summary row some other field cites reaches the ledger — and
    `analysisCommon.scope` describes the analysis target rather than listing
    rows, so a link to it would land nowhere. Rows under a
    scoped field anchor inside their parent's card (`scoped_anchor_map`) and
    take no page-global name.

    Cross-check rows are anchored `id-xv-<id>` by the base template — the
    prefix keeps a legacy consensus row still numbered `C-NNN` from sharing
    an element id with the clarification of that number — so they carry that
    name here, and a legacy row whose id a clarification already owns keeps
    pointing at the clarification.
    """
    found = (
        _own_section_ids(data, anchored_fields)
        | set(evidence_index(data, anchored_fields, scoped_fields))
        | set(end_state_ids(data))
    )
    index = {
        row_id: f"id-{row_id}"
        for row_id in sorted(found)
        if isinstance(row_id, str) and _anchorable(row_id)
    }
    for block in ("consensus", "differences"):
        for row in _dig(data, ("crossVerification", block)):
            row_id = row.get("id") if isinstance(row, dict) else None
            if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
                index[row_id] = f"id-xv-{row_id}"
    return index


def analysis_review_ids(data: dict) -> tuple[str, ...]:
    found: set[str] = set()
    _collect_ids(data.get("analysisCommon", {}), found)
    _collect_ids(_task_block(data), found)
    return tuple(sorted(found))
