"""Parser for the worker verdict block that plan-body and convergence share.

The response shape is fixed by contract (`prompts/lead/plan-body-verification.md`
§"Response format"), so the parser belongs here rather than in each lead. A
per-round ad-hoc regex makes the round's fidelity depend on whoever wrote it,
and its failure mode is silence: dev-10400 lost 19 of 37 assigned items to a
no-match that nothing reported. Every shape this module cannot read is an error.

A label that means the same thing is read, not refused. The contract writes
`**Verdict**: AGREE`; a worker following a lead-authored instruction wrote
`- Verdict: REFUTED` and the whole reverify round was rejected for it
(2026-09-09). The bullet form, the colon inside the bold (`**Verdict:**`), a
bullet before the bold, and a differently-cased key all name the same field —
refusing them costs a dispatch cycle and yields no information. A bare
`Verdict:` without bullet or bold is still prose: `Note:` opens sentences.
"""
from __future__ import annotations

import re
from dataclasses import dataclass

VERDICT_TOKENS = frozenset({
    "AGREE", "DISAGREE", "SUPPLEMENT", "UNVERIFIABLE", "VERIFICATION-ERROR",
})
FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})

# The convergence reverify prompts use their own vocabularies. Collaborative and
# full-reanalysis rounds speak the schema's own words; the adversarial round asks
# the verifier to break the finding, so it answers in break/survive terms that
# have to be translated back (`prompts/lead/convergence.md`
# §"Adversarial Re-verification Prompt").
COLLABORATIVE_VERDICTS = {
    "AGREE": "agree",
    "DISAGREE": "disagree",
    "SUPPLEMENT": "supplement",
    "UNVERIFIABLE": "unverifiable",
    "VERIFICATION-ERROR": "verification-error",
}
ADVERSARIAL_VERDICTS = {
    "SURVIVES": "agree",
    "SURVIVES-WITH-CAVEAT": "supplement",
    "REFUTED": "disagree",
    # A verifier that looked and could not check is not a verifier that failed.
    # `verification-error` drops the vote from the participating count, which
    # shrinks the roster without saying so.
    "UNVERIFIABLE": "unverifiable",
    "VERIFICATION-ERROR": "verification-error",
}
# Plan-body verdicts persist in their own vocabulary, which is NOT the
# convergence one above: the schema keeps the first three tokens uppercase
# (`$defs/PlanBodyVerification/properties/planItems/items/properties/verdicts/
# items/properties/verdict`) and has no `UNVERIFIABLE` member at all.
# `plan-body-verification.md` §"Planning-time environment gap" states the
# mapping — "Recording `UNVERIFIABLE` is the honest outcome — it is persisted as
# `verification-error`" — and it lives here rather than inside the transcription
# CLI so the worker-facing token and the persisted token are decided in one
# place. Writing the raw token through put a value in data.json that its own
# schema rejects.
PLAN_ITEM_VERDICTS = {
    "AGREE": "AGREE",
    "DISAGREE": "DISAGREE",
    "SUPPLEMENT": "SUPPLEMENT",
    "UNVERIFIABLE": "verification-error",
    "VERIFICATION-ERROR": "verification-error",
}
DISAGREE_BASES = frozenset({"counter-evidence", "burden-not-met"})

_ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
# The contract writes some labels with a parenthetical qualifier —
# `**Fixability** (only when DISAGREE):` — so the colon may trail a `(...)`.
# The label shapes read as one field (see the module docstring):
#   `**Key**: v`  `**Key:** v`  `- Key: v`  `- **Key**: v`  `- **Key:** v`
# `_field_match` enforces that an opening `**` is closed exactly once and that
# a label without bold carries a bullet.
_FIELD_KEYS = ("Verdict", "Fixability", "Note", "Explanation", "Prior dissent",
               "Basis", "Your evidence")
