"""선임 에이전트가 소유하는 승인 결정 입력 원장."""
from __future__ import annotations

import argparse
import json
import re
import sys
from copy import deepcopy
from functools import lru_cache
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence

from .clarification_items.sidecars import (
    sidecar_answers,
    attached_response_sections,
    response_source_report,
)
from .user_response_values import parse_user_response_entries
from .convergence_store import write_json_atomic
from .final_report_schema import load_schema_version
from .json_boundary import JsonBoundaryError, load_owned_object
from .report_contract import CURRENT_REPORT_SCHEMA_VERSION


@lru_cache(maxsize=None)
def _schema_enum(definition: str) -> frozenset[str]:
    """리포트 스키마의 ``$defs`` enum 을 그대로 읽는다.

    이 원장의 행은 그대로 final-report 의 ``clarificationItems`` 가 된다. 값 목록을
    여기에 다시 타이핑하면 스키마와 따로 흐르므로 정본에서 읽는다.
    """
    schema = load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
    values = (schema.get("$defs") or {}).get(definition, {}).get("enum")
    if not isinstance(values, list) or not values:
        raise ApprovalDecisionError(
            f"report schema has no enum for {definition!r}"
        )
    return frozenset(str(value) for value in values)


DISPOSITIONS = frozenset({"select", "accept-risk", "request-revision", "reject"})
REACHES = frozenset({"in-repo", "cross-repo"})
SCOPE_EFFECTS = frozenset({"new-schema", "deferrable"})
CLASSIFICATIONS = frozenset(
    {"user-decision", "noncritical-dissent", "correctness-critical"}
)
_FORBIDDEN_BY_CLASSIFICATION = {
    "correctness-critical": frozenset({"select"}),
    "noncritical-dissent": frozenset({"select"}),
    "user-decision": frozenset(),
}


class ApprovalDecisionError(ValueError):
    """승인 결정 입력이 역할 계약이나 선택지 불변식을 위반했다."""


