"""plan 단계 — approved plan, approve confirm, stage pick, handoff stage, selected direction, fix cycle, reverify scope 의 build/submit."""
from __future__ import annotations

import re
from pathlib import Path
from collections.abc import Mapping
from typing import Optional

from okstra_ctl.clarification_items import scan_approval_gate
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
from okstra_ctl.incremental_scope import CARRY_ALL_SCOPE
from okstra_ctl.implementation_direction import (
    DirectionSelectionError,
    lexical_absolute_path,
    resolve_selected_direction,
    validate_task_artifact_path,
)
from okstra_ctl.final_report_paths import final_report_data_path
from okstra_ctl.run import (
    PrepareError,
    _apply_cli_implementation_option,
    _load_final_report_data_if_present,
    _record_approved_flag,
    _reject_blocking_plan_body_gate,
    _set_data_json_approved_true_if_present,
)
from okstra_ctl.stage_map import StageMapError, parse_stage_map_file, stage_map_records
from okstra_ctl.user_response import PlanDecisionRecord, parse_plan_decision
from okstra_ctl.wizard_stage_intent import WHOLE_TASK_STAGE
from okstra_ctl import fix_cycles
from okstra_ctl.paths import task_dir, task_runs_dir
from okstra_project.state import read_task_manifest

from .ids import (
    ALL_STAGES,
    PICK_OTHER,
    PICK_TYPE_CUSTOM,
    PICK_USE_DEFAULT,
    SCOPE_SHAPE_LINKED,
    SCOPE_SHAPE_NO_ANSWERS,
    SCOPE_SHAPE_UNLINKED,
    S_APPROVED_PLAN,
    S_APPROVED_PLAN_PICK,
    S_APPROVE_PLAN_CONFIRM,
    S_FIX_CYCLE_CONFIRM,
    S_HANDOFF_STAGE_PICK,
    S_REVERIFY_SCOPE_PICK,
    S_REVERIFY_SCOPE_STAGES,
    S_SELECTED_DIRECTION_PICK,
    S_STAGE_PICK,
    _REPORT_PREFIX,
)
from .state import Option, Prompt, WizardError, WizardState
from .prompts import _opt, _p
from .sources import (
    _latest_implementation_planning_report,
    _list_implementation_planning_reports,
    _parse_stage_objects,
    _prior_stage_numbers,
    _project_relative_path,
    _require_file,
    _reserved_stage_numbers,
    _reverify_scope_pick_required,
    _reverify_scope_preview,
    _reverify_scope_shape,
    _stage_lifecycle_snapshot,
)


# 후보는 리포트 **기록**으로 찾는다. 전체 열람본(`.md`)은 `2bcd575` 이후 완료
# 산출물이 아니라 요청 시 렌더라, `.md` 를 글롭하는 동안 이 목록은 항상 비었고
# 방향 선택 단계 자체가 뜨지 않았다 — 비교가 끝나도 계획으로 갈 길이 없었다.
_SELECTION_REPORT_RE = re.compile(
    r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.data\.json$"
)


def _selected_direction_candidates(state: WizardState) -> list[str]:
    if not state.project_root or not state.task_group or not state.task_id:
        return []
    project_root = Path(state.project_root).resolve()
    task_root = lexical_absolute_path(
        task_dir(project_root, state.task_group, state.task_id)
    )
    reports = (
        task_runs_dir(project_root, state.task_group, state.task_id)
        / "implementation-option-selection"
        / "reports"
    )
    candidates: list[tuple[int, Path]] = []
    for report in reports.glob(
        "final-report-implementation-option-selection-*.data.json"
    ):
        match = _SELECTION_REPORT_RE.fullmatch(report.name)
        if match is None:
            continue
        try:
            validated = validate_task_artifact_path(
                report, task_root, "selection report"
            )
        except DirectionSelectionError:
            continue
        candidates.append((int(match.group("seq")), validated))
    return [
        _project_relative_path(path, project_root)
        for _, path in sorted(candidates, reverse=True)[:3]
    ]


def _planning_rerun_selected(state: WizardState) -> bool:
    if (
        not state.clarification_response_path
        or not state.project_root
        or not state.task_group
        or not state.task_id
    ):
        return False
    project_root = Path(state.project_root).resolve()
    raw_path = Path(state.clarification_response_path).expanduser()
    path = lexical_absolute_path(
        raw_path if raw_path.is_absolute() else project_root / raw_path
    )
    task_root = lexical_absolute_path(
        task_dir(project_root, state.task_group, state.task_id)
    )
    reports = lexical_absolute_path(
        task_runs_dir(project_root, state.task_group, state.task_id)
        / "implementation-planning"
        / "reports"
    )
    try:
        validate_task_artifact_path(path, task_root, "planning report")
    except DirectionSelectionError:
        return False
    return (
        path.is_file()
        and not path.is_symlink()
        and re.fullmatch(
            r"final-report-implementation-planning-\d{3,}\.(?:md|data\.json)", path.name
        )
        is not None
        and path.parent == reports
    )


