"""위저드 추천값의 출처 — `.okstra` 이력·리포트·프로필·git worktree 를 읽는 헬퍼."""
from __future__ import annotations

import subprocess
from datetime import datetime
from pathlib import Path
from collections.abc import Mapping
from typing import Any, Optional

from okstra_ctl.ids import slugify_task_segment
from okstra_ctl.brief_frontmatter import read_brief_frontmatter
from okstra_ctl.analysis_inputs import (
    ANALYSIS_TASK_TYPES,
    AnalysisInputError,
    _resolved_within,
    load_analysis_report_candidate,
)
from okstra_ctl.clarification_items import sidecar_answers
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
from okstra_ctl.incremental_scope import (
    parse_stage_graph,
    preview_link_availability_for_report,
)
from okstra_ctl.final_report_paths import final_report_data_path
from okstra_ctl.plan_run_root import list_implementation_planning_reports
from okstra_ctl.stage_map import StageMapError, parse_stage_map_file
from okstra_ctl.workers import (
    normalize_workers,
    resolve_optional_workers,
    resolve_profile_workers,
)
from okstra_ctl.workflow import PHASE_SEQUENCE
from okstra_ctl import worktree_registry
from okstra_ctl.worktree import is_git_work_tree, main_worktree_path
from okstra_ctl.paths import RunRef, task_runs_dir
from okstra_ctl.run_context import latest_run_inputs
from okstra_project.state import (
    StateError,
    list_project_tasks,
    read_task_manifest,
    find_task_root,
)

from .ids import (
    SCOPE_SHAPE_LINKED,
    SCOPE_SHAPE_NO_ANSWERS,
    SCOPE_SHAPE_UNLINKED,
    TASK_TYPE_VALUES,
    _BRIEF_HEAD_LINES,
    _BRIEF_NEXT_PHASE_RE,
)
from .state import WizardError, WizardState, _profile_path, _slug_or_die
from .prompts import _p


def _looks_like_template_placeholder(value: str) -> bool:
    """Treat ``<task-group>``, ``<...>``, empty strings, and ``self`` as
    non-suggestions. Anything else (a real slug-like value) is honored."""
    v = (value or "").strip()
    if not v:
        return True
    if v.startswith("<") and v.endswith(">"):
        return True
    if v.lower() in ("self", "tbd", "n/a", "na", "none"):
        return True
    return False


def _brief_recommended_phase(path: Path) -> str:
    """brief 가 스스로 지목한 진입 phase. 없거나 미지의 값이면 ''.

    템플릿 자리표시자 ``<requirements-discovery | error-analysis | ...>`` 는
    정규식이 잡지 않으므로 채워지지 않은 brief 는 자연히 '' 가 된다.
    """
    try:
        with path.open(encoding="utf-8") as fh:
            head = [line for _, line in zip(range(_BRIEF_HEAD_LINES), fh)]
    except OSError:
        return ""
    for line in head:
        match = _BRIEF_NEXT_PHASE_RE.match(line)
        if match and match.group(1) in TASK_TYPE_VALUES:
            return match.group(1)
    return ""


def _brief_suggestions(path: Path) -> tuple[str, str]:
    """Return ``(task_group_suggestion, task_id_suggestion)`` extracted from
    the brief's frontmatter, or empty strings when no usable value exists.

    - ``task_group`` ← frontmatter ``task-group``.
    - ``task_id``    ← frontmatter ``brief-id`` (which matches the
                       filename stem in okstra-brief-gen output and is the
                       strongest single identifier of the task).

    A brief without frontmatter, or with placeholder values, yields two
    empty strings — callers fall back to plain-text input.
    """
    fm = read_brief_frontmatter(path)
    tg_raw = fm.get("task-group", "")
    bid_raw = fm.get("brief-id", "")
    tg = "" if _looks_like_template_placeholder(tg_raw) else tg_raw
    tid = "" if _looks_like_template_placeholder(bid_raw) else bid_raw
    return tg, tid


