"""Decide whether an implementation-planning clarification re-run can be
incremental, and if so which stages to re-verify vs carry forward.

Deterministic half of the incremental-reverification feature: the lead
prompt supplies the impacted-stage set (its discretionary C2 judgement) and
the base SHAs; everything here is pure so the same inputs always yield the
same decision. See docs/superpowers/specs/2026-07-09-incremental-clarification-reverification-design.md.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

from okstra_ctl.final_report_paths import final_report_data_path
from okstra_ctl.stage_citations import cited_stage_numbers
from okstra_ctl.stage_targets import downstream_stage_closure
from okstra_ctl.json_boundary import load_owned_object

CUTOFF_RATIO = 0.5

# Prefix that marks a full decision the lead *chose* rather than fell back to.
# Both cases return `mode: full`, and without this the two are one string —
# which is what made a re-run's "no impacted stages resolved" unreadable as
# either a structural judgement or a mapping the lead never made.
DECLARED_FULL_PREFIX = "declared structural change:"

# CLI-to-lead signal only: an answered id traced to no stage and the user has
# not named `--impacted` stages. Never recorded as incrementalDecision (schema
# enum is incremental|full). The lead asks for stage numbers and calls again.
UNRESOLVED_MODE = "unresolved"

# `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
# id itself. Every other prefix is numbered by position; its stage comes from
# `stageScope` / `stageRefs`, then from the prose the planner wrote.
_STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")


@dataclass
class IncrementalDecision:
    mode: str  # "incremental" | "full" | "unresolved"
    reverify_stages: list[int]
    carry_stages: list[int]
    reason: str


@dataclass(frozen=True)
class UserReverifyScope:
    """What the user answered at the wizard's re-verification-scope step.

    The lead still runs `okstra incremental-scope`; this only says which of the
    CLI's inputs the user pinned. `auto` pins nothing.
    """
    mode: str  # "auto" | "full" | "stages"
    stages: list[int]


class ReverifyScopeError(ValueError):
    """The `--reverify-scope` value is not one of the three accepted forms."""


def parse_user_reverify_scope(raw: str) -> UserReverifyScope:
    """`--reverify-scope` → the user's pinned scope.

    Accepts exactly `""` / `auto`, `full`, or a comma-separated stage list.
    Anything else raises rather than degrading to `auto`: a typo silently read
    as "let the lead decide" would drop a full-re-verification request the user
    made on purpose, and the run would look like it honoured it.
    """
    value = (raw or "").strip()
    if not value or value == "auto":
        return UserReverifyScope("auto", [])
    if value == "full":
        return UserReverifyScope("full", [])
    tokens = [token.strip() for token in value.split(",") if token.strip()]
    if not tokens or not all(token.isdigit() for token in tokens):
        raise ReverifyScopeError(
            f"--reverify-scope must be 'auto', 'full', or a stage-number list "
            f"(e.g. '2,3'); got {raw!r}"
        )
    return UserReverifyScope("stages", sorted({int(token) for token in tokens}))


def _parse_depends_on(cell: str) -> list[int]:
    text = (cell or "").strip()
    if not text or text == "(none)":
        return []
    return [int(tok.strip()) for tok in text.split(",") if tok.strip()]


def parse_stage_graph(data: dict) -> list[tuple[int, list[int]]]:
    stage_map = data.get("implementationPlanning", {}).get("stageMap", [])
    return [(int(row["stage"]), _parse_depends_on(row.get("dependsOn", ""))) for row in stage_map]


def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
    items = (
        data.get("implementationPlanning", {})
        .get("designPreparation", {})
        .get("items", [])
    )
    if not isinstance(items, list):
        raise ValueError("designPreparation.items must be an array")
    matched = {
        str(item.get("id")): item
        for item in items
        if isinstance(item, dict) and str(item.get("id")) in item_ids
    }
    missing = item_ids - set(matched)
    if missing:
        raise ValueError(f"unknown design-prep item(s): {', '.join(sorted(missing))}")

    impacted: set[int] = set()
    for item_id, item in matched.items():
        stage_refs = item.get("stageRefs")
        if not isinstance(stage_refs, list) or not stage_refs:
            raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
        try:
            parsed_refs = {int(stage) for stage in stage_refs}
        except (TypeError, ValueError) as exc:
            raise ValueError(
                f"design-prep item {item_id} has invalid stageRefs"
            ) from exc
        if any(stage < 1 for stage in parsed_refs):
            raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
        impacted.update(parsed_refs)
    return impacted


def _int_stages(value: object) -> set[int]:
    if not isinstance(value, list):
        return set()
    stages: set[int] = set()
    for entry in value:
        if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 1:
            stages.add(entry)
    return stages


def _plan_item_stages(item: dict) -> set[int]:
    """항목이 걸린 스테이지. id 좌표, 그다음 기록된 범위, 그다음 산문.

    `P-Val-*` / `P-Req-*` 는 위치 번호라 id 에서 스테이지가 안 나온다. 행이
    `stageScope` / `stageRefs` 를 실어도 산문 `subject` 만 읽으면 C-039 같은
    답이 unlinked 가 되어 재실행이 full 로 떨어진다.
    """
    structural = _STRUCTURAL_STAGE_IN_ID_RE.match(str(item.get("id") or ""))
    if structural:
        return {int(structural.group(1) or structural.group(2))}
    stages = _int_stages(item.get("stageScope")) or _int_stages(item.get("stageRefs"))
    payload = item.get("payload")
    if not stages and isinstance(payload, dict):
        stages = _int_stages(payload.get("stageRefs"))
    if stages:
        return stages
    return cited_stage_numbers(str(item.get("subject") or ""))


_BLOCK_MARKER_FIELDS = ("status", "approvalDisposition")


def coverage_row_blocked_on(row: object, clarification_id: str) -> bool:
    """Whether a requirement-coverage row records a block on this clarification.

    The marker lives in either of two fields, and the schema says so: `status`
    allows `blocked C-NNN`, and so does `approvalDisposition`. A row recorded
    as a `documented-deviation` carries its block in the latter, so reading
    `status` alone missed it — which forced the whole re-run to full for an
    answer whose blast radius the report did record.

    `validate-run.py` shares this predicate to require that every approval
    blocker has at least one such link. One definition, so the gate cannot
    demand a link shape this resolver would refuse to follow.
    """
    if not isinstance(row, dict):
        return False
    blocked = f"blocked {clarification_id}"
    return any(
        str(row.get(field) or "").strip() == blocked
        for field in _BLOCK_MARKER_FIELDS
    )


def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
    """Stages of every coverage row blocked on ``clarification_id``."""
    stages: set[int] = set()
    for row in coverage if isinstance(coverage, list) else []:
        if coverage_row_blocked_on(row, clarification_id):
            stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
            stages |= _int_stages(row.get("stageRefs"))
    return stages


def partition_clarification_stages(
    data: dict, clarification_ids: set[str]
) -> tuple[set[int], list[str]]:
    """연결된 stage 번호와, 아무 stage 에도 안 닿는 id.

    preview 와 판정 경로가 같은 분할을 쓴다. unlinked 는 full 강등이 아니라
    `--impacted` 를 받거나 `--full-reason` 을 받는 분기이다.
    """
    impacted: set[int] = set()
    unlinked: list[str] = []
    for clarification_id in sorted(clarification_ids):
        stages = stages_for_clarification(data, clarification_id)
        if stages:
            impacted |= stages
        else:
            unlinked.append(clarification_id)
    return impacted, unlinked


def clarification_impacted_stages(
    data: dict, clarification_ids: set[str]
) -> set[int]:
    """Stage Map stages the answered clarifications touch, read off the prior run.

    Two links the prior report already recorded: the `P-*` plan item that blocked
    on the clarification, and the requirement-coverage row blocked on the same
    id. Resolving them here removes the discretionary mapping step that made the
    lead fall back to full even for an answer that changed nothing.

    Raises when any id resolves to no stage — a partially-resolved set would
    narrow the re-run past an answer whose blast radius nobody established.
    The CLI decision path does not use this raise as a full fallback; it asks
    for `--impacted` instead.
    """
    impacted, unlinked = partition_clarification_stages(data, clarification_ids)
    if unlinked:
        raise ValueError(
            f"answered clarification {unlinked[0]} traces to no stage "
            "(no plan item or coverage row cites one)"
        )
    return impacted


def stages_for_clarification(data: dict, clarification_id: str) -> set[int]:
    """Stages the prior run linked to one clarification; empty when it linked none.

    Separate from the raising walk above because the preview needs the same
    lookup without the exception: it reports *which* ids are unlinked, and a
    raise on the first one would hide the rest.
    """
    planning = data.get("implementationPlanning")
    if not isinstance(planning, dict):
        raise ValueError("implementationPlanning is missing")
    verification = planning.get("planBodyVerification")
    plan_items = (
        verification.get("planItems") if isinstance(verification, dict) else []
    )
    stages = _stages_blocked_on(planning.get("requirementCoverage"), clarification_id)
    for item in plan_items if isinstance(plan_items, list) else []:
        if isinstance(item, dict) and (
            item.get("clarificationId") == clarification_id
            or clarification_id in (item.get("clarificationRefs") or [])
        ):
            stages |= _plan_item_stages(item)
    return stages


def preview_link_availability(data: dict, clarification_ids: set[str]) -> dict:
    """Would the answered ids let the next re-run narrow — decided without a SHA.

    The full decision also needs safety condition C1 (base ref unchanged), and
    the current base SHA only exists once `render-bundle` has written a
    manifest and registered the run. By then the two-hour cost of a full
    re-verification is already committed. Link availability is the half that
    decides the common case and needs nothing but the prior report, so it can
    be shown while the run is still reshapeable.

    `wouldForceFull: false` is therefore not a promise of `incremental` — it
    says only that this half found nothing forcing full. An unlinked id is
    not such a thing: it cannot auto-narrow, and it does not force full.
    """
    _, unlinked = partition_clarification_stages(data, clarification_ids)
    if unlinked:
        return {
            "wouldForceFull": False,
            "unlinkedIds": unlinked,
            "reason": (
                f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
                "name those stages via --impacted, or declare a structural change "
                "with --full-reason; an unlinked id does not force the whole re-run "
                "to full"
            ),
        }
    if not clarification_ids:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": "no answered clarifications given; nothing to narrow with",
        }
    return {
        "wouldForceFull": False,
        "unlinkedIds": [],
        "reason": (
            "every answered id traces to a stage; the base-ref comparison at run "
            "time still decides the final mode"
        ),
    }


def preview_link_availability_for_report(
    report: Path, clarification_ids: set[str]
) -> dict:
    """`preview_link_availability` for a report on disk, data read included.

    Both callers hold a report path, not a loaded dict. Leaving the read at
    each call site is how they would drift on what a missing or unreadable
    data sibling means — and that verdict is the one the user acts on.
    """
    data_path = final_report_data_path(report)
    if not data_path.is_file():
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"prior report has no data sibling at {data_path.name}",
        }
    try:
        data = load_owned_object(data_path, artifact="incremental scope report")
    except (OSError, ValueError) as exc:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"prior report data is unreadable: {exc}",
        }
    return preview_link_availability(data, clarification_ids)


def decide_scope(
    *,
    stages: list[tuple[int, list[int]]],
    impacted_stages: set[int],
    prev_base_sha: str,
    cur_base_sha: str,
    cutoff_ratio: float = CUTOFF_RATIO,
) -> IncrementalDecision:
    if not prev_base_sha or prev_base_sha != cur_base_sha:
        return IncrementalDecision(
            "full", [], [],
            "the branch this plan was written against has moved on "
            f"({prev_base_sha or 'unrecorded'} -> {cur_base_sha or 'unrecorded'}), "
            "so none of the prior stages can be reused as they stand",
        )
    if not impacted_stages:
        return IncrementalDecision(
            "full", [], [],
            "no stage of the prior plan could be tied to the answers, so there is "
            "nothing to narrow the rework down to",
        )
    all_stages = {num for num, _ in stages}
    closure = downstream_stage_closure(stages, set(impacted_stages))
    if len(closure) * 2 > len(all_stages):
        return IncrementalDecision(
            "full", [], [],
            f"the answers reach {len(closure)} of the plan's {len(all_stages)} stages — "
            f"past the {int(cutoff_ratio * 100)}% mark where replanning outright costs "
            "less than tracking what carried over",
        )
    reverify = sorted(closure)
    carry = sorted(all_stages - closure)
    return IncrementalDecision(
        "incremental", reverify, carry,
        f"the answers reach {len(reverify)} of the plan's {len(all_stages)} stages; "
        "the rest is reused as written",
    )


def _preview_result(args) -> dict:
    """`preview_link_availability` over CLI args, degrading to full on bad input.

    Same posture as the decision path below: a caller reads the answer off
    stdout, so a traceback would leave it with nothing. `full` is the safe
    answer when the input cannot be read.
    """
    try:
        data = load_owned_object(
            Path(args.prev_data), artifact="incremental scope report"
        )
        answered = {
            token.strip()
            for token in args.answered_clarifications.split(",")
            if token.strip()
        }
        return preview_link_availability(data, answered)
    except (OSError, ValueError, KeyError, TypeError) as exc:
        return {
            "wouldForceFull": True,
            "unlinkedIds": [],
            "reason": f"invalid incremental-scope input: {exc}",
        }


def _decision_for_run(args) -> IncrementalDecision:
    """SHA·폐포·컷오프 판정. unlinked 는 full 로 강등하지 않는다."""
    data = load_owned_object(
        Path(args.prev_data), artifact="incremental scope report"
    )
    stages = parse_stage_graph(data)
    impacted = {
        int(token.strip()) for token in args.impacted.split(",") if token.strip()
    }
    prep_ids = {
        token.strip() for token in args.prep_items.split(",") if token.strip()
    }
    impacted.update(design_prep_impacted_stages(data, prep_ids))
    answered = {
        token.strip()
        for token in args.answered_clarifications.split(",")
        if token.strip()
    }
    linked, unlinked = partition_clarification_stages(data, answered)
    if unlinked and not impacted:
        return IncrementalDecision(
            UNRESOLVED_MODE,
            [],
            [],
            f"{', '.join(unlinked)} trace(s) to no stage in the prior report — "
            "pass --impacted with the stage numbers those answers affect, or "
            "--full-reason for a structural change",
        )
    impacted.update(linked)
    unknown_stages = impacted - {stage for stage, _ in stages}
    if unknown_stages:
        unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
        raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
    return decide_scope(
        stages=stages,
        impacted_stages=impacted,
        prev_base_sha=args.prev_base_sha,
        cur_base_sha=args.cur_base_sha,
    )


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(prog="okstra incremental-scope")
    ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
    # Not required: `--preview` answers the link half before a worktree base
    # commit exists, which is the whole reason that mode is there.
    ap.add_argument("--cur-base-sha", default="")
    ap.add_argument("--prev-base-sha", default="")
    ap.add_argument(
        "--preview",
        action="store_true",
        help="report whether the answered ids could narrow the re-run, using the "
             "prior report alone. Side-effect free and needs no base SHA, so it "
             "can run before the decision to start is irreversible",
    )
    ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
    ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
    ap.add_argument(
        "--answered-clarifications",
        default="",
        help="comma-separated C-NNN ids answered since the prior run; their "
             "stages are resolved from that run's plan-item and coverage links",
    )
    ap.add_argument(
        "--full-reason",
        default="",
        help="declare that an answer changes the selected option, Stage Map, or "
             "recommended approach. Forces full and records the judgement, so it "
             "is distinguishable from a re-run that simply resolved no stage",
    )
    args = ap.parse_args(argv)

    if args.preview:
        print(json.dumps(_preview_result(args), ensure_ascii=False))
        return 0

    declared = args.full_reason.strip()
    if declared:
        # The lead's structural judgement is exactly what the back-trace cannot
        # make, so a successful trace must not override it.
        print(json.dumps(asdict(IncrementalDecision(
            "full", [], [], f"{DECLARED_FULL_PREFIX} {declared}",
        )), ensure_ascii=False))
        return 0

    try:
        decision = _decision_for_run(args)
    # Every bad input degrades to a full re-verification rather than raising:
    # `full` is always the safe answer, and the caller reads the decision off
    # stdout, so a traceback would leave it with no decision at all. OSError
    # covers an unreadable --prev-data, KeyError/TypeError a stageMap row that
    # is missing `stage` or is not a mapping. Unlinked ids are not this case
    # — `_decision_for_run` returns `unresolved` instead of raising.
    except (OSError, ValueError, KeyError, TypeError) as exc:
        decision = IncrementalDecision(
            "full", [], [], f"invalid incremental-scope input: {exc}",
        )
    print(json.dumps(asdict(decision), ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