def _build_selected_direction_pick(state: WizardState) -> Prompt:
    candidates = _selected_direction_candidates(state)
    t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
    if not candidates:
        raise WizardError(t["errors"]["none"])
    return Prompt(
        step=S_SELECTED_DIRECTION_PICK,
        kind="pick",
        label=t["label"],
        options=[
            _opt(path, _selection_option_label(state, path, t))
            for path in candidates
        ],
        echo_template=t["echo_template"],
    )


def _selection_option_label(state: WizardState, rel_path: str, t: dict) -> str:
    """후보 경로에 그 리포트가 실제로 무엇을 담았는지 붙인다.

    목록은 최신순이라 후보가 0건인 최근 재실행이 맨 위에 온다. 경로만 보이면
    사용자가 그것을 고르고, 방향 없는 리포트로 계획을 열려다 막힌다.
    """
    record = Path(state.project_root) / rel_path
    try:
        data = load_owned_object(record, artifact="implementation option data")
    except (OSError, JsonBoundaryError):
        return rel_path
    selection = data.get("implementationOptionSelection")
    if not isinstance(selection, Mapping):
        return rel_path
    rows = selection.get("rankedOptions")
    ids = [
        str(row.get("id"))
        for row in rows
        if isinstance(row, Mapping) and row.get("id")
    ] if isinstance(rows, list) else []
    if not ids:
        return t["labels"]["no_options"].format(path=rel_path)
    return t["labels"]["options"].format(path=rel_path, ids=", ".join(ids))


def _submit_selected_direction_pick(
    state: WizardState, value: str
) -> Optional[str]:
    t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
    candidates = _selected_direction_candidates(state)
    if value not in candidates:
        raise WizardError(t["errors"]["unknown"].format(value=value))
    try:
        resolve_selected_direction(
            Path(state.project_root) / value,
            expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
        )
    except DirectionSelectionError as exc:
        raise WizardError(str(exc)) from exc
    state.selected_direction_path = value
    # 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
    # clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
    # 거절을 여기서 앞당기는 대신 값을 비우고, 무엇이 비워졌는지 echo 로
    # 알린다. 사용자 답변은 손실되지 않는다: 그 사이드카는
    # `--selected-direction` 경로로 계획 런에 첨부된다(`run.py` 참조).
    if state.clarification_response_path:
        state.clarification_response_path = ""
        return t["echo_variants"]["cleared_clarification"].format(value=value)
    return f"selected-direction: {value}"


def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bool]:
    """Resolve the plan and classify it as fully-approved vs approvable.

    Returns ``(resolved_path, already_fully_approved)``. Raises WizardError ONLY
    for failures that approval cannot fix: missing `approved` on the report
    record (or schema-v1 frontmatter), a blocking plan-body gate, an unparseable
    §1, or unresolved `Blocks=approval` rows. A plan that is merely
    not-yet-approved (record `approved: false`, gate ok, no blockers) returns
    ``already_fully_approved=False`` — the approve-confirm step offers to flip it.
    """
    from okstra_ctl.final_report_paths import require_approved_plan_record

    resolved = _require_file(path_str, project_root, "approved plan")
    try:
        p = require_approved_plan_record(resolved)
    except ValueError as exc:
        raise WizardError(str(exc)) from exc
    loaded = _load_final_report_data_if_present(p)
    if loaded is not None:
        planning = loaded[1].get("implementationPlanning")
        if (
            isinstance(planning, dict)
            and planning.get("planningContract") == "selected-direction"
            and planning.get("outcome") == "direction-invalidated"
        ):
            raise WizardError(
                "direction-invalidated planning reports are not approvable; "
                "re-enter implementation-option-selection"
            )
    # A blocking gate or an open Blocks=approval row makes the plan UN-approvable
    # — these raise regardless of the current flag value.
    _reject_blocking_plan_body_gate(p, "", action="approved plan validation")
    scan = scan_approval_gate(p)
    if scan.unreadable_reason:
        raise WizardError(
            f"approved plan §1 approval gate could not be read: {p}\n"
            f"  {scan.unreadable_reason}.\n"
            "  the gate refuses to soft-pass — re-render the report so §1 "
            "matches the schema."
        )
    blockers = scan.blockers
    if blockers:
        lines = [
            f"approved plan §1 has {len(blockers)} unresolved `Blocks=approval` "
            "row(s); resolve them or mark them obsolete before approving:",
        ]
        for b in blockers:
            lines.append(f"  - {b.row_id} (Status={b.raw_status})")
        lines.append(f"  file: {p}")
        raise WizardError("\n".join(lines))
    try:
        record_approved = _record_approved_flag(p)
    except PrepareError as exc:
        raise WizardError(str(exc)) from exc
    return p, record_approved is True


def _approve_plan_in_place(plan_path: Path) -> None:
    """Flip the report record `frontmatter.approved` to true and re-render."""
    if not _set_data_json_approved_true_if_present(plan_path):
        raise WizardError(
            f"approve-plan: report record could not be updated: {plan_path}"
        )


