"""사용자 확인형 TDD 우회 원장 — `<task-root>/qa/tdd-bypass.json`.

S10c 는 모든 stage 의 첫 step 에 `RED:` 를, 뒤 step 중 하나에 `GREEN:` 을
요구하고, S10e 는 그 요구를 `doc-only` / `config-only` / `pure-rename` 세
사유로만 면제한다. 세 사유 중 어느 것도 맞지 않는 stage 는 통과할 값이
없으므로, 계획서가 가장 가까운 토큰을 골라 자기를 잘못 기술하게 된다
(실측 2026-09-10, fontsninja-v3-site dev-10628-3 implementation-planning 002:
제품 변경이 이전 run 에서 이미 커밋·적합성 PASS 까지 끝난 stage 를
`config-only` 로 신고).

그래서 네 번째 사유 `user-bypass` 는 계획서의 선언만으로는 성립하지 않는다.
사용자가 `okstra prepare --tdd-bypass "<stage>:<reason>"` 로 이 파일에 사유를
원문 그대로 남겨야 검증기가 인정한다 — `qa/self-mock-waivers.json` 이 gate A/B
의 우회를 담는 방식과 같은 idiom 이고, 같은 `{reason, acknowledgedBy}` 계약을
쓴다. 계획서가 스스로에게 면제를 발급하는 경로는 없다.
"""
from __future__ import annotations

from pathlib import Path
from typing import Any, Mapping

from .json_boundary import (
    JsonBoundaryError,
    load_owned_object,
    write_owned_object_atomic,
)

ARTIFACT = "tdd bypass ledger"
FILENAME = "tdd-bypass.json"
SCHEMA_VERSION = "1.0"

# `tddExemption` 에 적는 값. S10e 가 이 토큰을 볼 때만 원장을 조회한다.
REASON_TOKEN = "user-bypass"


class TddBypassError(ValueError):
    """TDD 우회 원장 입력이 계약을 위반했다."""


def bypass_file(task_root: Path) -> Path:
    """이 task 의 우회 원장 경로. conformance 산출물과 같은 `qa/` 아래다."""
    return task_root / "qa" / FILENAME


def parse_bypass_arg(value: object) -> tuple[int, str] | None:
    """`--tdd-bypass` 값 `<stage>:<reason>` 를 (stage, reason) 로 분해.

    형식이 아니거나 stage 가 1 이상의 정수가 아니면 None — 호출 측이 무엇을
    받았는지 그대로 보여 주며 거절한다.
    """
    if not isinstance(value, str) or ":" not in value:
        return None
    raw_stage, reason = value.split(":", 1)
    raw_stage, reason = raw_stage.strip(), reason.strip()
    if not raw_stage.isdigit() or not reason:
        return None
    stage = int(raw_stage)
    if stage < 1:
        return None
    return stage, reason


def _entries(ledger: object) -> list[dict[str, Any]]:
    rows = ledger.get("entries") if isinstance(ledger, Mapping) else None
    return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []


def record_bypass(
    path: Path, stage: int, reason: str, *, at: str, acknowledged_by: str = "user",
) -> None:
    """stage 의 우회 사유를 원문 그대로 기록한다(같은 stage 는 마지막 값이 이긴다).

    사용자가 사유를 고쳐 다시 부여하는 것이 정상 경로이므로 중복은 오류가
    아니라 교체다. 파일이 없으면 만든다 — 전제 파일 부재로 거절하면 사용자가
    빈 원장을 손으로 만들어야 한다.
    """
    if not isinstance(stage, int) or stage < 1:
        raise TddBypassError(f"stage must be a positive integer, got {stage!r}")
    if not isinstance(reason, str) or not reason.strip():
        raise TddBypassError("reason must be a non-empty string")
    if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
        raise TddBypassError("acknowledgedBy must be a non-empty string")
    ledger: dict[str, Any]
    if path.is_file():
        try:
            ledger = load_owned_object(path, artifact=ARTIFACT)
        except JsonBoundaryError as exc:
            raise TddBypassError(str(exc)) from exc
    else:
        ledger = {"schemaVersion": SCHEMA_VERSION, "entries": []}
    rows = [row for row in _entries(ledger) if row.get("stage") != stage]
    rows.append({
        "stage": stage,
        "reason": reason.strip(),
        "acknowledgedBy": acknowledged_by.strip(),
        "at": at,
    })
    ledger["schemaVersion"] = ledger.get("schemaVersion") or SCHEMA_VERSION
    ledger["entries"] = sorted(rows, key=lambda row: row["stage"])
    path.parent.mkdir(parents=True, exist_ok=True)
    try:
        write_owned_object_atomic(path, ledger, artifact=ARTIFACT)
    except JsonBoundaryError as exc:
        raise TddBypassError(str(exc)) from exc


def granted_stages(path: Path) -> dict[int, str]:
    """`{stage: reason}` — 사용자가 우회를 부여한 stage 들.

    파일이 없으면 빈 map 이다(우회 없음). 사유나 승인자가 빈 행은 우회로
    세지 않는다 — 그 행은 사용자가 무엇을 승인했는지 말하지 못한다.
    """
    if not path.is_file():
        return {}
    try:
        ledger = load_owned_object(path, artifact=ARTIFACT)
    except JsonBoundaryError as exc:
        raise TddBypassError(str(exc)) from exc
    granted: dict[int, str] = {}
    for row in _entries(ledger):
        stage = row.get("stage")
        reason = row.get("reason")
        acknowledged_by = row.get("acknowledgedBy")
        if not isinstance(stage, int) or isinstance(stage, bool) or stage < 1:
            continue
        if not isinstance(reason, str) or not reason.strip():
            continue
        if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
            continue
        granted[stage] = reason.strip()
    return granted
