"""계획 저작 쪽에 넘길 Stage 원장을 디스크에서 조립한다.

계획을 세우는 쪽은 지금 어느 stage 가 이미 구현됐는지 모른 채 계획을 쓴다.
이 모듈은 그 사실만 모아 준다 — 무엇을 계획해야 하는지는 말하지 않는다.

판정은 소유하지 않는다. 상태 어휘와 lifecycle 판정은 stage_targets 가, stage
map 의 출처 판정은 stage_map 이 소유한다. 여기서는 둘을 잇고 직렬화만 한다.

원장은 두 질문에 답하고, 답의 출처가 서로 다르다.

- "무엇이 이미 지어졌나" — carry 사이드카가 가리키는 계획이 답한다. 실행이
  실제로 따른 문서이기 때문이다.
- "어떤 stage 번호가 이미 쓰였나" — 최신 계획이 답한다. ADR-0015 의
  append-only 는 최신 계획의 `max` 로만 판정할 수 있다.

한 소스가 둘 다 답하면, 완료 이후 stage 를 덧붙인 계획이 있을 때 그 번호가
비어 있는 것처럼 보이고 새 stage 가 그 번호를 다시 받는다.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from .consumers import read_stage_consumer_state
from .paths import RunRef
from .stage_map import (
    StageMapError,
    load_latest_plan_stage_map,
    load_task_stage_map,
)
from .stage_targets import stage_lifecycle_snapshot_from_state
from .task_target import infer_project_root


def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
    """이 task 의 Stage 원장.

    세 결과를 구분한다. 셋이 하나로 접히면 소비처가 "계획이 아직 없다" 와
    "계획을 못 읽었다" 를 같은 것으로 읽는다.

    - ``None`` — 계획 리포트가 아직 없다. 이 task 의 첫 계획 run 이다.
    - ``{"unreadable": <사유>}`` — 계획은 있는데 Stage Map 을 못 읽었다.
    - 그 외 — 사실 기록.
    """
    task_root = Path(task_root)
    try:
        latest = load_latest_plan_stage_map(task_root)
    except StageMapError as exc:
        return {"unreadable": _failure_reason(task_root, exc)}
    if latest.state != "ready" or not latest.stages:
        return None

    plan_run_root = RunRef.from_task_root(
        task_root, "implementation-planning"
    ).run_dir
    # carry 사이드카에서 done 행을 복구한다. implementation prep 이 하는 것과
    # 같은 멱등 복구이며, 이걸 건너뛰면 크래시 창에 걸린 완료 stage 가 원장에
    # 미완으로 실려 저작 쪽이 이미 구현된 stage 를 다시 계획한다.
    state = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
    lifecycle = stage_lifecycle_snapshot_from_state(latest.stages, state)
    records = lifecycle.ledger_records()

    built_from, divergence = _built_from(task_root, latest)
    divergence += _renumbering_divergence(
        latest, built_from, state.done_stages
    )
    ledger: dict[str, Any] = {
        "sourcePlan": _project_relative(
            task_root, built_from.source_plan_path if built_from else ""
        ),
        "latestPlan": _project_relative(task_root, latest.source_plan_path),
        "stages": records,
    }
    if divergence:
        ledger["planDivergence"] = divergence
    return ledger


def render_stage_ledger(ledger: dict[str, Any] | None) -> str:
    """packet 에 실릴 JSON 본문. 사실 기록이 없으면 빈 문자열."""
    if not ledger or ledger.get("unreadable"):
        return ""
    return json.dumps(ledger, ensure_ascii=False, indent=2)


def stage_ledger_notice(ledger: dict[str, Any] | None) -> str:
    """원장을 실을 수 없는 사유. 실을 수 있으면 빈 문자열.

    평문이다. 실패 통지는 저작 쪽이 파싱할 데이터가 아니라 읽을 판정이고,
    JSON 표면을 하나 더 만들면 그만큼 오독할 키가 늘어난다.
    """
    if not ledger:
        return ""
    return str(ledger.get("unreadable") or "")


def _built_from(task_root: Path, latest: Any):
    """완료된 stage 가 따른 계획. 못 읽으면 ``(None, [사유])``.

    이걸 못 읽어도 원장 자체는 낼 수 있다 — 상태는 consumers 원장에서 오고,
    stage 목록은 최신 계획에서 온다. 잃는 것은 번호 재배치 검사뿐이므로,
    실패를 원장 부재로 승격하지 않고 그 사실만 함께 싣는다.
    """
    try:
        return load_task_stage_map(task_root, {}), []
    except StageMapError as exc:
        return None, [
            "could not read the plan the completed stages were built against, "
            "so stage renumbering could not be checked: "
            + _failure_reason(task_root, exc)
        ]


def _renumbering_divergence(
    latest: Any,
    built_from: Any,
    done_stages: set[int],
) -> list[str]:
    """완료된 stage 가 두 계획에서 같은 작업을 가리키는지.

    ADR-0015 는 stage 번호의 재사용과 재배치를 금지하므로, 규칙이 지켜졌다면
    최신 계획 위에 실행 상태를 겹쳐 쓰는 것이 안전하다. 지켜졌는지는 검사할
    사실이지 가정할 사실이 아니다 — 과거 replan 이 번호를 다시 매겼다면 완료
    커밋이 엉뚱한 stage 에 귀속되고, 그 오류는 통합 시점까지 조용하다.
    """
    if built_from is None or built_from.state != "ready":
        return []
    if built_from.source_plan_path == latest.source_plan_path:
        # 완료된 stage 를 지은 계획이 곧 최신 계획이다. 비교할 두 번째 계획이
        # 없으므로 재배치가 일어날 자리 자체가 없다.
        return []
    latest_titles = {
        int(row["stage_number"]): str(row.get("title") or "")
        for row in latest.stages
    }
    built_titles = {
        int(row["stage_number"]): str(row.get("title") or "")
        for row in built_from.stages
    }
    notes: list[str] = []
    for stage in sorted(done_stages):
        if stage not in built_titles:
            continue
        if stage not in latest_titles:
            notes.append(
                f"stage {stage} is recorded done but the latest plan does not "
                "declare it; a completed stage was dropped rather than cancelled"
            )
            continue
        if built_titles[stage] != latest_titles[stage]:
            notes.append(
                f"stage {stage} names different work in the two plans "
                f"(built: {built_titles[stage]!r}; latest: "
                f"{latest_titles[stage]!r}); its completion may be attributed "
                "to the wrong stage"
            )
    return notes


def _failure_reason(task_root: Path, exc: StageMapError) -> str:
    """StageMapError 를 프로젝트 상대 경로로 낮춘 한 줄."""
    parts = [exc.reason]
    if exc.source_plan_path:
        parts.append(f"source={_project_relative(task_root, exc.source_plan_path)}")
    if exc.conflicting_paths:
        conflicts = ", ".join(
            _project_relative(task_root, path) for path in exc.conflicting_paths
        )
        parts.append(f"conflicts={conflicts}")
    return "; ".join(parts)


def _project_relative(task_root: Path, source_plan_path: str) -> str:
    """절대 경로를 프로젝트 상대로 낮춘다.

    packet 은 워커에게 그대로 전달된다. 절대 경로를 실으면 실행 머신의
    디렉터리 구조가 프롬프트에 박히고, 다른 머신에서 재현할 때 어긋난다.
    """
    if not source_plan_path:
        return ""
    project_root = infer_project_root(task_root)
    try:
        return str(Path(source_plan_path).relative_to(project_root))
    except ValueError:
        return source_plan_path