def _project_relative_path(path: Path, project_root: Path) -> str:
    try:
        return str(path.relative_to(project_root))
    except ValueError:
        return str(path)


def _project_relative_value(path_value: str, project_root: Path) -> str:
    p = Path(path_value)
    if p.is_absolute():
        return _project_relative_path(p, project_root)
    return str(p)


def _parse_iso_timestamp(value: Any) -> float:
    if not isinstance(value, str):
        return 0.0
    text = value.strip()
    if not text:
        return 0.0
    if text.endswith("Z"):
        text = f"{text[:-1]}+00:00"
    try:
        return datetime.fromisoformat(text).timestamp()
    except ValueError:
        return 0.0


def _file_recency(path: Path) -> float:
    try:
        stat = path.stat()
    except OSError:
        return 0.0
    return max(stat.st_mtime, float(getattr(stat, "st_birthtime", 0.0) or 0.0))


def _accept_brief_path(state: WizardState, path: Path) -> None:
    """Record a validated brief and derive safe identity suggestions.

    New-task runs now ask for ``task_group`` before the brief so the wizard
    can offer same-group brief candidates. If the selected brief carries a
    conflicting frontmatter ``task-group``, fail fast; otherwise keep using
    the brief's ``brief-id`` as the task-id suggestion.
    """
    tg_suggestion, tid_suggestion = _brief_suggestions(path)
    if state.task_group and tg_suggestion:
        suggested_group = _slug_or_die(tg_suggestion, "task_group")
        if suggested_group != state.task_group:
            raise WizardError(
                "brief task-group does not match selected task-group: "
                f"{tg_suggestion!r} != {state.task_group!r} ({path})"
            )
    state.brief_path = str(path)
    state.brief_path_pending_text = False
    if state.is_new_task:
        if not state.task_group:
            state.task_group_suggestion = tg_suggestion
        if not state.task_id:
            state.task_id_suggestion = tid_suggestion


def _resolve_path(path_str: str, project_root: Path) -> Path:
    p = Path(path_str).expanduser()
    return p if p.is_absolute() else (project_root / p).resolve()


def _require_file(path_str: str, project_root: Path, label: str) -> Path:
    if not (path_str or "").strip():
        raise WizardError(f"{label}: empty path")
    p = _resolve_path(path_str, project_root)
    if not p.is_file():
        raise WizardError(f"{label}: file not found: {p}")
    return p


def _git_main_worktree(project_root: Path) -> Path:
    # main worktree 해소는 worktree 모듈의 public seam(main_worktree_path)에
    # 위임한다. base-ref 검증은 git 이 없으면 의미가 없으므로, 자체 메시지로
    # 먼저 fail-fast 한다 (과거 자체 `--git-common-dir` 구현을 대체).
    if not is_git_work_tree(project_root):
        raise WizardError(f"git unavailable or not a work tree in {project_root}")
    return main_worktree_path(project_root)


def _load_profile_workers(workspace_root: Path, task_type: str) -> list[str]:
    return resolve_profile_workers(_profile_path(workspace_root, task_type))


def _load_profile_optional_workers(
    workspace_root: Path, task_type: str
) -> list[str]:
    return resolve_optional_workers(_profile_path(workspace_root, task_type))


def _resolved_roster(state: WizardState) -> list[str]:
    """Effective worker list AFTER override. Implementation: profile default
    (caller never asks for override). Others: override or profile default."""
    if state.task_type == "implementation":
        roster = list(state.profile_workers)
        # implementation 은 roster override 단계를 건너뛰므로, executor 로 명시
        # 선택한 워커(예: antigravity)가 프로필 기본 roster 에 없으면 여기서
        # 합류시켜야 render-bundle 의 executor∈roster 가드를 통과한다.
        if state.executor and state.executor not in roster:
            roster.append(state.executor)
        return roster
    if state.workers_override.strip():
        return normalize_workers(state.workers_override)
    return list(state.profile_workers)