def _find_html_approval_sidecar(
    plan_path: Path,
) -> Optional[tuple[Path, PlanDecisionRecord]]:
    """plan 의 run 디렉토리 sibling ``user-responses/`` 에서 승인 판정을 담은
    sidecar 를 찾는다. source-report 파일명과 seq 가 plan 과 일치해야 하며,
    복수면 mtime 최신을 택한다.

    승인이 아닌 판정(반려·재작업 요청)은 여기서 걸러진다 — 이 단계가 묻는 것은
    "사용자가 이 plan 을 승인해 두었는가" 뿐이고, 반려 사유는 다음 planning
    run 이 sidecar 를 통째로 읽어 처리한다."""
    loaded = _load_final_report_data_if_present(plan_path)
    if loaded is not None:
        planning = loaded[1].get("implementationPlanning")
        if (
            isinstance(planning, dict)
            and planning.get("planningContract") == "selected-direction"
        ):
            return None
    responses_dir = plan_path.parent.parent / "user-responses"
    if not responses_dir.is_dir():
        return None
    m = re.search(r"-(\d+)\.(?:md|data\.json)$", plan_path.name)
    plan_seq = m.group(1) if m else ""
    best: Optional[tuple[float, Path, PlanDecisionRecord]] = None
    for f in sorted(responses_dir.glob("user-response-*.md")):
        try:
            text = f.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        rec = parse_plan_decision(text)
        if rec is None or not rec.approved or rec.seq != plan_seq:
            continue
        from okstra_ctl.final_report_paths import (
            final_report_markdown_path,
            is_report_record_path,
        )

        expected_name = (
            final_report_markdown_path(plan_path).name
            if is_report_record_path(plan_path)
            else plan_path.name
        )
        if Path(rec.source_report).name != expected_name:
            continue
        mtime = f.stat().st_mtime
        if best is None or mtime > best[0]:
            best = (mtime, f, rec)
    return (best[1], best[2]) if best else None


def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict) -> None:
    """sidecar 의 옵션 이름이 plan data.json 의 optionCandidates 에 있는지
    검증한다. 없으면 유효 후보를 나열하며 거부한다 (fail-closed)."""
    data_path = final_report_data_path(plan_path)
    candidates: list[str] = []
    if data_path.is_file():
        try:
            data = load_owned_object(data_path, artifact="planning final report")
            planning = data.get("implementationPlanning") or {}
            candidates = [
                c.get("name", "") for c in planning.get("optionCandidates") or []
                if isinstance(c, dict) and c.get("name")
            ]
        except (OSError, JsonBoundaryError):
            candidates = []
    if option_name not in candidates:
        raise WizardError(
            errors_t["unknown_option"].format(
                option=option_name,
                candidates=", ".join(candidates) if candidates else "(없음)",
            )
        )


def _plan_short_label(candidate: str) -> str:
    """plan 파일명에서 사용자용 짧은 식별자를 뽑는다.
    final-report-implementation-planning-002.data.json → implementation-planning-002"""
    if not candidate:
        return ""
    name = Path(candidate).name
    stem = name[: -len(".data.json")] if name.endswith(".data.json") else Path(name).stem
    return stem.removeprefix("final-report-")


def _stage_plan_for_confirmation(
    state: WizardState, path_str: str, *, suffix: str = ""
) -> Optional[str]:
    """Resolve + validate a selected plan, then stage it for the approve-confirm
    step. Selection NEVER finalizes the plan — the confirm step always runs and
    asks the user to proceed (approving the plan first if it is not yet approved).
    `_classify_approved_plan` still raises for failures approval cannot fix."""
    p, _ = _classify_approved_plan(path_str, Path(state.project_root))
    state.approved_plan_pending_text = False
    state.approved_plan_path = ""
    state.approve_plan_candidate = str(p)
    state.html_approval_sidecar = ""
    state.html_approval_option = ""
    found = _find_html_approval_sidecar(p)
    if found is not None:
        sidecar_path, record = found
        state.html_approval_sidecar = str(sidecar_path)
        state.html_approval_option = record.implementation_option
    t = _p(state.workspace_root, "approve_plan_confirm", path=str(p))
    variants = t["echo_variants"]
    key = ("selected_final_verification"
           if state.task_type == "final-verification"
           and variants.get("selected_final_verification")
           else "selected")
    msg = variants[key].format(path=p)
    return f"{msg} {suffix}".rstrip() if suffix else msg


def _fix_cycle_confirm_required(state: WizardState) -> bool:
    """완료(release-handoff) task 에 entry phase 로 재진입하고, 아직 열린 fix
    cycle 이 없을 때만 묻는다."""
    if state.task_type not in fix_cycles.FIX_CYCLE_ENTRY_PHASES:
        return False
    task_root = task_dir(Path(state.project_root),
                         state.task_group, state.task_id)
    workflow = (read_task_manifest(task_root) or {}).get("workflow") or {}
    if workflow.get("lastCompletedPhase") != "release-handoff":
        return False
    return fix_cycles.open_cycle(fix_cycles.read_rows(task_root)) is None


