"""Phase 7 report post-processing — the single reference point.

Contract 3.0 collects usage into team state, assembles the role-owned inputs
into final-report data.json once, then verifies and renders that published
record. Contract 2.0 keeps its historical in-place projection sequence for
read-only compatibility. The order is load-bearing: rendering before assembly
or usage collection publishes stale derived views, and validating before
rendering trips the report-views contract.

A non-zero exit no longer stops the sequence before `validate-run`. A schema
cap used to hide the contract scan: assembly refused, later steps never ran,
and the lead only saw those failures after a second finalize. Each step still
records its own exit; `ok` stays False when any step failed. `teardown-stages`
is the exception — it reclaims worktrees, so a failed prefix skips it.

`token-usage` reads the lead session log (state outside this run). A refusal
there used to delete every later artifact; it no longer does, because the
sequence continues. `validators/validate-run.py` re-collects when the recorded
usage is all zeros against an `unavailable` session source (`_needs_token_autofix`)
and refuses the run rather than ship zeroed counts (`accuracy-failed`). A
legacy v1 report is caught earlier still, by its unsubstituted `{{...}}`
placeholders, which a v2 report never carries because its numeric cells are
`null` until this step fills them. Substituting the tokens on a later retry
then leaves the already-rendered html stale for `validators/validate-report-views.py`.

The translation sidecar is NOT one of these steps. `render-views` overlays it,
so a non-English run dispatches the translator before this sequence starts —
after verifying the data.json is English, which is why `check-source` is also
available as a standalone command.

Every lead adapter drives Phase 7 through this module: the Codex adapter calls
it in-process (``codex_dispatch``), and a Claude-led run reaches the same code
through the ``okstra report-finalize`` CLI. Neither reimplements the sequence.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence

from .agent_activity import ActivityProjectionError, project_agent_activity
from .report_contract import apply_execution_roles
from .report_assembly import ReportAssemblyError, assemble_report
from .dispatch_state import DispatchError, link_agent_dispatch_result
from .final_report_paths import final_report_data_path, final_report_markdown_path
from .paths import task_dir, task_manifest_file
from .release_gate import release_handoff_allowed
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
from .stage_integrate import IntegrateError
from .stage_targets import (
    StageTargetError,
    integrate_and_teardown_whole_task,
)
from .session import observe_lead_session


STEP_PROJECT_ACTIVITY = "project-activity"
STEP_CHECK_SOURCE = "check-source"
STEP_TOKEN_USAGE = "token-usage"
STEP_RENDER_VIEWS = "render-views"
STEP_SPAWN_FOLLOWUPS = "spawn-followups"
STEP_VALIDATE_RUN = "validate-run"
STEP_TEARDOWN_STAGES = "teardown-stages"

STEP_ORDER = (
    STEP_PROJECT_ACTIVITY,
    # First, because everything after it derives from the data.json: rendering
    # a Korean SSOT into English chrome, spawning follow-ups from it, and
    # validating it all succeed on a record the next phase cannot read.
    STEP_CHECK_SOURCE,
    STEP_TOKEN_USAGE,
    STEP_RENDER_VIEWS,
    STEP_SPAWN_FOLLOWUPS,
    STEP_VALIDATE_RUN,
    # Last, and only after the run validated: it removes the stage worktrees a
    # blocked verdict would send the user straight back to.
    STEP_TEARDOWN_STAGES,
)

V3_STEP_ORDER = (
    STEP_TOKEN_USAGE,
    STEP_PROJECT_ACTIVITY,
    STEP_CHECK_SOURCE,
    STEP_RENDER_VIEWS,
    STEP_SPAWN_FOLLOWUPS,
    STEP_VALIDATE_RUN,
    STEP_TEARDOWN_STAGES,
)


class FinalizeError(Exception):
    """Raised when the Phase 7 sequence cannot be assembled."""


def string_value(value: Any) -> str:
    if not isinstance(value, str):
        return ""
    return value.strip()


def require_string(payload: Mapping[str, Any], key: str) -> str:
    value = payload.get(key)
    if not isinstance(value, str) or not value.strip():
        raise FinalizeError(f"missing required string field: {key}")
    return value


def resolve_project_path(project_root: Path, value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else project_root / path


def resolve_optional_path(project_root: Path, value: Any) -> Path | None:
    if not isinstance(value, str) or not value.strip():
        return None
    return resolve_project_path(project_root, value)


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


def task_group(manifest: Mapping[str, Any]) -> str:
    value = string_value(manifest.get("taskGroup"))
    if value:
        return value
    task_key = require_string(manifest, "taskKey")
    colon_parts = task_key.split(":")
    if len(colon_parts) >= 3 and colon_parts[-2].strip():
        return colon_parts[-2].strip()
    slash_parts = task_key.split("/")
    if len(slash_parts) >= 2 and slash_parts[0].strip():
        return slash_parts[0].strip()
    raise FinalizeError(f"cannot infer task group from taskKey: {task_key}")


def task_id(manifest: Mapping[str, Any]) -> str:
    value = string_value(manifest.get("taskId"))
    if value:
        return value
    task_key = require_string(manifest, "taskKey")
    colon_parts = task_key.split(":")
    if len(colon_parts) >= 3 and colon_parts[-1].strip():
        return colon_parts[-1].strip()
    slash_parts = task_key.split("/")
    if len(slash_parts) >= 2 and slash_parts[-1].strip():
        return slash_parts[-1].strip()
    raise FinalizeError(f"cannot infer task id from taskKey: {task_key}")


def _category_seq(manifest: Mapping[str, Any], category: str) -> str:
    seqs = manifest.get("runSequencesByCategory")
    if not isinstance(seqs, Mapping):
        raise FinalizeError("run manifest has no runSequencesByCategory object")
    return require_string(seqs, category)


def run_seq(manifest: Mapping[str, Any]) -> str:
    """How many times this task type has run — what a lead event records."""
    return _category_seq(manifest, "manifests")


def report_seq(manifest: Mapping[str, Any]) -> str:
    """The `reports` sequence — the one the final-report filename carries.

    Categories advance independently (`paths.compute_run_paths`), so a run whose
    predecessor produced no report reuses the free report slot and `run_seq`
    outruns this one. This value reaches `render-report-views --seq`, which
    stamps it into the HTML `runMeta` naming the exported
    `user-response-<task-type>-<seq>.md`; `user_response.write_sidecar` derives
    that same name from the report path, so anything but `reports` makes the two
    answer paths write different files for one report.
    """
    return _category_seq(manifest, "reports")


def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
    """Resolve a run manifest's task manifest, falling back through the identity.

    Distinct from `paths.task_manifest_file`, which only names the file inside a
    known task root — this one decides *which* task root applies.
    """
    value = string_value(manifest.get("taskManifestPath"))
    if value:
        return resolve_project_path(project_root, value)
    task_root = string_value(manifest.get("taskRootPath"))
    if task_root:
        return task_manifest_file(resolve_project_path(project_root, task_root))
    return task_manifest_file(
        task_dir(project_root, task_group(manifest), task_id(manifest))
    )


def resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
    # The build lands helper scripts under `bin/`; `scripts/` is the repo layout.
    return _resolve_workspace_file(workspace_root, ("bin", "scripts"), script_name)


def resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
    return _resolve_workspace_file(workspace_root, ("validators",), validator_name)


def _resolve_workspace_file(
    workspace_root: Path,
    subdirs: tuple[str, ...],
    name: str,
) -> Path:
    candidates = [workspace_root / subdir / name for subdir in subdirs]
    candidates.append(workspace_root / name)
    for candidate in candidates:
        if candidate.is_file():
            return candidate.resolve()
    searched = ", ".join(str(candidate) for candidate in candidates)
    raise FinalizeError(f"{name} not found (searched: {searched})")


def tail(text: str, *, limit: int = 4000) -> str:
    if len(text) <= limit:
        return text
    return text[-limit:]


@dataclass(frozen=True)
class FinalizeContext:
    """Everything the Phase 7 sequence needs, resolved from a run manifest."""

    project_root: Path
    workspace_root: Path
    manifest_path: Path
    team_state_path: Path
    data_path: Path
    task_manifest_path: Path
    task_key: str
    task_type: str
    task_group: str
    task_id: str
    seq: str
    final_status_path: Path | None
    report_contract_version: str = "2.0"

    @property
    def markdown_path(self) -> Path:
        return final_report_markdown_path(self.data_path)

    @classmethod
    def from_manifest(
        cls,
        *,
        project_root: Path,
        workspace_root: Path,
        manifest_path: Path,
        team_state_path: Path,
        data_path: Path,
        manifest: Mapping[str, Any],
    ) -> "FinalizeContext":
        return cls(
            project_root=project_root,
            workspace_root=workspace_root,
            manifest_path=manifest_path,
            team_state_path=team_state_path,
            data_path=data_path,
            task_manifest_path=task_manifest_path(project_root, manifest),
            task_key=require_string(manifest, "taskKey"),
            task_type=require_string(manifest, "taskType"),
            task_group=task_group(manifest),
            task_id=task_id(manifest),
            seq=report_seq(manifest),
            final_status_path=resolve_optional_path(
                project_root, manifest.get("finalStatusPath")
            ),
            report_contract_version=str(
                manifest.get("reportContractVersion") or "2.0"
            ),
        )


def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
    """Assemble the ordered Phase 7 argv list. Order is contractual."""
    markdown_path = ctx.markdown_path
    commands = [
        (
            STEP_PROJECT_ACTIVITY,
            [
                "<in-process>",
                "agent-activity",
                "project",
                str(ctx.manifest_path),
                str(ctx.data_path),
            ],
        ),
        (
            STEP_CHECK_SOURCE,
            [
                sys.executable,
                str(
                    resolve_workspace_script(
                        ctx.workspace_root, "okstra-report-translate.py"
                    )
                ),
                "check-source",
                str(ctx.data_path),
            ],
        ),
        (
            STEP_TOKEN_USAGE,
            [
                sys.executable,
                str(resolve_workspace_script(ctx.workspace_root, "okstra-token-usage.py")),
                str(ctx.team_state_path),
                "--project-root",
                str(ctx.project_root),
                "--write",
                "--substitute-data",
                str(ctx.data_path),
            ],
        ),
        (
            STEP_RENDER_VIEWS,
            [
                sys.executable,
                str(
                    resolve_workspace_script(
                        ctx.workspace_root, "okstra-render-report-views.py"
                    )
                ),
                str(ctx.data_path),
                "--task-key",
                ctx.task_key,
                "--task-type",
                ctx.task_type,
                "--seq",
                ctx.seq,
                "--source-report",
                project_relative_path(ctx.project_root, markdown_path),
            ],
        ),
        (
            STEP_SPAWN_FOLLOWUPS,
            [
                sys.executable,
                str(
                    resolve_workspace_script(
                        ctx.workspace_root, "okstra-spawn-followups.py"
                    )
                ),
                str(ctx.data_path),
                "--project-root",
                str(ctx.project_root),
                "--task-group",
                ctx.task_group,
                "--parent-task-key",
                ctx.task_key,
            ],
        ),
        (STEP_VALIDATE_RUN, _validate_run_command(ctx, ctx.data_path)),
        (
            STEP_TEARDOWN_STAGES,
            ["<in-process>", "teardown-stages", str(ctx.data_path)],
        ),
    ]
    if ctx.report_contract_version != "3.0":
        return commands
    by_name = dict(commands)
    usage = by_name[STEP_TOKEN_USAGE]
    marker = usage.index("--substitute-data")
    by_name[STEP_TOKEN_USAGE] = usage[:marker]
    by_name[STEP_PROJECT_ACTIVITY] = [
        "<in-process>", "report-assembly", str(ctx.manifest_path), str(ctx.data_path)
    ]
    return [(name, by_name[name]) for name in V3_STEP_ORDER]


def _teardown_stage_worktrees(
    ctx: FinalizeContext,
    command: list[str],
) -> subprocess.CompletedProcess:
    """판정이 릴리스로 향할 때만 stage worktree 와 registry 키를 정리한다.

    whole-task 진입이 통합만 하고 정리를 남겨두므로(`stage_targets`), 정리는 판정이
    나온 뒤인 여기서 한다. `blocked` 이거나 릴리스를 막는 조건이 남은 판정에서는
    stage 작업물을 그대로 둬서 재작업이 바로 이어지게 한다. 이미 정리된 뒤 재실행돼도
    같은 결과를 낸다 — 병합은 `already_merged` 로, 없는 worktree 는 건너뛴다.
    """
    def _done(payload: Mapping[str, Any]) -> subprocess.CompletedProcess:
        return subprocess.CompletedProcess(command, 0, json.dumps(payload), "")

    if ctx.task_type != "final-verification":
        return _done({"skipped": "not a final-verification run"})
    try:
        data = load_owned_object(Path(ctx.data_path), artifact="final report record")
    except JsonBoundaryError as exc:
        return subprocess.CompletedProcess(
            command, 1, "", f"cannot read final-report data.json: {exc}")
    if data.get("verificationScope") != "whole-task":
        return _done({"skipped": "single-stage verification owns no teardown"})
    if not release_handoff_allowed(data):
        return _done({"skipped": "verdict does not clear the work for release"})
    try:
        result = integrate_and_teardown_whole_task(
            project_root=ctx.project_root,
            task_group=ctx.task_group,
            task_id=ctx.task_id,
        )
    except (IntegrateError, StageTargetError, OSError) as exc:
        return subprocess.CompletedProcess(command, 1, "", str(exc))
    return _done(result)


def _validate_run_command(ctx: FinalizeContext, report_record_path: Path) -> list[str]:
    command = [
        sys.executable,
        str(resolve_workspace_validator(ctx.workspace_root, "validate-run.py")),
        "--team-state",
        str(ctx.team_state_path),
        "--report",
        str(report_record_path),
        "--run-manifest",
        str(ctx.manifest_path),
        "--task-manifest",
        str(ctx.task_manifest_path),
    ]
    if ctx.final_status_path is not None:
        command.extend(["--final-status", str(ctx.final_status_path)])
    return command


def step_payload(
    name: str,
    command: Sequence[str],
    result: subprocess.CompletedProcess[str],
) -> dict[str, Any]:
    return {
        "name": name,
        "command": list(command),
        "exitCode": result.returncode,
        "stdoutTail": tail(result.stdout),
        "stderrTail": tail(result.stderr),
    }


def run_finalize(
    ctx: FinalizeContext,
    *,
    before_step: Callable[[str], None] | None = None,
    only: Sequence[str] | None = None,
) -> dict[str, Any]:
    """계약 순서로 Phase 7 을 돌리고, 실패해도 `validate-run` 까지 모은다.

    `before_step` 은 각 단계 프로세스 직전에 호출된다. Codex 어댑터가
    `validate-run` 앞에 작성기 상태를 `completed` 로 표시하는 자리이다.

    `only` 는 고른 단계만 계약 순서로 남긴다.
    """
    steps: list[dict[str, Any]] = []
    try:
        if ctx.report_contract_version != "3.0":
            write_execution_roles(ctx)
        commands = build_commands(ctx)
    except FinalizeError as exc:
        return {"ok": False, "reason": str(exc), "steps": steps}

    if only:
        selected = set(only)
        contract_order = (
            V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
        )
        unknown = sorted(selected - set(contract_order))
        if unknown:
            return {
                "ok": False,
                "reason": f"unknown finalize step(s): {', '.join(unknown)}",
                "steps": steps,
            }
        commands = [(name, cmd) for name, cmd in commands if name in selected]

    first_failure = ""
    for name, command in commands:
        # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라진다.
        if name == STEP_TEARDOWN_STAGES and first_failure:
            continue
        if before_step is not None:
            before_step(name)
        result = _run_finalize_step(ctx, name, command)
        steps.append(step_payload(name, command, result))
        if result.returncode != 0 and not first_failure:
            first_failure = f"{name} failed with exit code {result.returncode}"
    if first_failure:
        return {"ok": False, "reason": first_failure, "steps": steps}
    return {"ok": True, "reason": "", "steps": steps}


def _run_finalize_step(
    ctx: FinalizeContext,
    name: str,
    command: Sequence[str],
) -> subprocess.CompletedProcess[str]:
    """한 Phase 7 단계를 실행하고 그 단계의 종료 코드만 돌려준다."""
    if name == STEP_PROJECT_ACTIVITY:
        return _run_project_activity(ctx, command)
    if name == STEP_TEARDOWN_STAGES:
        return _teardown_stage_worktrees(ctx, command)
    if name == STEP_VALIDATE_RUN:
        try:
            _link_lead_result_for_validation(ctx)
        except (DispatchError, OSError, json.JSONDecodeError) as exc:
            return subprocess.CompletedProcess(
                command, 1, "", f"lead result linkage failed: {exc}"
            )
    return subprocess.run(
        command,
        cwd=ctx.project_root,
        text=True,
        capture_output=True,
    )


def _run_project_activity(
    ctx: FinalizeContext,
    command: Sequence[str],
) -> subprocess.CompletedProcess[str]:
    """계약 3.0 은 조립, 그 외는 활동 투영만 한다."""
    try:
        if ctx.report_contract_version == "3.0":
            assembled = assemble_report(ctx.project_root, ctx.manifest_path)
            count = len(assembled.get("agentActivity") or [])
        else:
            rows = project_agent_activity(
                ctx.project_root,
                ctx.manifest_path,
                ctx.data_path,
            )
            count = len(rows)
    except (ActivityProjectionError, ReportAssemblyError) as exc:
        return subprocess.CompletedProcess(command, 1, "", str(exc))
    return subprocess.CompletedProcess(
        command, 0, json.dumps({"count": count}), ""
    )


def write_execution_roles(ctx: FinalizeContext) -> None:
    """Write the exact manifest role set onto the final-report data.json."""
    if not ctx.data_path.is_file() or not ctx.manifest_path.is_file():
        return
    manifest = _load_manifest(ctx.manifest_path)
    data = _load_manifest(ctx.data_path)
    updated = apply_execution_roles(dict(data), dict(manifest))
    if updated == data:
        return
    write_owned_object_atomic(ctx.data_path, updated, artifact="final report record")


def _link_lead_result_for_validation(ctx: FinalizeContext) -> None:
    """Link the lead-authored synthesis artifact before Phase 7 validation."""
    manifest = _load_manifest(ctx.manifest_path)
    contract = manifest.get("agentContract")
    if not isinstance(contract, Mapping) or contract.get("schemaVersion") != 1:
        return
    team_state = _load_manifest(ctx.team_state_path)
    lead_dispatches = [
        row for row in (team_state.get("agentDispatches") or [])
        if isinstance(row, Mapping)
        and row.get("audience") == "lead"
        and row.get("workerId") == "lead"
    ]
    if len(lead_dispatches) != 1:
        return
    convergence_value = string_value(manifest.get("convergenceStatePath"))
    convergence_path = (
        resolve_project_path(ctx.project_root, convergence_value)
        if convergence_value else None
    )
    if convergence_path is not None and convergence_path.is_file():
        result_path = convergence_path
    elif ctx.task_type == "release-handoff" and ctx.data_path.is_file():
        result_path = ctx.data_path
    else:
        return
    link_agent_dispatch_result(
        project_root=ctx.project_root,
        run_manifest_path=ctx.manifest_path,
        dispatch_id=str(lead_dispatches[0]["dispatchId"]),
        result_path=result_path,
    )


def _load_manifest(path: Path) -> Mapping[str, Any]:
    try:
        payload = load_owned_object(path, artifact="run manifest")
    except JsonBoundaryError as exc:
        raise FinalizeError(f"run manifest is not valid: {path} ({exc})") from exc
    if not isinstance(payload, Mapping):
        raise FinalizeError(f"run manifest is not a JSON object: {path}")
    return payload


def _context_from_args(args: argparse.Namespace) -> FinalizeContext:
    project_root = Path(args.project_root).resolve()
    manifest_path = resolve_project_path(project_root, args.run_manifest)
    manifest = _load_manifest(manifest_path)
    if args.team_state:
        team_state_path = resolve_project_path(project_root, args.team_state)
    else:
        team_state_path = resolve_project_path(
            project_root, require_string(manifest, "teamStatePath")
        )
    report_path = resolve_project_path(project_root, args.report)
    return FinalizeContext.from_manifest(
        project_root=project_root,
        workspace_root=Path(args.workspace_root).resolve(),
        manifest_path=manifest_path,
        team_state_path=team_state_path,
        data_path=final_report_data_path(report_path),
        manifest=manifest,
    )


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Run the Phase 7 report post-processing sequence.",
    )
    parser.add_argument("--project-root", required=True)
    parser.add_argument("--run-manifest", required=True)
    parser.add_argument(
        "--report",
        required=True,
        help="final-report record (.data.json); a leftover markdown path still locates the sibling record",
    )
    parser.add_argument("--workspace-root", required=True)
    parser.add_argument(
        "--team-state",
        default="",
        help="defaults to the run manifest's teamStatePath",
    )
    parser.add_argument(
        "--only",
        action="append",
        default=[],
        choices=list(STEP_ORDER),
        help=(
            "run only these steps (repeatable, contractual order preserved). "
            "Use `--only validate-run` to retry the step that usually fails "
            "without repeating the preceding idempotent steps."
        ),
    )
    return parser


def _step_summary_lines(
    result: Mapping[str, Any], order: Sequence[str] = STEP_ORDER,
) -> list[str]:
    """One human-readable line per step, so the outcome is legible without
    parsing the JSON payload."""
    lines = []
    for step in result.get("steps") or []:
        code = step.get("exitCode")
        mark = "ok  " if code == 0 else "FAIL"
        lines.append(f"  [{mark}] {step.get('name')} (exit {code})")
    for name in order:
        if not any(s.get("name") == name for s in (result.get("steps") or [])):
            lines.append(f"  [skip] {name}")
    return lines


def _recovery_step_names(
    result: Mapping[str, Any], order: Sequence[str] = STEP_ORDER,
) -> list[str]:
    """The steps a retry has to re-run: every step from the earliest failure on.

    Naming only the failed steps would prescribe half a recovery. `token-usage`
    can fail while `render-views` still writes html from unsubstituted data;
    substituting the tokens on a retry leaves that view stale
    (`validators/validate-report-views.py` checks `source-sha256` against the
    md body). Resuming from the earliest failure redoes the tail while skipping
    the prefix that succeeded — the saving `--only` exists for.
    """
    failed = {
        string_value(step.get("name"))
        for step in (result.get("steps") or [])
        if step.get("exitCode") != 0
    }
    for index, name in enumerate(order):
        if name in failed:
            return list(order[index:])
    return []


def main(argv: Sequence[str] | None = None) -> int:
    args = _parser().parse_args(argv)
    try:
        ctx = _context_from_args(args)
    except FinalizeError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    # Phase 7 is the last boundary the lead crosses, and the last chance to add
    # the generations resume and compaction split it into. It runs ahead of the
    # sequence because the token-usage step below reads `leadSessionIds`.
    observe_lead_session(ctx.project_root, ctx.team_state_path)
    result = run_finalize(ctx, only=args.only or None)
    order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
    print(json.dumps(result, indent=2, ensure_ascii=False))
    print("finalize steps:", file=sys.stderr)
    for line in _step_summary_lines(result, order):
        print(line, file=sys.stderr)
    if not result["ok"]:
        print(f"error: {result['reason']}", file=sys.stderr)
        recovery = _recovery_step_names(result, order)
        if recovery:
            flags = " ".join(f"--only {name}" for name in recovery)
            print(
                f"resume the sequence from the earliest failure with `{flags}`",
                file=sys.stderr,
            )
        return 1
    return 0


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