"""`okstra stage-close` — 이미 랜딩한 stage 를 기록만 남겨 done 으로 닫는다.

stage 완료의 정본은 `runs/implementation-planning/consumers.jsonl` 의 `done`
행이고, 그 행은 두 경로로만 생겼다: implementation run 이 정상 종료하거나,
`backfill_done_from_carry` 가 carry 사이드카에서 복원하거나. 두 경로 모두
없는 상태가 실재한다 — 제품 변경은 커밋되고 conformance 결과도 PASS 인데
run 이 carry 를 쓰기 전에 끝난 경우다. 그러면 `stage-map` 은 `doneStages: []`
을 보고하고, 다음 계획은 아무 일도 없었던 것처럼 그 stage 를 다시 쓰며, 거기서
나오는 RED 기대는 전부 도달 불가다(실측 2026-09-10, fontsninja-v3-site
dev-10628-3: 마지막 행이 `started`, carry 디렉터리는 빈 폴더, stage 결과는
`overall: PASS`).

이 명령은 그 복구 경로다. 커밋과 conformance 결과라는 두 증거를 확인한 뒤
done 행을 append 한다 — 증거 없이 닫는 수단은 아니다.
"""
from __future__ import annotations

import argparse
import json
import subprocess
from pathlib import Path

from okstra_project import (
    ResolverError,
    StateError,
    resolve_project_root,
    resolve_task_identity,
)

from .conformance import (
    conformance_result_file,
    decide_conformance_gate,
    qa_result_from_dict,
)
from .consumers import append_consumer, last_lifecycle_status_by_stage, read_consumers
from .json_boundary import JsonBoundaryError, load_owned_object
from .paths import RunRef, task_conformance_manifest_file, task_qa_dir
from .stage_map import StageMapError, load_latest_plan_stage_map

# 이 status 를 이미 가진 stage 는 리드가 판정을 내린 것이다. 되쓰기는 그 판정을
# 덮는 일이므로 여기서 하지 않는다.
_SETTLED = ("done", "failed")


def _git_commit_exists(repo_root: Path, commit: str) -> bool:
    probe = subprocess.run(
        ["git", "-C", str(repo_root), "cat-file", "-e", f"{commit}^{{commit}}"],
        capture_output=True,
    )
    return probe.returncode == 0


def _manifest_entry(task_root: Path, stage: int) -> dict | None:
    """이 stage 의 conformance entry, 선언이 없으면 None.

    stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라 디렉터리 segment 와 표기가
    다를 수 있으므로 `-stage-<N>` 접미사로 맞춘다 — `_clear_stale_stage_waiver`
    와 같은 규칙이다.
    """
    path = task_conformance_manifest_file(task_root)
    if not path.is_file():
        return None
    try:
        manifest = load_owned_object(path, artifact="conformance manifest")
    except JsonBoundaryError as exc:
        raise StateError(str(exc), stage="conformance") from exc
    entries = manifest.get("entries")
    if not isinstance(entries, list):
        return None
    suffix = f"-stage-{stage}"
    return next(
        (
            entry for entry in entries
            if isinstance(entry, dict)
            and isinstance(entry.get("stageKey"), str)
            and entry["stageKey"].endswith(suffix)
        ),
        None,
    )


def _conformance_state(task_root: Path, stage: int) -> tuple[bool, str]:
    """(닫아도 되는가, 사람이 읽는 근거).

    선언 자체가 없으면 게이트할 것이 없다. 있으면 그 stage 의 결과 사이드카로
    `decide_conformance_gate` 를 그대로 돌린다 — 검증기가 쓰는 것과 같은 판정
    함수라, 여기서 통과한 stage 는 검증기에서도 통과한다.
    """
    entry = _manifest_entry(task_root, stage)
    if entry is None:
        return True, "no conformance entry declared for this stage"
    key = str(entry.get("stageKey"))
    sidecar = conformance_result_file(task_qa_dir(task_root), key)
    result = None
    if sidecar.is_file():
        try:
            result = qa_result_from_dict(
                load_owned_object(sidecar, artifact="conformance result")
            )
        except JsonBoundaryError:
            # 읽을 수 없는 결과는 결과가 아니다 — MISSING 으로 게이트에 넘겨
            # 판정을 `decide_conformance_gate` 가 내리게 한다. 검증기도 같다.
            result = qa_result_from_dict(None)
    verdict = decide_conformance_gate(entry, result)
    return verdict.ok, f"{verdict.status}: {verdict.message}"