def _whole_task_allowed(
    state: WizardState,
    *,
    stages: Optional[list] = None,
    done: Optional[set] = None,
) -> bool:
    """final-verification 이고 Stage Map 의 모든 stage 가 done 일 때만 True.
    위저드는 done 만 본다 — 머지/clean/active 는 prepare 게이트가 강제한다.

    `stages`/`done` 를 넘기면 재파싱(validator exec_module)·재읽기를 생략한다 —
    이미 둘을 계산한 `_build_stage_pick` 의 hot path 중복을 없애기 위한 seam."""
    if state.task_type != "final-verification":
        return False
    if not state.approved_plan_path:
        return False
    if stages is None:
        stages = _parse_stage_objects(state)
    if not stages:
        return False
    if done is None:
        done = _stage_lifecycle_snapshot(state, stages).done_stages
    return all(s.stage_number in done for s in stages)


def _build_approved_plan_pick(state: WizardState) -> Prompt:
    reports = _list_implementation_planning_reports(state, limit=3)
    default = reports[0] if reports else None
    t = _p(state.workspace_root, "approved_plan_pick",
           default=str(default) if default is not None else "")
    is_fv = state.task_type == "final-verification"
    label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
    other_report_label = t["labels"]["other_report"]
    options: list[Option] = []
    if default is not None:
        options.append(_opt(PICK_USE_DEFAULT,
                            t["options"][PICK_USE_DEFAULT].format(default=str(default))))
    for p in reports[1:]:
        options.append(_opt(f"{_REPORT_PREFIX}{p}",
                            other_report_label.format(path=str(p))))
    options.append(_opt(PICK_OTHER, t["options"][PICK_OTHER]))
    return Prompt(
        step=S_APPROVED_PLAN_PICK, kind="pick",
        label=label, options=options,
        echo_template=t["echo_template"],
    )


def _submit_approved_plan_pick(state: WizardState, value: str) -> Optional[str]:
    t = _p(state.workspace_root, "approved_plan_pick", default="")
    if value == PICK_USE_DEFAULT:
        default = _latest_implementation_planning_report(state)
        if default is None:
            raise WizardError(t["errors"]["default_not_found"])
        return _stage_plan_for_confirmation(state, str(default))
    if value.startswith(_REPORT_PREFIX):
        rel = value[len(_REPORT_PREFIX):]
        return _stage_plan_for_confirmation(
            state, rel, suffix=t["echo_suffixes"]["other_report"])
    if value == PICK_OTHER:
        state.approved_plan_pending_text = True
        state.approved_plan_path = ""
        return None
    raise WizardError(
        f"unexpected approved-plan value: {value!r} "
        f"(expected {PICK_USE_DEFAULT!r}, {PICK_OTHER!r}, or '{_REPORT_PREFIX}<path>')"
    )


def _build_approved_plan(state: WizardState) -> Prompt:
    t = _p(state.workspace_root, "approved_plan")
    return Prompt(
        step=S_APPROVED_PLAN, kind="text",
        label=t["label"],
        echo_template=t["echo_template"],
    )


def _submit_approved_plan(state: WizardState, value: str) -> Optional[str]:
    return _stage_plan_for_confirmation(state, value)


def _build_approve_plan_confirm(state: WizardState) -> Prompt:
    t = _p(state.workspace_root, "approve_plan_confirm",
           path=state.approve_plan_candidate)
    is_fv = state.task_type == "final-verification"
    label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
    options_map = (t["options_final_verification"] or t["options"]) if is_fv else t["options"]
    if state.html_approval_sidecar:
        plan_label = _plan_short_label(state.approve_plan_candidate)
        label += t["html_approval_note"].format(
            plan_label=plan_label,
            option=state.html_approval_option
            or t["html_approval_note_default_option"],
        )
        options_map = {
            k: v.format(plan_label=plan_label)
            for k, v in t["options_html_approval"].items()
        }
    return Prompt(
        step=S_APPROVE_PLAN_CONFIRM, kind="pick",
        label=label,
        # `yes_apply` 는 사용자가 리포트에서 내보낸 승인 사이드카를 그대로
        # 적용하는 선택지다 — 그 사이드카가 있을 때만 나오고, 있으면 그것이
        # 이 run 의 추천이다.
        options=[
            _opt(k, v, recommended=(k == "yes_apply"))
            for k, v in options_map.items()
        ],
        echo_template=t["echo_template"],
    )