_CANONICAL_KEYS = {key.lower(): key for key in _FIELD_KEYS}
_FIELD_RE = re.compile(
    r"^(?P<bullet>[-*+][ \t]+)?(?P<open>\*\*)?"
    r"(?P<key>" + "|".join(re.escape(key) for key in _FIELD_KEYS) + r")"
    r"(?P<close>\*\*)?(?:[ \t]*\([^)]*\))?[ \t]*:(?P<close_after>\*\*)?"
    r"[ \t]*(?P<value>.*)$",
    re.IGNORECASE,
)
_VERDICT_RE = re.compile(r"^(?P<token>[A-Z-]+)(?:\((?P<kind>[a-f])\))?$")
_PROSE_FIELDS = frozenset({"Explanation", "Note", "Prior dissent", "Your evidence"})
_FENCE_RE = re.compile(r"^ {0,3}(?P<fence>`{3,}|~{3,})(?P<suffix>.*)$")


class VerdictBlockError(ValueError):
    """Raised when a worker response does not match the contract shape."""


def _field_match(line: str) -> tuple[str, str] | None:
    """`(canonical key, value)` when *line* is a field label, else ``None``."""
    match = _FIELD_RE.match(line)
    if match is None:
        return None
    opened = bool(match.group("open"))
    closes = sum(1 for name in ("close", "close_after") if match.group(name))
    if opened and closes != 1:
        return None
    if not opened and (closes or not match.group("bullet")):
        return None
    return _CANONICAL_KEYS[match.group("key").lower()], match.group("value")


@dataclass(frozen=True)
class FindingVote:
    """One worker's vote on one convergence finding, in schema vocabulary."""

    finding_id: str
    verdict: str
    disagree_basis: str | None
    explanation: str


def _scan_blocks(text: str) -> dict[str, dict[str, str]]:
    """코드 예시를 경계로 오인하지 않고 자유 서술 필드의 줄을 보존한다."""
    blocks: dict[str, dict[str, str]] = {}
    item_id = field = fence = ""
    for line in text.splitlines():
        marker = _FENCE_RE.match(line)
        in_fence = bool(fence)
        if marker:
            delimiter = marker.group("fence")
            if not fence:
                fence = delimiter
            elif (delimiter[0] == fence[0] and len(delimiter) >= len(fence)
                  and not marker.group("suffix").strip()):
                fence = ""
        if in_fence or marker:
            if item_id and field in _PROSE_FIELDS:
                blocks[item_id][field] += "\n" + line
            continue
        heading = _ITEM_RE.match(line)
        if heading:
            item_id, field = heading.group("id"), ""
            if item_id in blocks:
                raise VerdictBlockError(f"item `{item_id}` appears twice in one response")
            blocks[item_id] = {}
            continue
        if not item_id:
            continue
        match = _field_match(line)
        if match:
            field, value = match
            if field in blocks[item_id]:
                raise VerdictBlockError(f"item `{item_id}` has duplicate field `{field}`")
            blocks[item_id][field] = value
        elif field in _PROSE_FIELDS:
            blocks[item_id][field] += "\n" + line
    return {
        key: {name: value.strip() for name, value in fields.items()}
        for key, fields in blocks.items()
    }


def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVote]:
    """Convergence reverify votes in *text*, keyed by finding id.

    *adversarial* selects the vocabulary; guessing it from the content would make
    an unfamiliar token silently read as a different verdict.
    """
    vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
    return _collect_blocks(
        _scan_blocks(text),
        lambda item_id, fields: _finding_vote(item_id, fields, vocabulary),
    )


def finding_vote_defect(text: str) -> str | None:
    """재검증 결과가 표로 읽히지 않는 이유. 읽히면 ``None``.

    수집(`okstra convergence collect-results`)은 이 파일이 표로 읽혀야 진행한다.
    읽히지 않는 파일은 산출물이 있어도 표가 없는 것이므로, 디스패치가 그 자리에서
    "없는 산출물" 로 세어 같은 배치 안에서 재시도한다
    (`okstra_ctl.dispatch_state.unusable_result_defect`). 그러지 않으면 원장은
    `ok`, 수집기는 거절이 되어 리드가 손으로 재띄우거나 run 을 닫는 수밖에 없다
    (2026-09-10 실측, fontsninja-v3-site dev-10631 implementation-option-selection).

    라운드가 adversarial 인지 collaborative 인지는 결과 파일에 적혀 있지 않다. 한쪽
    어휘로만 읽으면 반대쪽 라운드의 정상 결과를 결함으로 신고하므로, 어느 한쪽으로
    읽히면 결함이 아니다. 둘 다 실패했을 때만 adversarial 쪽 메시지를 낸다 — 실측된
    결함(설명 누락)은 두 어휘에서 같은 문장을 낸다.
    """
    adversarial_error: str | None = None
    for adversarial in (True, False):
        try:
            parse_finding_votes(text, adversarial=adversarial)
        except VerdictBlockError as exc:
            if adversarial:
                adversarial_error = str(exc)
            continue
        return None
    return adversarial_error


