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


# 본문이 인용하지만 전용 섹션이 없는 행만 대장에 넣는다. 키 이름만으로
# 블록을 고르면 `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, and the requirement-coverage table already names
# every one of them in its Source column.
#
# 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"),
)


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 _own_section_ids(data: dict, omitted_fields: tuple[str, ...] = ()) -> set[str]:
    """Ids a section of the report already renders and anchors.

    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. `crossVerification.consensus` numbers its rows `C-001` in some
    runs, exactly where a clarification lives.

    `omitted_fields` names task-block fields the HTML template does not render,
    so their ids do not count as anchored.
    """
    from ..report_contract import TASK_TYPE_DATA_PROPERTY

    found: set[str] = set()
    _collect_ids(data.get("clarificationItems", []), found)
    _collect_ids(data.get("agentActivity", []), found)
    property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
    if property_name:
        block = data.get(property_name, {})
        if omitted_fields and isinstance(block, dict):
            block = {
                key: value for key, value in block.items() if key not in omitted_fields
            }
        _collect_ids(block, found)
    return found


_OMITTED_TEXT_KEYS = (
    "subject",
    "check",
    "item",
    "action",
    "requiredWork",
    "statement",
    "summary",
    "need",
    "title",
)


def _omitted_row_text(row: dict) -> str:
    """생략된 행에서 독자가 읽을 한 줄을 고른다."""
    for key in _OMITTED_TEXT_KEYS:
        value = row.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def _collect_row_dicts(value: object, rows: dict[str, dict]) -> None:
    if isinstance(value, dict):
        row_id = value.get("id") or value.get("activityId") or value.get("clarificationId")
        if isinstance(row_id, str) and row_id:
            rows.setdefault(row_id, value)
        for nested in value.values():
            _collect_row_dicts(nested, rows)
    elif isinstance(value, list):
        for nested in value:
            _collect_row_dicts(nested, rows)


def _omitted_rows(data: dict, omitted_fields: tuple[str, ...]) -> dict[str, 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
    if not isinstance(block, dict):
        return {}
    rows: dict[str, dict] = {}
    for field in omitted_fields:
        _collect_row_dicts(block.get(field), rows)
    return rows


def evidence_index(
    data: dict, omitted_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.

    `omitted_fields` are task-block arrays the HTML template does not render
    as their own section. Their rows still have to land somewhere, because
    prose cites them — without a ledger entry the id in the body is dead text.
    """
    owned = _own_section_ids(data, omitted_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 _omitted_rows(data, omitted_fields).items():
        if row_id in owned or row_id in rows:
            continue
        text = _omitted_row_text(row)
        if not text:
            continue
        rows[row_id] = {
            "id": row_id,
            "kind": "plan-item",
            "text": text,
            "codeEvidence": [],
            "source": "",
            "confidence": "",
        }
    return rows


def _collect_ids(value: object, found: set[str]) -> None:
    if isinstance(value, dict):
        for key in ("id", "activityId", "clarificationId"):
            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 anchor_index(data: dict, omitted_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 task's own sections,
    the clarifications, and every block the ledger takes in, including rows
    whose section the template left out.

    It stops there. `summary` is the AI-facing digest and
    `analysisCommon.scope` describes the analysis target rather than listing
    rows; neither renders, so a link to one would land nowhere.
    """
    found = _own_section_ids(data, omitted_fields) | set(
        evidence_index(data, omitted_fields)
    )
    return {
        row_id: f"id-{row_id}"
        for row_id in sorted(found)
        if isinstance(row_id, str) and _anchorable(row_id)
    }


def analysis_review_ids(data: dict) -> tuple[str, ...]:
    found: set[str] = set()
    _collect_ids(data.get("analysisCommon", {}), found)
    task_type = data.get("header", {}).get("taskType", "")
    from ..report_contract import TASK_TYPE_DATA_PROPERTY

    property_name = TASK_TYPE_DATA_PROPERTY.get(task_type)
    if property_name:
        _collect_ids(data.get(property_name, {}), found)
    return tuple(sorted(found))