def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
    allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
    if value not in allowed:
        raise WizardError(f"expected one of {allowed}, got: {value!r}")
    candidate = state.approve_plan_candidate
    if not candidate:
        raise WizardError("approve-plan: no candidate plan to approve")
    t = _p(state.workspace_root, "approve_plan_confirm", path=candidate)
    if value == "no":
        # Declining leaves the candidate set so the confirm step re-prompts;
        # implementation cannot proceed without choosing to proceed.
        raise WizardError(t["errors"]["declined"])
    apply_option = value == "yes_apply" and bool(state.html_approval_option)
    p = Path(candidate)
    if apply_option:
        # 승인 플립 전에 옵션 유효성부터 검증한다 — 무효 옵션으로 plan 이
        # 절반만(승인만) 적용되는 상태를 만들지 않는다.
        _validate_sidecar_option(p, state.html_approval_option, t["errors"])
    resolved, fully_approved = _classify_approved_plan(
        str(p), Path(state.project_root))
    # flip / 옵션 적용은 PrepareError 를 던질 수 있는데, 마법사 디스패처는
    # WizardError 만 재프롬프트로 처리한다. raw PrepareError 가 새면 (승인이
    # 디스크에 반영된 채) state 저장 없이 traceback 으로 죽으므로 여기서 번역한다.
    try:
        if not fully_approved:
            # Not yet approved → flip data.json (SSOT) + re-render, then re-verify.
            _approve_plan_in_place(p)
            resolved, fully_approved = _classify_approved_plan(
                str(p), Path(state.project_root))
            if not fully_approved:
                raise WizardError(
                    t["errors"]["still_unapproved"].format(path=resolved))
        variants = t["echo_variants"]
        approved_key = ("approved_final_verification"
                        if state.task_type == "final-verification"
                        and variants.get("approved_final_verification")
                        else "approved")
        echo = variants[approved_key].format(path=resolved)
        if apply_option:
            _apply_cli_implementation_option(
                str(resolved), state.html_approval_option)
            echo = t["echo_variants"]["approved_with_option"].format(
                path=resolved, option=state.html_approval_option)
    except PrepareError as exc:
        raise WizardError(str(exc)) from exc
    state.approved_plan_path = str(resolved)
    state.approve_plan_candidate = ""
    state.html_approval_sidecar = ""
    state.html_approval_option = ""
    return echo


def _build_stage_pick(state: WizardState) -> Prompt:
    """Parse the Stage Map from the approved plan and build the stage picker."""
    t = _p(state.workspace_root, "stage_pick")
    stages = _parse_stage_objects(state)
    is_fv = state.task_type == "final-verification"
    is_impl = state.task_type == "implementation"
    label = (t["label_final_verification"] or t["label"]) if is_fv else t["label"]
    snapshot = _stage_lifecycle_snapshot(
        state, stages,
        reserved_stages=_reserved_stage_numbers(state) if is_impl else None,
    )
    done = snapshot.done_stages
    options = []
    if _whole_task_allowed(state, stages=stages, done=done):
        options.append(_opt(WHOLE_TASK_STAGE, t["options"]["whole_task"]))
    if is_impl:
        options.append(_opt(ALL_STAGES, t["options"]["all_stages"]))
    for s in stages:
        depends = ",".join(map(str, s.depends_on)) or "(none)"
        suffix = ""
        if is_fv:
            suffix = "  " + (t["options"]["done_mark"]
                             if s.stage_number in done
                             else t["options"]["undone_mark"])
        elif is_impl:
            suffix = "  " + _impl_stage_marker(
                t, snapshot.lifecycle_for(s.stage_number))
        options.append(_opt(
            str(s.stage_number),
            f"{s.stage_number}: {s.title}  "
            f"[depends-on: {depends} | steps: {s.step_count}]{suffix}",
        ))
    return Prompt(
        step=S_STAGE_PICK, kind="pick", multi=is_impl,
        label=label,
        options=options,
        echo_template=t["echo_template"],
    )


# Presentation keys are mapped explicitly rather than interpolated from the
# status, so renaming a Stage Lifecycle status is a compile-time-visible edit
# here instead of a KeyError raised while drawing the picker.
_STAGE_MARKER_KEYS = {
    "done": "mark_done",
    "active": "mark_active",
    "ready": "mark_ready",
    "blocked": "mark_blocked",
}


def _impl_stage_marker(t, lifecycle) -> str:
    return t["options"][_STAGE_MARKER_KEYS[lifecycle.status]]


def _submit_stage_pick(state: WizardState, answer: str) -> Optional[str]:
    if state.task_type == "implementation":
        return _submit_impl_stage_pick(state, answer)
    # final-verification: 단일선택 유지 (whole-task 또는 단일 정수; auto 불가)
    if not answer:
        raise WizardError("value required")
    if answer == WHOLE_TASK_STAGE:
        if not _whole_task_allowed(state):
            raise WizardError(
                "whole-task verification requires final-verification "
                "with all stages done")
    else:
        try:
            int(answer)
        except ValueError:
            raise WizardError(
                f"answer must be whole-task or a stage number, got {answer!r}")
    state.selected_stage = answer
    return f"stage: {answer}"


