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

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

from .convergence_store import write_json_atomic
from .json_boundary import JsonBoundaryError, load_owned_object


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:
        raise ApprovalDecisionError("approval ledger identity does not match this run")
    return ledger


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 _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}")
    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)


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:
        raise ApprovalDecisionError("every repeated option field needs the same count")
    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=[])


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(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)
    carried.add_argument("--source-ledger", type=Path, required=True)
    carried.add_argument("--source-run-ref", required=True)
    carried.add_argument("--clarification-id", required=True)
    return parser


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


def _carry_from_args(args: argparse.Namespace) -> None:
    source = _read_ledger(args.source_ledger)
    rows = source.get("activeClarifications") or []
    matches = [row for row in rows if isinstance(row, dict) and row.get("id") == args.clarification_id]
    if len(matches) != 1:
        raise ApprovalDecisionError(f"source clarification not found: {args.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,
        )
    else:
        _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())