# ---- Worktree resolution ------------------------------------------------

def _resolve_reuse_worktree(state: WizardState) -> bool:
    """For a finalized task identity, is there an active worktree to reuse?
    New tasks always answer False (no entry possible)."""
    if state.is_new_task:
        return False
    if not (state.project_id and state.task_group and state.task_id):
        return False
    entry = worktree_registry.lookup(state.project_id,
                                     state.task_group, state.task_id)
    return bool(entry and entry.status == "active")


def _brief_resolved(state: WizardState) -> bool:
    """brief 입력이 끝났는가. release-handoff 는 brief 가 없는 phase 라 항상 True."""
    return bool(state.brief_path) or state.task_type == "release-handoff"


def _parse_stage_objects(state: WizardState) -> list:
    """Return the approved plan's strict Stage Map objects for the picker."""
    try:
        return parse_stage_map_file(Path(state.approved_plan_path))
    except StageMapError as exc:
        raise WizardError(
            f"approved plan 의 Stage Map 을 신뢰할 수 없습니다 "
            f"({state.approved_plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
        ) from exc


def _stage_lifecycle_snapshot(
    state: WizardState, stages: list, *, reserved_stages: Optional[set] = None,
):
    """picker 가 보는 Stage Lifecycle Snapshot. 순수 읽기 — carry backfill 금지.

    backfill_done_from_carry 는 consumers.jsonl 에 done 행을 append 하고
    그 부수효과로 worktree-registry stage 점유를 해제한다. 이 스냅샷은 stage
    picker 표시와 progress 추정(_remaining_screens 가 매 step 반복 호출)에서만
    쓰이므로, 화면을 그리는 것만으로 동시 실행 중인 run 의 점유를 풀면 안 된다.
    carry 기반 done 보정은 prepare 게이트(run.py)가 단일 진입점으로 수행한다.

    One snapshot per call site: the two ledger reads this replaced ran once per
    helper, and `_build_stage_pick` sits inside `_remaining_screens`' simulation.
    """
    from ..stage_targets import read_stage_lifecycle_snapshot
    return read_stage_lifecycle_snapshot(
        [{"stage_number": s.stage_number,
          "depends_on": list(s.depends_on),
          "step_count": s.step_count} for s in stages],
        Path(state.approved_plan_path).resolve().parents[1],
        recover_from_carry=False,
        reserved_stages=reserved_stages,
    )


def _reserved_stage_numbers(state: WizardState) -> set:
    """worktree-registry 가 active 로 잡고 있는 stage 번호 집합(점유 SSOT).
    implementation prepare 와 동일 인자로 list_active_stage_numbers 를 호출한다."""
    if not (state.project_id and state.task_group and state.task_id):
        return set()
    from ..worktree_registry import list_active_stage_numbers
    return list_active_stage_numbers(
        state.project_id, state.task_group, state.task_id)


def _existing_task_brief(project_root: Path, task_key: str) -> str:
    """Read taskBriefPath from manifest for an existing task. Empty if none."""
    root = find_task_root(project_root, task_key)
    if root is None:
        return ""
    manifest = read_task_manifest(root) or {}
    val = manifest.get("taskBriefPath") or ""
    return val if isinstance(val, str) else ""


def _recently_used_brief_times(state: WizardState) -> dict[str, float]:
    if not state.project_root or not state.task_group:
        return {}
    project_root = Path(state.project_root)
    try:
        tasks = list_project_tasks(project_root)
    except (OSError, StateError):
        return {}
    out: dict[str, float] = {}
    for entry in tasks:
        if entry.get("taskGroup") != state.task_group:
            continue
        task_root = entry.get("_resolvedTaskRoot") or ""
        manifest = read_task_manifest(Path(task_root)) if task_root else None
        brief_path = ""
        if isinstance(manifest, dict):
            value = manifest.get("taskBriefPath")
            brief_path = value if isinstance(value, str) else ""
        if not brief_path:
            value = entry.get("taskBriefPath")
            brief_path = value if isinstance(value, str) else ""
        if not brief_path:
            continue
        relpath = _project_relative_value(brief_path, project_root)
        out[relpath] = max(
            out.get(relpath, 0.0),
            _parse_iso_timestamp(entry.get("updatedAt")),
        )
    return out