def close_stage(
    project_root: Path, task_key: str, stage: int, head_commit: str,
) -> dict:
    """stage 를 done 으로 닫고 그 근거를 함께 돌려준다."""
    identity = resolve_task_identity(project_root, task_key)
    task_root = Path(identity["taskRoot"])
    plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
    if not plan_run_root.is_dir():
        raise StateError(
            f"this task has no implementation-planning run at {plan_run_root} — "
            "there is no Stage Map to close a stage of",
            stage="plan-run",
        )

    try:
        stage_map = load_latest_plan_stage_map(task_root)
    except StageMapError as exc:
        raise StateError(str(exc), stage=exc.code) from exc
    known = sorted(
        row["stage_number"] for row in stage_map.stages
        if isinstance(row, dict) and isinstance(row.get("stage_number"), int)
    )
    if stage not in known:
        raise StateError(
            f"stage {stage} is not in this task's Stage Map (has {known or 'none'})",
            stage="stage-map",
        )

    recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root))
    if recorded.get(stage) in _SETTLED:
        raise StateError(
            f"stage {stage} is already recorded {recorded[stage]!r} — a lead's "
            "ruling is not rewritten here",
            stage="consumers",
        )

    if not _git_commit_exists(project_root, head_commit):
        raise StateError(
            f"{head_commit} is not a commit in {project_root} — pass the commit "
            "the stage's work actually landed as",
            stage="git",
        )

    ok, conformance = _conformance_state(task_root, stage)
    if not ok:
        raise StateError(
            f"stage {stage} conformance does not permit closing it "
            f"({conformance}) — run the stage's conformance script, or record a "
            'user waiver with prepare `--qa-waiver "<stageKey>:<reason>"`, '
            "before closing the stage",
            stage="conformance",
        )

    append_consumer(
        plan_run_root,
        impl_task_key=identity["taskKey"],
        stage=stage,
        status="done",
        head_commit=head_commit,
        closed_by="stage-close",
    )
    return {
        "taskKey": identity["taskKey"],
        "taskRoot": str(task_root),
        "stage": stage,
        "headCommit": head_commit,
        "conformance": conformance,
        "consumersPath": str(plan_run_root / "consumers.jsonl"),
    }


_CLI_EPILOG = r"""Usage:
  okstra stage-close <task-key> --stage <N> --from-commit <sha>
  okstra stage-close <task-key> --stage <N> --from-commit <sha> --project <dir>

Records the `done` row an implementation run would have written, for a stage
whose work is already committed but which never registered as done (the run
ended before writing its carry sidecar). Refuses unless the Stage Map has that
stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a
commit in the project repo, and the stage's conformance gate permits progress.

Output: JSON { ok, taskKey, taskRoot, stage, headCommit, conformance,
consumersPath }. Exit 1 on a refusal (the reason names what to do), 2 when
PROJECT_ROOT cannot be resolved.
"""


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Close an already-landed implementation stage as done.",
        epilog=_CLI_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        prog="okstra stage-close")
    parser.add_argument("task_key", metavar="task-key",
                        help="project-id:task-group:task-id")
    parser.add_argument("--stage", type=int, required=True,
                        help="the Stage Map number to close")
    parser.add_argument("--from-commit", required=True, dest="from_commit",
                        help="the commit the stage's work landed as")
    parser.add_argument("--project-root", "--project", default="",
                        help="use this directory as PROJECT_ROOT")
    parser.add_argument("--cwd", default=".",
                        help="resolve PROJECT_ROOT starting from here")
    args = parser.parse_args(argv)

    def emit(payload: dict) -> None:
        print(json.dumps(payload, ensure_ascii=False, indent=2))

    try:
        resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
    except ResolverError as exc:
        emit({"ok": False, "stage": "resolve", "reason": str(exc)})
        return 2

    if args.stage < 1:
        emit({"ok": False, "stage": "stage-map",
              "reason": f"--stage must be a positive integer, got {args.stage}"})
        return 1

    try:
        closed = close_stage(
            Path(resolved), args.task_key, args.stage, args.from_commit.strip(),
        )
    except StateError as exc:
        emit({
            "ok": False,
            "stage": getattr(exc, "stage", None) or "stage-close",
            "reason": str(exc),
        })
        return 1

    emit({"ok": True, **closed})
    return 0


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