def _submit_impl_stage_pick(state: WizardState, answer: str) -> Optional[str]:
    from ..stage_targets import order_stage_closure
    t = _p(state.workspace_root, "stage_pick")
    picks = [v.strip() for v in (answer or "").split(",") if v.strip()]
    if not picks:
        raise WizardError(t["errors"]["none_selected"])
    if WHOLE_TASK_STAGE in picks:
        raise WizardError(t["errors"]["whole_task_impl"])
    stages = _parse_stage_objects(state)
    all_nums = {s.stage_number for s in stages}
    snapshot = _stage_lifecycle_snapshot(
        state, stages, reserved_stages=_reserved_stage_numbers(state))
    done = snapshot.done_stages
    occupied = {lc.stage for lc in snapshot.lifecycles
                if lc.status in ("done", "active")}
    chosen = _impl_chosen_stages(t, picks, answer, all_nums, occupied)
    ordered = order_stage_closure(
        [(s.stage_number, s.depends_on) for s in stages], chosen, done)
    state.selected_stages = ",".join(map(str, ordered))
    state.selected_stage = str(ordered[0]) if ordered else "auto"
    added = [n for n in ordered if n not in chosen]
    if added:
        return t["echo_variants"]["with_closure"].format(
            stages=state.selected_stages, added=", ".join(map(str, added)))
    return t["echo_variants"]["plain"].format(stages=state.selected_stages)


def _impl_chosen_stages(t, picks, answer, all_nums, occupied) -> set:
    if ALL_STAGES in picks:
        if len(picks) > 1:
            raise WizardError(t["errors"]["all_exclusive"])
        chosen = {n for n in all_nums if n not in occupied}
        if not chosen:
            raise WizardError(t["errors"]["nothing_selectable"])
        return chosen
    try:
        nums = {int(p) for p in picks}
    except ValueError:
        raise WizardError(t["errors"]["bad_number"].format(answer=answer))
    unknown = sorted(nums - all_nums)
    if unknown:
        raise WizardError(t["errors"]["unknown_stage"].format(
            bad=", ".join(map(str, unknown))))
    bad = sorted(nums & occupied)
    if bad:
        raise WizardError(t["errors"]["occupied"].format(
            bad=", ".join(map(str, bad))))
    return nums


def _handoff_msgs(state: WizardState) -> dict:
    """handoff_stage_pick 의 JSON 텍스트 묶음 (label 미사용 조회용)."""
    return _p(state.workspace_root, "handoff_stage_pick", blocked="")


def _resolve_handoff_plan(state: WizardState) -> Path:
    """release-handoff 의 approved plan 을 질문 없이 자동 해소한다.

    plan 미존재/미승인은 사용자가 고칠 대상이 아니라 라이프사이클 선행 단계
    누락이므로 picker 대신 안내 메시지로 즉시 실패한다."""
    if state.approved_plan_path:
        return Path(state.approved_plan_path)
    t = _handoff_msgs(state)
    latest = _latest_implementation_planning_report(state)
    if latest is None:
        raise WizardError(t["errors"]["no_plan"])
    p, fully_approved = _classify_approved_plan(
        str(latest), Path(state.project_root))
    if not fully_approved:
        raise WizardError(t["errors"]["plan_not_approved"].format(plan=p))
    state.approved_plan_path = str(p)
    return p


def _handoff_eligibility(state: WizardState) -> list:
    """stage 별 PR 자격 — okstra_ctl.handoff 의 SSOT 판정을 그대로 재사용한다."""
    from okstra_ctl.handoff import compute_eligibility
    from okstra_ctl.consumers import read_consumers
    plan = _resolve_handoff_plan(state)
    try:
        stage_map = stage_map_records(parse_stage_map_file(plan))
    except StageMapError as exc:
        raise WizardError(str(exc)) from exc
    rows = read_consumers(plan.resolve().parents[1])
    return compute_eligibility(stage_map, rows)


def _latest_whole_task_fv_release_ready(state: WizardState) -> str:
    """accepted whole-task final-verification 보고서 경로 — handoff 모듈 SSOT 위임."""
    from okstra_ctl.handoff import latest_whole_task_fv_release_ready
    return latest_whole_task_fv_release_ready(
        state.project_root, state.project_id, state.task_group, state.task_id)


def _build_handoff_stage_pick(state: WizardState) -> Prompt:
    elig = _handoff_eligibility(state)
    eligible = [e for e in elig if e["eligible"]]
    blocked = [e for e in elig if not e["eligible"]]
    whole_task_report = _latest_whole_task_fv_release_ready(state)
    msgs = _handoff_msgs(state)
    blocked_summary = ("; ".join(
        f"stage {e['stage']} ({', '.join(e['reasons'])})" for e in blocked)
        or msgs["labels"]["blocked_none"])
    if not eligible and not whole_task_report:
        raise WizardError(
            msgs["errors"]["nothing_eligible"].format(blocked=blocked_summary))
    t = _p(state.workspace_root, "handoff_stage_pick", blocked=blocked_summary)
    options: list[Option] = []
    if whole_task_report:
        options.append(_opt(WHOLE_TASK_STAGE, t["labels"]["whole_task"]))
    stage_label = t["labels"]["stage"]
    for e in eligible:
        deps = ", ".join(str(d) for d in e["depends_on"]) or "-"
        options.append(_opt(str(e["stage"]),
                            stage_label.format(stage=e["stage"], deps=deps)))
    return Prompt(
        step=S_HANDOFF_STAGE_PICK, kind="pick", multi=True,
        label=t["label"], options=options,
        echo_template=t["echo_template"],
    )