def _existing_task_manifest(state: WizardState) -> dict[str, Any]:
    """현재 task-key 의 task-manifest. 없으면 {}."""
    if not (state.project_id and state.task_group and state.task_id):
        return {}
    key = f"{state.project_id}:{state.task_group}:{state.task_id}"
    root = find_task_root(Path(state.project_root), key)
    if root is None:
        return {}
    manifest = read_task_manifest(root) or {}
    return manifest if isinstance(manifest, dict) else {}


def _existing_task_workflow(state: WizardState) -> dict:
    """현재 task-key 가 이미 존재하면 그 manifest 의 workflow dict 를 반환한다.

    picker 로 기존 task 를 고른 경우뿐 아니라, new-task 흐름으로 같은
    task-group/task-id 를 다시 입력한 경우(=사실상 이어가기)에도 직전 phase
    기반 추천이 끊기지 않게 하는 안전장치. 없으면 {}."""
    workflow = _existing_task_manifest(state).get("workflow") or {}
    return workflow if isinstance(workflow, dict) else {}


def _report_data_for_pointer(
    state: WizardState, manifest: Mapping[str, Any],
) -> dict[str, Any]:
    rel = manifest.get("latestReportRecordPath")
    if not isinstance(rel, str) or not rel.strip():
        return {}
    path = Path(rel)
    if not path.is_absolute():
        path = Path(state.project_root) / rel
    try:
        data = load_owned_object(path, artifact="latest report record")
    except (JsonBoundaryError, OSError):
        return {}
    return data if isinstance(data, dict) else {}


def _phase_after(task_type: str) -> str:
    """라이프사이클(PHASE_SEQUENCE) 상 task_type 바로 다음 단계. 없으면 ''."""
    try:
        idx = PHASE_SEQUENCE.index(task_type)
    except ValueError:
        return ""
    return PHASE_SEQUENCE[idx + 1] if idx + 1 < len(PHASE_SEQUENCE) else ""


def _recent_task_types(state: WizardState) -> list[str]:
    """catalog 최신순으로 이 프로젝트에서 최근 사용된 task-type 목록(중복 제거)."""
    if not state.project_root:
        return []
    try:
        tasks = list_project_tasks(Path(state.project_root))
    except (OSError, StateError):
        return []
    out: list[str] = []
    for entry in tasks:
        tt = entry.get("taskType") or ""
        if tt and tt not in out:
            out.append(tt)
    return out


def _contained_final_report_data_path(report: Path, task_root: Path) -> Path:
    _resolved_within(report, task_root, "final report path")
    return _resolved_within(
        final_report_data_path(report),
        task_root,
        "final report data path",
    )


def _newest_contained_final_report(
    runs_base: Path,
    task_root: Path,
    glob_pattern: str,
    project_root: Path,
) -> Path | None:
    candidates: list[Path] = []
    for report in runs_base.glob(glob_pattern):
        if not report.is_file():
            continue
        try:
            _contained_final_report_data_path(report, task_root)
            if report.parent.parent.name in ANALYSIS_TASK_TYPES:
                load_analysis_report_candidate(project_root, report)
        except AnalysisInputError:
            continue
        candidates.append(report)

    def mtime_safe(report: Path) -> float:
        try:
            return report.stat().st_mtime
        except OSError:
            return -1.0

    return max(candidates, key=mtime_safe) if candidates else None