def _required(value: str, field: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ApprovalDecisionError(f"{field} must be a non-empty string")
    return value.strip()


@dataclass(frozen=True)
class DecisionOption:
    role: str
    answer: str
    rationale: str
    disposition: str
    reach: str
    scope_effects: tuple[str, ...]
    added_work: str
    direction_change: str

    def __post_init__(self) -> None:
        if self.role not in {"recommended", "alternative"}:
            raise ApprovalDecisionError(f"invalid option role: {self.role}")
        for field in ("answer", "rationale", "added_work", "direction_change"):
            _required(getattr(self, field), field)
        if self.disposition not in DISPOSITIONS:
            raise ApprovalDecisionError(f"invalid disposition: {self.disposition}")
        if self.reach not in REACHES:
            raise ApprovalDecisionError(f"invalid reach: {self.reach}")
        unknown = sorted(set(self.scope_effects) - SCOPE_EFFECTS)
        if unknown or len(set(self.scope_effects)) != len(self.scope_effects):
            raise ApprovalDecisionError(f"invalid scope_effects: {unknown}")

    def to_payload(self) -> dict[str, Any]:
        return {
            "role": self.role,
            "answer": self.answer,
            "rationale": self.rationale,
            "disposition": self.disposition,
            "reach": self.reach,
            "scopeEffects": list(self.scope_effects),
            "addedWork": self.added_work,
            "directionChange": self.direction_change,
        }


def _new_ledger(task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
    return {
        "schemaVersion": "1.0",
        "owner": "lead",
        "taskKey": _required(task_key, "task_key"),
        "taskType": _required(task_type, "task_type"),
        "runSeq": _required(run_seq, "run_seq"),
        "activeClarifications": [],
        "carriedDecisions": [],
    }


def _read_ledger(path: Path) -> dict[str, Any]:
    try:
        value = load_owned_object(path, artifact="approval decision ledger")
    except JsonBoundaryError as exc:
        raise ApprovalDecisionError(f"cannot read approval ledger {path}: {exc}") from exc
    if not isinstance(value, dict) or value.get("owner") != "lead":
        raise ApprovalDecisionError(f"approval ledger owner must be lead: {path}")
    return value


def _ledger(path: Path, task_key: str, task_type: str, run_seq: str) -> dict[str, Any]:
    if not path.is_file():
        return _new_ledger(task_key, task_type, run_seq)
    ledger = _read_ledger(path)
    expected = (task_key, task_type, run_seq)
    actual = (ledger.get("taskKey"), ledger.get("taskType"), ledger.get("runSeq"))
    if actual != expected:
        # 원장은 `runSeq` 를 세 자리 문자열로 적는다. 정수 `4` 를 넘긴 호출은
        # 원장을 덤프해 보기 전까지 무엇이 달랐는지 알 수 없었다.
        raise ApprovalDecisionError(
            "approval ledger identity does not match this run: ledger has "
            f"taskKey={actual[0]!r} taskType={actual[1]!r} runSeq={actual[2]!r}; "
            f"this call passed taskKey={expected[0]!r} taskType={expected[1]!r} "
            f"runSeq={expected[2]!r}"
        )
    return ledger


def normalize_run_seq(value: str) -> str:
    """`4` → `004`. 원장과 run 산출물은 세 자리 0 패딩 문자열을 쓴다."""
    text = str(value).strip()
    return text.zfill(3) if text.isdigit() else text


def _validate_option_set(
    options: Sequence[DecisionOption], classification: str,
    recommended_disposition: str,
) -> None:
    if len(options) < 2:
        raise ApprovalDecisionError("a decision requires at least two options")
    if sum(option.role == "recommended" for option in options) != 1:
        raise ApprovalDecisionError("a decision requires exactly one recommended option")
    forbidden = _FORBIDDEN_BY_CLASSIFICATION[classification]
    used = {recommended_disposition, *(option.disposition for option in options)}
    invalid = sorted(used & forbidden)
    if invalid:
        raise ApprovalDecisionError(f"{classification} forbids dispositions: {invalid}")
    recommended = next(option for option in options if option.role == "recommended")
    if recommended.disposition != recommended_disposition:
        raise ApprovalDecisionError(
            "recommended_disposition must match the recommended option"
        )


def _reject_off_enum(value: str, field: str, definition: str) -> None:
    """스키마 밖 값을 원장 진입 지점에서 거절한다.

    종전에는 두 provenance 필드가 비어 있지 않기만 하면 통과했다. 리포트 조립이
    스키마로 잡아 내기는 하지만 그때는 이미 레코드를 디스크에 쓴 뒤라, 잘못된 값이
    원장과 리포트 양쪽에 남아 이후의 모든 읽기(`okstra user-response`)를 막았다.
    """
    if not value:
        return
    allowed = _schema_enum(definition)
    if value not in allowed:
        raise ApprovalDecisionError(
            f"invalid {field}: {value!r}. Allowed values: "
            + ", ".join(sorted(allowed))
        )


def _decision_row(
    *, clarification_id: str, ticket_id: str, statement: str,
    expected_form: str, classification: str, origin: str,
    user_confirmation: str, unblock_condition: str,
    recommended_disposition: str, options: Sequence[DecisionOption],
) -> dict[str, Any]:
    if classification not in CLASSIFICATIONS:
        raise ApprovalDecisionError(f"invalid classification: {classification}")
    _reject_off_enum(origin, "origin", "ClarificationOrigin")
    _reject_off_enum(
        user_confirmation, "user_confirmation", "ClarificationUserConfirmation"
    )
    if recommended_disposition not in DISPOSITIONS:
        raise ApprovalDecisionError(
            f"invalid recommended_disposition: {recommended_disposition}"
        )
    _validate_option_set(options, classification, recommended_disposition)
    return {
        "id": _required(clarification_id, "clarification_id"),
        "ticketId": _required(ticket_id, "ticket_id"),
        "kind": "decision",
        "statement": _required(statement, "statement"),
        "expectedForm": _required(expected_form, "expected_form"),
        "blocks": "approval",
        "origin": _required(origin, "origin"),
        "userConfirmation": _required(user_confirmation, "user_confirmation"),
        "approval": {
            "classification": classification,
            "unblockCondition": _required(unblock_condition, "unblock_condition"),
            "recommendedDisposition": recommended_disposition,
        },
        "options": [option.to_payload() for option in options],
    }


def open_decision(
    *, ledger_path: Path, task_key: str, task_type: str, run_seq: str,
    clarification_id: str, ticket_id: str, statement: str, expected_form: str,
    classification: str, origin: str, user_confirmation: str,
    unblock_condition: str, recommended_disposition: str,
    options: Sequence[DecisionOption],
) -> None:
    row = _decision_row(
        clarification_id=clarification_id, ticket_id=ticket_id,
        statement=statement, expected_form=expected_form,
        classification=classification, origin=origin,
        user_confirmation=user_confirmation, unblock_condition=unblock_condition,
        recommended_disposition=recommended_disposition, options=options,
    )
    ledger = _ledger(ledger_path, task_key, task_type, run_seq)
    active = ledger.get("activeClarifications")
    if not isinstance(active, list):
        raise ApprovalDecisionError("activeClarifications must be an array")
    if any(item.get("id") == clarification_id for item in active if isinstance(item, Mapping)):
        raise ApprovalDecisionError(f"duplicate active clarification: {clarification_id}")
    active.append(row)
    write_json_atomic(ledger_path, ledger)


def resolve_decision(
    ledger_path: Path, clarification_id: str, *, disposition: str,
    user_text: str, user_response_ref: str, check_refs: Sequence[str],
) -> None:
    if disposition not in DISPOSITIONS:
        raise ApprovalDecisionError(f"invalid disposition: {disposition}")
    resolution = {
        "disposition": disposition,
        "userText": _required(user_text, "user_text"),
        "userResponseRef": _required(user_response_ref, "user_response_ref"),
        "checkRefs": [_required(ref, "check_refs") for ref in check_refs],
    }
    if not resolution["checkRefs"]:
        raise ApprovalDecisionError("check_refs requires at least one value")
    invalid_refs = [
        ref for ref in resolution["checkRefs"]
        if re.fullmatch(r"A-\d{3,}", ref) is None
    ]
    if invalid_refs:
        raise ApprovalDecisionError(f"check_refs must be activity IDs: {invalid_refs}")
    ledger = _read_ledger(ledger_path)
    active = ledger.get("activeClarifications")
    rows = active if isinstance(active, list) else []
    matches = [row for row in rows if isinstance(row, dict) and row.get("id") == clarification_id]
    if len(matches) != 1:
        raise ApprovalDecisionError(f"active clarification not found: {clarification_id}")
    matches[0]["resolutionInput"] = resolution
    write_json_atomic(ledger_path, ledger)


def carry_decision(
    ledger_path: Path, *, source_run_ref: str, decision: Mapping[str, Any],
) -> None:
    ledger = _read_ledger(ledger_path)
    carried = ledger.get("carriedDecisions")
    if not isinstance(carried, list):
        raise ApprovalDecisionError("carriedDecisions must be an array")
    row = {
        "sourceRunRef": _required(source_run_ref, "source_run_ref"),
        "decision": deepcopy(dict(decision)),
    }
    if row not in carried:
        carried.append(row)
    write_json_atomic(ledger_path, ledger)


_CARRIED_STATUSES = frozenset({"answered", "resolved"})


def _carried_answer(row: Mapping[str, Any], sidecar_answer: str) -> str:
    """The answer a prior report's row carries, or an empty string.

    The user-responses sidecar wins over the row: it is the later writing,
    and a row the report left `open` is answered only there. A `resolved`
    row keeps its answer in `resolution.userText`; an `answered` row in
    `userInput`.
    """
    if sidecar_answer.strip():
        return sidecar_answer.strip()
    user_input = row.get("userInput")
    if isinstance(user_input, str) and user_input.strip():
        return user_input.strip()
    resolution = row.get("resolution")
    if isinstance(resolution, Mapping):
        text = resolution.get("userText")
        if isinstance(text, str) and text.strip():
            return text.strip()
    return ""


def seed_carried_decisions(
    ledger_path: Path, source: Path, *, source_run_ref: str,
) -> list[str]:
    """Carry every clarification the carry-in report already settled.

    Prepare calls this when it creates a run's ledger and the run was
    launched with a carry-in. The lead used to carry each id by hand and
    mostly did not (2026-09-04, dev-10626: the error-analysis ledgers carried
    six rows, the option-selection and planning ledgers none), so the report
    cited `C-005` in its prose and had no row for it. Carrying is a copy with
    no judgement in it — the prior record already holds the question, its
    options, and the answer — which is why it lives here rather than in the
    lead's procedure.

    Only a structured report record (or the Markdown sibling of one) seeds:
    an answers-only file has no question rows to copy, and there
    ``okstra approval-decision carry --from-responses`` remains the path.
    A row is carried when it is `answered` or `resolved`, or when the
    record's user-responses sidecars answer it; an `open` row nobody
    answered and an `obsolete` row are not decisions. Returns the carried
    ids in record order.
    """
    from .clarification_items.rows import _structured_report_data

    data = _structured_report_data(source)
    if data is None:
        return []
    rows = data.get("clarificationItems")
    if not isinstance(rows, list):
        return []
    answers = sidecar_answers(source)
    carried: list[str] = []
    for row in rows:
        if not isinstance(row, Mapping):
            continue
        clarification_id = row.get("id")
        if not isinstance(clarification_id, str) or not clarification_id:
            continue
        status = str(row.get("status") or "")
        answer = _carried_answer(row, answers.get(clarification_id, ""))
        if status == "obsolete" or not answer:
            continue
        if status not in _CARRIED_STATUSES and clarification_id not in answers:
            continue
        decision = {key: row[key] for key in _QUESTION_FIELDS if key in row}
        if isinstance(decision.get("options"), list):
            decision["options"] = [
                _contract_v3_option(option, clarification_id)
                for option in decision["options"]
                if isinstance(option, Mapping)
            ]
        if "approvalContext" in row:
            decision["approval"] = row["approvalContext"]
        decision["userConfirmation"] = "asked-and-answered"
        decision["userInput"] = answer
        carry_decision(ledger_path, source_run_ref=source_run_ref, decision=decision)
        carried.append(clarification_id)
    return carried


def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
    fields = (
        args.option_role, args.option_answer, args.option_rationale,
        args.option_disposition, args.option_reach, args.option_scope_effect,
        args.option_added_work, args.option_direction_change,
    )
    if len({len(values) for values in fields}) != 1:
        names = (
            "role", "answer", "rationale", "disposition", "reach",
            "scope-effect", "added-work", "direction-change",
        )
        counts = ", ".join(
            f"--option-{name}={len(values)}" for name, values in zip(names, fields)
        )
        raise ApprovalDecisionError(
            "every repeated option field needs the same count — repeat each "
            f"--option-* flag once per option, including the ones --help marks "
            f"optional: {counts}"
        )
    return tuple(
        DecisionOption(role, answer, rationale, disposition, reach,
                       tuple(effect.split(",")) if effect else (), added, direction)
        for role, answer, rationale, disposition, reach, effect, added, direction
        in zip(*fields, strict=True)
    )


def _add_open_arguments(parser: argparse.ArgumentParser) -> None:
    required = (
        "task-key", "task-type", "run-seq", "clarification-id", "ticket-id",
        "statement", "expected-form", "classification", "origin",
        "user-confirmation", "unblock-condition", "recommended-disposition",
    )
    for flag in required:
        parser.add_argument(f"--{flag}", required=True)
    repeated = (
        "role", "answer", "rationale", "disposition", "reach",
        "scope-effect", "added-work", "direction-change",
    )
    for flag in repeated:
        parser.add_argument(f"--option-{flag}", action="append", default=[])


_CLI_EPILOG = r"""Usage:
  okstra approval-decision open --ledger <path> [decision fields]
  okstra approval-decision resolve --ledger <path> --clarification-id <C-NNN> [resolution fields]
  okstra approval-decision carry --ledger <path> --from-responses <clarification-response.md> --clarification-id <C-NNN> [--clarification-id <C-NNN> ...]
  okstra approval-decision carry --ledger <path> --source-ledger <path> --source-run-ref <ref> --clarification-id <C-NNN>
"""
_CLI_DESCRIPTION = "Record lead-owned approval inputs."


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=_CLI_DESCRIPTION,
        epilog=_CLI_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        prog="okstra approval-decision")
    commands = parser.add_subparsers(dest="command", required=True)
    opened = commands.add_parser("open")
    opened.add_argument("--ledger", type=Path, required=True)
    _add_open_arguments(opened)
    resolved = commands.add_parser("resolve")
    resolved.add_argument("--ledger", type=Path, required=True)
    resolved.add_argument("--clarification-id", required=True)
    resolved.add_argument("--disposition", required=True)
    resolved.add_argument("--user-text", required=True)
    resolved.add_argument("--user-response-ref", required=True)
    resolved.add_argument("--check-ref", action="append", default=[])
    carried = commands.add_parser("carry")
    carried.add_argument("--ledger", type=Path, required=True)
    source = carried.add_mutually_exclusive_group(required=True)
    source.add_argument(
        "--from-responses",
        type=Path,
        help=(
            "attached user-responses bundle (`instruction-set/"
            "clarification-response.md`). It is the source of truth for the "
            "answer; the question's own fields come from the report each "
            "response section names. Task-level and cumulative, so no prior "
            "run seq has to be located."
        ),
    )
    source.add_argument(
        "--source-ledger",
        type=Path,
        help="a prior run's approval ledger, when it is still on disk",
    )
    carried.add_argument(
        "--source-run-ref",
        default="",
        help=(
            "provenance recorded on the carried row. Required with "
            "--source-ledger; with --from-responses it defaults to the "
            "response section the answer came from."
        ),
    )
    carried.add_argument(
        "--clarification-id",
        action="append",
        required=True,
        help="repeat to carry several ids in one call",
    )
    return parser


def _open_from_args(args: argparse.Namespace) -> None:
    values = vars(args).copy()
    values.pop("command")
    values["ledger_path"] = values.pop("ledger")
    values["run_seq"] = normalize_run_seq(values["run_seq"])
    for key in tuple(values):
        if key.startswith("option_"):
            values.pop(key)
    open_decision(**values, options=_options_from_args(args))


# `kind: decision` 인 행은 스키마가 옵션 메뉴를 요구하므로 함께 가져온다.
# `resolution` 은 가져오지 않는다 — 그 `checkRefs` 는 답을 기록한 run 의 활동
# 행이고, 이월 행은 `answered` 로 실린다.
_QUESTION_FIELDS = (
    "id", "ticketId", "kind", "statement", "expectedForm", "blocks", "origin",
    "options",
)


def _responses_index(path: Path) -> dict[str, tuple[Any, str, str, str]]:
    """묶음 파일의 답을 ``{id: (entry, 구간 이름, 리포트 기록 이름, task-type)}`` 로.

    같은 id 를 여러 run 이 답했으면 뒤 구간이 이긴다 — 묶음은 시간순으로 쌓이고
    최신 답이 정본이다.
    """
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise ApprovalDecisionError(f"cannot read {path}: {exc}") from exc
    sections = attached_response_sections(text)
    if not sections:
        raise ApprovalDecisionError(
            f"not an attached user-responses bundle (no `## user-response-*.md` "
            f"section): {path}"
        )
    index: dict[str, tuple[Any, str, str, str]] = {}
    for name, body in sections:
        report = response_source_report(body)
        task_type = _frontmatter_task_type(body)
        for entry in parse_user_response_entries(body):
            index[entry.response_id] = (entry, name, report, task_type)
    return index


def _frontmatter_task_type(section_body: str) -> str:
    match = re.search(r"^task-type:\s*(\S.*?)\s*$", section_body, re.MULTILINE)
    return match.group(1) if match else ""


def _prior_question_row(
    responses_path: Path, clarification_id: str, report_name: str, task_type: str,
) -> dict[str, Any]:
    """질문 메타데이터의 출처는 그 질문을 낸 리포트다.

    묶음 파일은 답만 담는다. `ticketId`·`statement`·`expectedForm` 은 스키마가
    요구하는데 묶음에 없으므로, 지어내는 대신 구간 frontmatter 가 지목한
    리포트에서 읽는다. 그 리드가 seq 를 찾아 헤매지 않게 하는 것이 이 경로의
    요점이다.
    """
    if not report_name or not task_type:
        raise ApprovalDecisionError(
            f"{clarification_id}: its response section names no source report; "
            "carry it with --source-ledger instead"
        )
    task_root = responses_path.resolve().parent.parent
    report_path = (
        task_root / "runs" / task_type / "reports" / Path(report_name).name
    )
    if not report_path.is_file():
        raise ApprovalDecisionError(
            f"{clarification_id}: source report not found: {report_path}"
        )
    try:
        data = load_owned_object(report_path, artifact="prior final report")
    except (JsonBoundaryError, OSError) as exc:
        raise ApprovalDecisionError(f"cannot read {report_path}: {exc}") from exc
    rows = data.get("clarificationItems") if isinstance(data, Mapping) else None
    match = next(
        (
            row for row in (rows or [])
            if isinstance(row, Mapping) and row.get("id") == clarification_id
        ),
        None,
    )
    if match is None:
        raise ApprovalDecisionError(
            f"{clarification_id}: not defined in {report_path.name}"
        )
    row = {key: match[key] for key in _QUESTION_FIELDS if key in match}
    if isinstance(row.get("options"), list):
        row["options"] = [
            _contract_v3_option(option, clarification_id)
            for option in row["options"]
            if isinstance(option, Mapping)
        ]
    if "approvalContext" in match:
        row["approval"] = match["approvalContext"]
    return row


_REACH_TOKENS = ("in-repo", "cross-repo")


def _contract_v3_option(option: Mapping[str, Any], clarification_id: str) -> dict[str, Any]:
    """계약 2.0 의 `scopeImpact` 를 3.0 의 `reach` + `scopeEffects` 로 되돌린다.

    두 열거는 정확히 분할 관계다 — 2.0 의 토큰 집합
    `{in-repo, cross-repo, new-schema, deferrable}` 이 3.0 에서 `reach`
    (`in-repo|cross-repo`, 정확히 하나)와 `scopeEffects`(나머지)로 갈렸다.
    그래서 이 변환은 값을 지어내지 않는다. 이미 3.0 모양이면 그대로 둔다.
    """
    row = dict(option)
    if "reach" in row:
        row.pop("scopeImpact", None)
        return row
    tokens = row.pop("scopeImpact", None)
    if not isinstance(tokens, list):
        raise ApprovalDecisionError(
            f"{clarification_id}: an option states neither `reach` nor "
            "`scopeImpact`; carry it with --source-ledger instead"
        )
    reach = [token for token in tokens if token in _REACH_TOKENS]
    if len(reach) != 1:
        raise ApprovalDecisionError(
            f"{clarification_id}: an option's `scopeImpact` names {len(reach)} "
            "reach token(s); exactly one is needed to state `reach`"
        )
    row["reach"] = reach[0]
    effects = [token for token in tokens if token not in _REACH_TOKENS]
    if effects:
        row["scopeEffects"] = effects
    return row


def _carry_from_responses(args: argparse.Namespace) -> None:
    index = _responses_index(args.from_responses)
    for clarification_id in args.clarification_id:
        found = index.get(clarification_id)
        if found is None:
            raise ApprovalDecisionError(
                f"clarification not answered in {args.from_responses}: "
                f"{clarification_id}"
            )
        entry, section, report_name, task_type = found
        if not entry.value.strip():
            raise ApprovalDecisionError(
                f"{clarification_id}: its answer in {section} has no Value"
            )
        decision = _prior_question_row(
            args.from_responses, clarification_id, report_name, task_type
        )
        # 답이 정본인 자리는 여기다. `resolutionInput` 은 쓰지 않는다 —
        # 그 모양은 `checkRefs` 로 이번 run 의 활동 행을 요구하는데, 이월된
        # 답에는 이번 run 의 활동이 없다(`report_assembly` 의 이월 분기).
        decision["userConfirmation"] = "asked-and-answered"
        decision["userInput"] = entry.value
        carry_decision(
            args.ledger,
            source_run_ref=args.source_run_ref or section,
            decision=decision,
        )


def _carry_from_args(args: argparse.Namespace) -> None:
    source = _read_ledger(args.source_ledger)
    rows = source.get("activeClarifications") or []
    for clarification_id in args.clarification_id:
        matches = [
            row for row in rows
            if isinstance(row, dict) and row.get("id") == clarification_id
        ]
        if len(matches) != 1:
            raise ApprovalDecisionError(
                f"source clarification not found: {clarification_id}"
            )
        carry_decision(
            args.ledger, source_run_ref=args.source_run_ref, decision=matches[0]
        )


def _run(args: argparse.Namespace) -> None:
    if args.command == "open":
        _open_from_args(args)
    elif args.command == "resolve":
        resolve_decision(
            args.ledger, args.clarification_id, disposition=args.disposition,
            user_text=args.user_text, user_response_ref=args.user_response_ref,
            check_refs=args.check_ref,
        )
    elif args.from_responses:
        _carry_from_responses(args)
    else:
        if not args.source_run_ref:
            raise ApprovalDecisionError("--source-ledger requires --source-run-ref")
        _carry_from_args(args)


def main(argv: list[str] | None = None) -> int:
    try:
        args = _parser().parse_args(argv)
        _run(args)
        print(json.dumps({"ok": True, "ledger": str(args.ledger)}, ensure_ascii=False))
        return 0
    except (ApprovalDecisionError, OSError) as exc:
        print(f"approval-decision: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