def _submit_handoff_stage_pick(state: WizardState, value: str) -> Optional[str]:
    t = _handoff_msgs(state)
    picks = [v.strip() for v in (value or "").split(",") if v.strip()]
    if not picks:
        raise WizardError(t["errors"]["none_selected"])
    if WHOLE_TASK_STAGE in picks:
        if len(picks) > 1:
            raise WizardError(t["errors"]["whole_task_exclusive"])
        if not _latest_whole_task_fv_release_ready(state):
            raise WizardError(t["errors"]["whole_task_missing"])
        state.handoff_mode = "whole-task"
        state.handoff_stages = ""
        return t["echo_variants"]["whole_task"]
    eligible = {str(e["stage"]) for e in _handoff_eligibility(state)
                if e["eligible"]}
    bad = [p for p in picks if p not in eligible]
    if bad:
        raise WizardError(t["errors"]["not_eligible"].format(
            bad=", ".join(bad), eligible=", ".join(sorted(eligible))))
    nums = sorted({int(p) for p in picks})
    state.handoff_mode = "stage-group"
    state.handoff_stages = ",".join(str(n) for n in nums)
    return t["echo_variants"]["stage_group"].format(
        stages=state.handoff_stages)


def _reverify_scope_step_pending(state: WizardState) -> bool:
    """범위 질문이 아직 안 끝났는가 — confirm 진입을 막는 게이트."""
    if state.reverify_scope_pending_text:
        return True
    return _reverify_scope_pick_required(state) and not state.reverify_scope


_SCOPE_SHAPE_LABEL_KEY = {
    SCOPE_SHAPE_UNLINKED: "label_unlinked",
    SCOPE_SHAPE_NO_ANSWERS: "label_no_answers",
}


def _build_reverify_scope_pick(state: WizardState) -> Prompt:
    t = _p(state.workspace_root, "reverify_scope_pick")
    opts = t["options"]
    shape = _reverify_scope_shape(state)
    if shape == SCOPE_SHAPE_LINKED:
        options = [
            _opt("auto", opts["auto"], recommended=True),
            _opt("full", opts["full"]),
            _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
        ]
    # 아래 두 모양에서 `auto` 를 빼는 이유는 같다 — 되짚기가 stage 를 못 냈으므로
    # 그 핀은 CLI 에 빈 `--impacted` 를 보내고 full(또는 `unresolved`)로만
    # 돌아온다. 고를 수 있는 것처럼 두면 추천이 아니라 함정이 된다.
    #
    # `carry-all` 이 그 자리를 메운다. stage 를 **추가만** 하는 재실행에서는
    # 지정할 번호가 없어 직접 입력도 쓸 수 없고, 남는 선택이 `full` 뿐이면
    # 이월해도 되는 stage 를 전부 다시 교차검증하는 비용을 치른다.
    elif shape == SCOPE_SHAPE_UNLINKED:
        # 되짚기가 stage 를 못 냈으므로 좁힐 근거가 없다 — 이 모양에서 안전한
        # 기본값은 full 이고, 그것이 추천이다. 직접 입력은 마지막이다: 목록이
        # 직접 입력으로 열리면 질문을 통째로 되돌려준 것이고, 실측에서 그
        # 화면은 리드가 preamble 에 stage 번호를 이미 적어 놓고도 자유 입력만
        # 내놓았다.
        options = [
            _opt("full", opts["full_recommended"], recommended=True),
            _opt(CARRY_ALL_SCOPE, opts[CARRY_ALL_SCOPE]),
            _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
        ]
    else:
        # 답변된 clarification 이 0건인 재실행. 사용자가 목적을 갖고 stage 를
        # 지정하러 온 자리라 full 을 추천으로 올리면 비싼 쪽으로 몰게 된다 —
        # 추천 없이 두고, 근거가 없다는 사실을 라벨이 말한다.
        options = [
            _opt(CARRY_ALL_SCOPE, opts[CARRY_ALL_SCOPE]),
            _opt("full", opts["full"]),
            _opt(PICK_TYPE_CUSTOM, opts[PICK_TYPE_CUSTOM]),
        ]
    label = t[_SCOPE_SHAPE_LABEL_KEY.get(shape, "label")] + _scope_pick_context(state, shape, t)
    return Prompt(
        step=S_REVERIFY_SCOPE_PICK, kind="pick", label=label,
        options=options,
        echo_template=t["echo_template"])


def _scope_pick_context(state: WizardState, shape: str, t: dict) -> str:
    """질문이 왜 나왔는지 화면에 싣는 한 문장.

    `unlinked` 는 어느 id 가 연결되지 않았는지가 답을 고르는 근거인데, 종전에는
    그 목록이 `errors.unlinked_auto`(자동을 고른 뒤에야 보이는 문구)에만 있었다.
    id 없이 "연결되지 않은 것이 있습니다" 만 받은 사용자는 자기 답변 중 무엇을
    말하는지 모른 채 stage 번호를 대야 했다.

    `no-answers` 는 추천할 근거가 없다는 사실 자체를 말한다 — 근거 없이 한
    선택지를 추천으로 꾸미지 않는다.
    """
    labels = t.get("labels") or {}
    if shape == SCOPE_SHAPE_UNLINKED:
        ids = (_reverify_scope_preview(state) or {}).get("unlinkedIds") or []
        note = labels.get("unlinked_ids", "")
        return note.format(ids=", ".join(str(i) for i in ids)) if note and ids else ""
    if shape == SCOPE_SHAPE_NO_ANSWERS:
        return labels.get("no_basis", "")
    return ""