def _analysis_revision_candidate(
    state: WizardState,
    report: Path,
    task_root: Path,
    expected_task_key: str,
) -> tuple[int, Path] | None:
    try:
        _contained_final_report_data_path(report, task_root)
        candidate = load_analysis_report_candidate(
            Path(state.project_root), report
        )
    except AnalysisInputError:
        return None
    if (
        candidate.task_key != expected_task_key
        or candidate.task_type != state.task_type
        or candidate.review_status != "revision-requested"
    ):
        return None
    return int(candidate.run_seq), report


def _latest_revision_requested_analysis_report(
    state: WizardState,
) -> Path | None:
    if state.task_type not in ANALYSIS_TASK_TYPES:
        return None
    reports = task_runs_dir(
        Path(state.project_root), state.task_group, state.task_id
    ) / state.task_type / "reports"
    try:
        task_root = _resolved_within(
            reports.parents[2],
            Path(state.project_root).resolve(),
            "analysis task root",
        )
    except AnalysisInputError:
        return None
    expected_task_key = f"{state.project_id}:{state.task_group}:{state.task_id}"
    candidates: list[tuple[int, Path]] = []
    for report in reports.glob("final-report-*.data.json"):
        candidate = _analysis_revision_candidate(
            state,
            report,
            task_root,
            expected_task_key,
        )
        if candidate is not None:
            candidates.append(candidate)
    if not candidates:
        return None
    return max(candidates, key=lambda candidate: candidate[0])[1]


def _latest_revision_requested_analysis_type(state: WizardState) -> str:
    report = _latest_revision_requested_analysis_report(state)
    return state.task_type if report is not None else ""


def _analysis_current_commit(state: WizardState) -> str:
    try:
        return subprocess.check_output(
            ["git", "-C", state.project_root, "rev-parse", "HEAD"],
            text=True,
            stderr=subprocess.DEVNULL,
        ).strip()
    except (OSError, subprocess.CalledProcessError) as exc:
        raise WizardError(
            f"cannot resolve current project commit for analysis evidence: {state.project_root}"
        ) from exc


def _list_implementation_planning_reports(
    state: WizardState, limit: int = 3
) -> list[Path]:
    """task 의 implementation-planning runs 디렉토리에서 최신순으로 final-report 경로를 limit 개까지 반환.

    Each path is relative to ``project_root`` when possible.
    """
    if not state.task_group or not state.task_id or not state.project_root:
        return []
    # Run seq lives in the filename, not a per-run subdirectory: every
    # implementation-planning run writes into the same flat `reports/`
    # dir (see paths.py — `run_reports = runs/<task-type>/reports`).
    reports_dir = RunRef(
        project_root=state.project_root, task_group=state.task_group,
        task_id=state.task_id, task_type="implementation-planning",
    ).reports_dir
    if not reports_dir.is_dir():
        return []
    out: list[Path] = []
    for p in list_implementation_planning_reports(reports_dir)[:limit]:
        try:
            out.append(p.relative_to(Path(state.project_root)))
        except ValueError:
            out.append(p)
    return out


def _latest_implementation_planning_report(state: WizardState) -> Optional[Path]:
    """task 의 implementation-planning runs 중 가장 최신 final-report 경로 (relpath where possible)."""
    reports = _list_implementation_planning_reports(state, limit=1)
    return reports[0] if reports else None


def _same_file(a: Path, b_str: str) -> bool:
    """Whether two paths resolve to the same file. False when either path
    cannot be resolved (missing intermediate dir, permission, etc.)."""
    try:
        return a.resolve() == Path(b_str).resolve()
    except OSError:
        return False



def _technical_evidence_for_comparison(
    state: WizardState, source: Path | None, task_root: Path,
) -> Path | None:
    """현재 비교 보고서를 검증한 결과만 다음 재비교의 입력으로 추천한다."""
    if state.task_type != "implementation-option-selection" or source is None:
        return source
    project_root = Path(state.project_root)
    report = _newest_contained_final_report(
        task_root / "runs", task_root,
        "technical-verification/reports/final-report-*.data.json", project_root,
    )
    if report is None:
        return source
    try:
        data = load_owned_object(report, artifact="technical verification report")
    except (OSError, ValueError):
        return source
    block = data.get("technicalVerification") or {}
    if block.get("sourceReport") != str(source.relative_to(project_root)):
        return source
    return report


