"""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.
"""
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 `(...)`.
_FIELD_RE = re.compile(
    r"^\*\*(?P<key>Verdict|Fixability|Note|Explanation|Prior dissent|Basis"
    r"|Your evidence)\*\*"
    r"(?:[ \t]*\([^)]*\))?[ \t]*:[ \t]*(?P<value>.*)$",
    re.MULTILINE,
)
_VERDICT_RE = re.compile(r"^(?P<token>[A-Z-]+)(?:\((?P<kind>[a-f])\))?$")


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


@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]]:
    """Every `### <id>` block in *text*, as id → field map, in document order."""
    matches = list(_ITEM_RE.finditer(text))
    blocks: dict[str, dict[str, str]] = {}
    for index, match in enumerate(matches):
        end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
        item_id = match.group("id")
        if item_id in blocks:
            raise VerdictBlockError(f"item `{item_id}` appears twice in one response")
        body = text[match.end():end]
        blocks[item_id] = {
            m.group("key"): m.group("value").strip() for m in _FIELD_RE.finditer(body)
        }
    return blocks


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 {
        finding_id: _finding_vote(finding_id, fields, vocabulary)
        for finding_id, fields in _scan_blocks(text).items()
    }


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 {
        item_id: _block(item_id, fields)
        for item_id, fields in _scan_blocks(text).items()
    }


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", ""),
    )