def _collect_blocks(blocks: dict[str, dict[str, str]], parse) -> dict:
    """모든 블록을 읽고 결함을 한 번에 보고한다.

    첫 결함에서 멈추면 15건 중 13건이 같은 결함인 응답도 한 건씩만 드러나,
    교정 디스패치를 그 수만큼 반복하게 된다(2026-09-02 실측). 한 응답의 결함은
    서로 독립이므로 전부 모아 하나의 오류로 낸다.
    """
    parsed: dict = {}
    errors: list[str] = []
    for item_id, fields in blocks.items():
        try:
            parsed[item_id] = parse(item_id, fields)
        except VerdictBlockError as exc:
            errors.append(str(exc))
    if errors:
        if len(errors) == 1:
            raise VerdictBlockError(errors[0])
        raise VerdictBlockError(
            f"{len(errors)} of {len(blocks)} blocks are malformed: " + "; ".join(errors)
        )
    return parsed


def _finding_vote(
    finding_id: str, fields: dict[str, str], vocabulary: dict[str, str]
) -> FindingVote:
    raw = fields.get("Verdict", "")
    token = raw.strip().strip("`").strip().upper()
    if token not in vocabulary:
        raise VerdictBlockError(
            f"finding `{finding_id}` has an unknown verdict: {raw or '(missing)'} "
            f"— expected one of {sorted(vocabulary)}"
        )
    verdict = vocabulary[token]
    explanation = fields.get("Explanation", "")
    if not explanation:
        raise VerdictBlockError(
            f"finding `{finding_id}` has no `**Explanation**:` line — every vote "
            f"the classifier reads carries one"
        )
    basis = fields.get("Basis", "").strip() or None
    if verdict != "disagree":
        basis = None
    elif basis is not None and basis not in DISAGREE_BASES:
        raise VerdictBlockError(
            f"finding `{finding_id}` has basis `{basis}` — expected one of "
            f"{sorted(DISAGREE_BASES)}"
        )
    return FindingVote(finding_id, verdict, basis, explanation)


@dataclass(frozen=True)
class VerdictBlock:
    """One worker's verdict on one item."""

    item_id: str
    verdict: str
    breakage_kind: str
    fixability: str
    note: str
    explanation: str
    prior_dissent: str


def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
    """Every `### <item-id>` plan-body verdict block in *text*, keyed by item id."""
    return _collect_blocks(_scan_blocks(text), _block)


def _verdict_token(item_id: str, raw: str) -> tuple[str, str]:
    parsed = _VERDICT_RE.match(raw.strip().strip("`").strip())
    if parsed is None or parsed.group("token") not in VERDICT_TOKENS:
        raise VerdictBlockError(
            f"item `{item_id}` has an unknown verdict: {raw or '(missing)'}"
        )
    return parsed.group("token"), parsed.group("kind") or ""


def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
    raw = fields.get("Verdict", "")
    if not raw:
        raise VerdictBlockError(f"item `{item_id}` has no `**Verdict**:` line")
    token, kind = _verdict_token(item_id, raw)
    fixability = fields.get("Fixability", "")
    if token == "DISAGREE":
        if not kind:
            raise VerdictBlockError(
                f"item `{item_id}` is DISAGREE with no breakage kind — the gate "
                f"reads the kind to decide whether one vote blocks, so a bare "
                f"`DISAGREE` cannot be scored"
            )
        if fixability not in FIXABILITY_VALUES:
            raise VerdictBlockError(
                f"item `{item_id}` is DISAGREE with fixability "
                f"`{fixability or '(missing)'}` — expected one of "
                f"{sorted(FIXABILITY_VALUES)}"
            )
    return VerdictBlock(
        item_id=item_id,
        verdict=token,
        breakage_kind=kind,
        fixability=fixability,
        note=fields.get("Note", ""),
        explanation=fields.get("Explanation", ""),
        prior_dissent=fields.get("Prior dissent", ""),
    )