def _suggest_latest_final_report(state: WizardState) -> str:
    """clarification carry-in 으로 추천할 직전 final-report 의 relpath.

    resume-clarification 은 "같은 phase 를 답변과 함께 재실행" 이므로 재실행
    task-type **자신의** 보고서(``runs/<task-type>/reports/final-report-*.md``)를
    우선한다. 그 phase 에 보고서가 없을 때만(예: 다음 phase 로 진행) 전체 phase 의
    mtime 최신으로 폴백한다 — 과거엔 무조건 전체 mtime 최신이라 더 최근에 재렌더된
    다른 phase 보고서를 잘못 집을 수 있었다. 못 찾으면 빈 문자열.
    """
    if not state.task_group or not state.task_id or not state.project_root:
        return ""
    runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
    if not runs_base.is_dir():
        return ""
    try:
        task_root = _resolved_within(
            runs_base.parent,
            Path(state.project_root).resolve(),
            "task root",
        )
    except AnalysisInputError:
        return ""

    # A revision request outranks ordinary recency. Carry the newest requested
    # revision for the selected analysis type into that exact rerun.
    revision = _latest_revision_requested_analysis_report(state)
    best = revision
    if best is None and state.task_type:
        source_type = "implementation-option-selection" if state.task_type == "technical-verification" else state.task_type
        seg = slugify_task_segment(source_type)
        best = _newest_contained_final_report(
            runs_base,
            task_root,
            f"{seg}/reports/final-report-*.data.json",
            Path(state.project_root),
        )
    # implementation-planning 의 `--clarification-response` 는 같은 phase 의
    # 계획서만 받는다(`run._validate_planning_entry_inputs`). 그 phase 에 아직
    # 리포트가 없으면 이 런은 새 계획이고, 답은 `--selected-direction` 으로
    # 들어간다 — 전체 phase 폴백은 여기서 직전 후보비교 리포트를 추천했고,
    # 그것을 고른 사용자는 두 상호 배타 입력을 동시에 갖게 됐다.
    if best is None and state.task_type not in {"implementation-planning", "technical-verification"}:
        best = _newest_contained_final_report(
            runs_base,
            task_root,
            "*/reports/final-report-*.data.json",
            Path(state.project_root),
        )
    best = _technical_evidence_for_comparison(state, best, task_root)
    if best is None:
        return ""
    # The approved plan is already wired via --approved-plan. On the first run
    # of an approved-plan consuming phase (final-verification / implementation)
    # the newest cross-phase report IS that plan, so the fallback would
    # re-recommend it as a clarification answer — injecting it twice, read as a
    # user clarification it is not. Never carry the approved plan back in here.
    if state.approved_plan_path and _same_file(best, state.approved_plan_path):
        return ""
    try:
        return str(best.relative_to(Path(state.project_root)))
    except ValueError:
        return str(best)


def _latest_run_inputs(
    state: WizardState, *, phase_segment: str = ""
) -> dict:
    """직전 run 의 입력 스냅샷. 위치/구조 지식은 run_context.latest_run_inputs 가 SSOT."""
    return latest_run_inputs(
        state.project_root, state.task_group, state.task_id,
        phase_segment=phase_segment)


def _carried_planning_report(state: WizardState) -> Optional[Path]:
    """이번 재실행이 이어받는 직전 implementation-planning 리포트 (없으면 None)."""
    if state.task_type != "implementation-planning":
        return None
    if not state.clarification_response_path or not state.project_root:
        return None
    report = _resolve_path(
        state.clarification_response_path, Path(state.project_root)
    )
    return report if report.is_file() else None