def _submit_reverify_scope_pick(state: WizardState, value: str) -> Optional[str]:
    t = _p(state.workspace_root, "reverify_scope_pick")
    picked = value.strip().lower()
    if picked == PICK_TYPE_CUSTOM:
        state.reverify_scope = ""
        state.reverify_scope_pending_text = True
        return "reverify-scope: 직접 입력"
    if picked not in ("auto", "full", CARRY_ALL_SCOPE):
        raise WizardError(
            f"expected 'auto' / 'full' / {CARRY_ALL_SCOPE!r} / "
            f"{PICK_TYPE_CUSTOM!r}, got: {value!r}"
        )
    if picked == "auto":
        _refuse_auto_without_a_back_trace(state, t)
    state.reverify_scope = picked
    state.reverify_scope_pending_text = False
    return t["echo_suffixes"][picked]


def _refuse_auto_without_a_back_trace(
    state: WizardState,
    t: dict,
    *,
    auto_key: str = "unlinked_auto",
    no_answers_key: str = "no_answers_auto",
) -> None:
    """되짚기가 stage 를 못 낸 재실행에서 `auto` 를 거절한다.

    두 모양의 사유가 다르므로 문구도 다르다 — 연결 안 된 id 는 그 id 를 이름으로
    말해 줘야 사용자가 무엇의 stage 를 지정할지 알고, 답변이 아예 없는 경우에는
    말할 id 자체가 없다. 두 진입점(picker 의 `auto`, stage 입력의 빈 줄)이 같은
    판정을 쓰도록 한곳에 둔다.
    """
    shape = _reverify_scope_shape(state)
    if shape == SCOPE_SHAPE_UNLINKED:
        unlinked = (_reverify_scope_preview(state) or {}).get("unlinkedIds") or []
        raise WizardError(t["errors"][auto_key].format(ids=", ".join(unlinked)))
    if shape == SCOPE_SHAPE_NO_ANSWERS:
        raise WizardError(t["errors"][no_answers_key])


def _build_reverify_scope_stages(state: WizardState) -> Prompt:
    t = _p(state.workspace_root, "reverify_scope_stages")
    return Prompt(
        step=S_REVERIFY_SCOPE_STAGES, kind="text", label=t["label"],
        echo_template=t["echo_template"])


def _submit_reverify_scope_stages(state: WizardState, value: str) -> Optional[str]:
    t = _p(state.workspace_root, "reverify_scope_stages")
    tokens = [token.strip() for token in value.split(",") if token.strip()]
    if not tokens:
        # 빈 줄은 `auto` 로 떨어지므로 picker 의 `auto` 와 같은 배제가 걸린다 —
        # 여기만 열어 두면 picker 에서 막은 핀이 뒷문으로 들어온다.
        _refuse_auto_without_a_back_trace(
            state, t, auto_key="unlinked_empty", no_answers_key="no_answers_empty"
        )
        state.reverify_scope = "auto"
        state.reverify_scope_pending_text = False
        return t["echo_suffixes"]["auto"]
    for token in tokens:
        if not token.isdigit():
            raise WizardError(t["errors"]["not_a_number"].format(token=token))
    known = _prior_stage_numbers(state)
    picked = sorted({int(token) for token in tokens})
    unknown = [num for num in picked if num not in known]
    if unknown:
        raise WizardError(t["errors"]["unknown_stage"].format(
            stages=", ".join(str(num) for num in unknown),
            known=", ".join(str(num) for num in sorted(known)),
        ))
    state.reverify_scope = ",".join(str(num) for num in picked)
    state.reverify_scope_pending_text = False
    return t["echo_template"].format(value=state.reverify_scope)


def _build_fix_cycle_confirm(state: WizardState) -> Prompt:
    t = _p(state.workspace_root, "fix_cycle_confirm")
    opts = t["options"]
    return Prompt(
        step=S_FIX_CYCLE_CONFIRM, kind="pick", label=t["label"],
        options=[
            _opt("yes", opts["yes"]),
            _opt("no", opts["no"]),
            _opt("abort", opts["abort"]),
        ],
        echo_template=t["echo_template"])


def _submit_fix_cycle_confirm(state: WizardState, value: str) -> Optional[str]:
    v = value.strip().lower()
    if v == "abort":
        state.aborted = True
        return "fix-cycle: abort"
    if v not in ("yes", "no"):
        raise WizardError(f"expected 'yes' / 'no' / 'abort', got: {value!r}")
    state.fix_cycle = v
    return f"fix-cycle: {v}"