def _reverify_scope_preview(state: WizardState) -> Optional[dict]:
    """답변된 id 가 직전 리포트의 stage 로 되짚어지는지 — 범위 판정의 앞 절반."""
    report = _carried_planning_report(state)
    if report is None:
        return None
    return preview_link_availability_for_report(
        report, set(sidecar_answers(report))
    )


def _reverify_scope_shape(state: WizardState) -> str:
    """이 재실행에서 되짚기(back-trace)가 무엇을 내놓았는가.

    셋은 서로 다른 picker 를 요구한다 — `linked` 는 자동 판정이 실제로 좁힐 수
    있고, 나머지 둘은 자동이 낼 수 있는 답이 full 뿐이라 추천으로 내놓으면
    거짓말이 된다.
    """
    preview = _reverify_scope_preview(state) or {}
    if preview.get("unlinkedIds"):
        return SCOPE_SHAPE_UNLINKED
    if preview.get("wouldForceFull"):
        return SCOPE_SHAPE_NO_ANSWERS
    return SCOPE_SHAPE_LINKED


def _prior_stage_map_readable(state: WizardState) -> bool:
    """직전 리포트의 Stage Map 을 읽을 수 있는가 — 번호를 검증할 수 있는가."""
    try:
        return bool(_prior_stage_numbers(state))
    except WizardError:
        return False


def _reverify_scope_pick_required(state: WizardState) -> bool:
    """직전 리포트를 읽을 수 있으면 묻는다.

    종전에는 `preview_link_availability` 의 `wouldForceFull` 이 참이면 묻지
    않았다. 그 값은 답변된 id 가 0건일 때도 참인데, 그 경우야말로 질문이 필요한
    경우다 — `--impacted` 는 답변과 **독립된 입력**이고
    `incremental_scope._decision_for_run` 은 answered 가 비어도 impacted 만으로
    `incremental` 을 낸다. 열린 항목이 하나도 남지 않은 리포트를 재실행하면서
    한 stage 만 다시 보고 싶은 경우가 정확히 그 형태이고, 종전 게이트는 그때
    질문을 없애 사용자가 CLI 플래그를 직접 쓰는 것 말고는 길이 없게 만들었다.

    남는 배제 사유는 하나 — Stage Map 을 못 읽으면 입력받은 번호를 검증할 수단이
    없다(`_prior_stage_numbers` 가 드는 `no_stage_map`). 그때만 묻지 않는다.

    **base ref(C1)는 여기서 안 본다**: `preview_link_availability` 는 이름 그대로
    SHA 없이 판정하고, 현재 base SHA 는 `render-bundle` 이 매니페스트를 쓴 뒤에야
    존재한다. base ref 가 움직인 재실행에서도 질문은 그대로 나오고, full 판정은
    나중에 `decide_scope` 가 내린다.
    """
    if _reverify_scope_preview(state) is None:
        return False
    if _reverify_scope_shape(state) != SCOPE_SHAPE_NO_ANSWERS:
        return True
    return _prior_stage_map_readable(state)


def _prior_stage_numbers(state: WizardState) -> set[int]:
    """직전 리포트 Stage Map 의 stage 번호. 읽을 수 없으면 WizardError."""
    t = _p(state.workspace_root, "reverify_scope_stages")
    report = _carried_planning_report(state)
    if report is None:
        raise WizardError(
            t["errors"]["no_stage_map"].format(reason="carried report not found")
        )
    data_path = final_report_data_path(report)
    try:
        data = load_owned_object(data_path, artifact="planning final report")
        stages = {num for num, _ in parse_stage_graph(data)}
    except (OSError, ValueError, KeyError, TypeError) as exc:
        raise WizardError(
            t["errors"]["no_stage_map"].format(reason=str(exc))
        ) from exc
    if not stages:
        raise WizardError(
            t["errors"]["no_stage_map"].format(reason="stage map is empty")
        )
    return stages


def _has_prior_run_inputs(state: WizardState) -> bool:
    seg = slugify_task_segment(state.task_type) if state.task_type else ""
    return bool(_latest_run_inputs(state, phase_segment=seg))
