"""Read-side state accessors over okstra's on-disk authority files.

Skills, okstra.sh, and okstra-ctl all read the same files through this module
so identity / discovery values are derived from disk on every call instead of
being passed via process environment. This keeps concurrent claude-code skill
invocations isolated — each call reads the authoritative state at the moment
it runs and never sees stale snapshots inherited from a parent process.
Some accessors also run best-effort reconciliation before returning derived
phase pointers, so stale catalog entries can be healed from authoritative
task artifacts.

권위 파일 매핑 (okstra root = <PROJECT_ROOT>/<OKSTRA_DIR_NAME>):
  - <okstra root>/project.json
      -> {projectId, projectRoot, ...}
  - <okstra root>/discovery/task-catalog.json
      -> tasks[]: 각 task 의 stable identity 와 phase pointer
  - <okstra root>/discovery/latest-task.json
      -> 가장 최근에 prepare/run 된 task 의 포인터
  - <task-root>/task-manifest.json
      -> 한 task 의 manifest (workflow.* phase 정보 포함)
"""
from __future__ import annotations

import json
import re
import subprocess
from pathlib import Path
from typing import Optional

from .dirs import (
    DISCOVERY_RELATIVE,
    LATEST_TASK_RELATIVE,
    TASK_CATALOG_RELATIVE,
    TASK_MANIFEST_FILENAME,
    TASKS_RELATIVE,
)


class StateError(Exception):
    """state file 읽기/파싱 실패 — 호출자가 surface 해야 할 오류."""

    def __init__(self, message: str, *, stage: Optional[str] = None):
        super().__init__(message)
        self.stage = stage


def _load_json(path: Path) -> Optional[dict]:
    if not path.is_file():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise StateError(f"failed to parse {path}: {exc}") from exc


def _next_phase():
    """`okstra_ctl.next_phase` 를 호출 시점에 가져온다.

    모듈 최상단에서 import 하면 순환이 된다 — `okstra_ctl/__init__.py` 가
    `.ids` 를 거쳐 이 모듈의 `slugify` 를 도로 import 하기 때문에, 여기서
    okstra_ctl 을 먼저 끌어오면 아직 정의되지 않은 이름을 찾다가 ImportError 로
    죽는다. 같은 파일의 `_reconcile_task_root_best_effort` 도 같은 이유로 지연
    import 를 쓴다.
    """
    from okstra_ctl import next_phase

    return next_phase


def slugify(value: str) -> str:
    """task-group / task-id segment 를 디스크 경로용 slug 로 정규화한다.

    소문자화 + 알파넘 외 문자 → '-' 로 collapse.
    okstra.sh 의 path-resolve.sh slug 규칙과 일치해야 한다.
    """
    value = (value or "").lower()
    value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
    return value


def read_task_catalog(project_root: Path) -> list[dict]:
    """project-level task-catalog.json 을 list[dict] 로 돌려준다.

    파일이 없으면 빈 리스트(아직 task 가 prepare 되지 않은 신규 프로젝트).
    파싱 실패 시 StateError.
    """
    catalog = _load_json(Path(project_root) / TASK_CATALOG_RELATIVE)
    if not isinstance(catalog, dict):
        return []
    tasks = catalog.get("tasks")
    return [t for t in tasks if isinstance(t, dict)] if isinstance(tasks, list) else []


def read_latest_task(project_root: Path) -> Optional[dict]:
    """latest-task.json 을 dict 로. 파일 없으면 None."""
    return _load_json(Path(project_root) / LATEST_TASK_RELATIVE)


def read_task_manifest(task_root: Path) -> Optional[dict]:
    """<task-root>/task-manifest.json 을 dict 로. 파일 없으면 None.

    구형 문자열 포인터는 반환값에서 승격한다. 읽는 곳이 여럿이므로 승격을 seam
    하나에 두지 않으면 형태가 갈라진다 (ADR-0004).

    **디스크는 건드리지 않는다.** 승격한 값을 여기서 되적으면 lock 없는
    read-modify-write 가 되어, 파싱과 쓰기 사이에 다른 프로세스가 적은
    `currentPhase` · `phaseStates` · `latestReportRecordPath` 를 되돌린다
    (`os.replace` 는 찢어진 읽기만 막고 갱신 유실은 못 막는다). 이 분기는
    디스크 포인터가 아직 문자열일 때 — 즉 업그레이드 직후 진행 중인 task 가
    살아 있을 때 — 만 발화하므로 되돌릴 수 있는 값이 바로 phase 진행 상태다.
    그래서 디스크 형태는 다음 writer 가 손댈 때까지 구형으로 남는다. 모든
    읽기가 여기서 승격하므로 소비자는 그 차이를 보지 않는다.
    """
    manifest = _load_json(Path(task_root) / TASK_MANIFEST_FILENAME)
    if not isinstance(manifest, dict):
        return manifest
    workflow = manifest.get("workflow")
    if not isinstance(workflow, dict):
        return manifest
    workflow["nextRecommendedPhase"] = _next_phase().promote(
        workflow.get("nextRecommendedPhase")
    )
    # routingStatus 는 포인터 status 로 흡수됐다 — 승격과 같은 자리에서 지운다.
    workflow.pop("routingStatus", None)
    return manifest


def read_task_key(task_root: Path) -> str:
    """manifest 의 taskKey. 파일이 없거나 깨졌으면 "".

    리포트 렌더러(recap·time-report·error-report)는 taskKey 를 헤더 라벨로만 쓰므로
    manifest 가 없어도 실패하지 않아야 한다 — 세 모듈이 각자 갖고 있던 관용 리더를
    여기로 모은다.
    """
    try:
        manifest = read_task_manifest(Path(task_root))
    except (StateError, OSError):
        return ""
    if not isinstance(manifest, dict):
        return ""
    value = manifest.get("taskKey")
    return value if isinstance(value, str) else ""


def _reconcile_task_root_best_effort(project_root: Path, task_root: Path) -> None:
    try:
        from okstra_ctl.implementation_outcome import reconcile_implementation_outcome

        reconcile_implementation_outcome(project_root, task_root)
    except Exception:
        return


_DERIVED_STATUS_STRING_FIELDS = (
    "workStatus",
    "currentStatus",
    "latestRunStatus",
    "latestReportRecordPath",
)


def derived_task_status(manifest: dict) -> dict:
    """Task status a caller acts on, projected off the raw manifest.

    Both the task list rows and the read-side snapshot project through here so
    the two views cannot disagree about whether a task is finished.
    """
    out: dict = {}
    for field in _DERIVED_STATUS_STRING_FIELDS:
        value = manifest.get(field)
        if isinstance(value, str):
            out[field] = value
    phase_outcome = manifest.get("phaseOutcome")
    if isinstance(phase_outcome, dict):
        out["phaseOutcome"] = phase_outcome
    return out


def _entry_with_manifest_state(entry: dict, task_root: Path) -> dict:
    out = {**entry}
    manifest = read_task_manifest(task_root) or {}
    workflow = manifest.get("workflow") if isinstance(manifest.get("workflow"), dict) else {}
    for source, target in (
        ("currentPhase", "currentPhase"),
        ("currentPhaseState", "currentPhaseState"),
        ("lastCompletedPhase", "lastCompletedPhase"),
    ):
        value = workflow.get(source)
        if isinstance(value, str):
            out[target] = value
    # 포인터는 구조체라 위 루프의 str 검사에 걸리지 않는다 — 그 검사에 남겨두면
    # 값이 예외도 로그도 없이 버려진다. 매니페스트가 값을 가질 때만 카탈로그
    # 항목을 덮는 규칙은 위 세 필드와 같게 유지하고, 덮지 않을 때는 카탈로그에
    # 남아 있던 구형 문자열을 승격해 내보낸다.
    raw = workflow.get("nextRecommendedPhase")
    if raw is None:
        raw = entry.get("nextRecommendedPhase")
    out["nextRecommendedPhase"] = _next_phase().promote(raw)
    out.update(derived_task_status(manifest))
    return out


def parse_task_key(task_key: str) -> tuple[str, str, str]:
    """`project-id:task-group:task-id` 를 3-tuple 로 분해.

    형식 불일치 시 StateError.
    """
    parts = (task_key or "").split(":")
    if len(parts) != 3 or not all(parts):
        raise StateError(
            f"invalid task-key: {task_key!r} (expected project-id:task-group:task-id)")
    return parts[0], parts[1], parts[2]


def find_task_root(project_root: Path, task_key: str) -> Optional[Path]:
    """task-key 로 task root 디렉터리를 해석한다.

    해석 우선순위:
      1. task-catalog.json 의 같은 taskKey 항목의 taskRootPath
      2. <PROJECT_ROOT>/<OKSTRA_DIR_NAME>/tasks/<slug-group>/<slug-id>/

    어느 쪽도 디렉터리로 존재하지 않으면 None.
    """
    project_root = Path(project_root)
    _, task_group, task_id = parse_task_key(task_key)
    requested_ci = task_key.lower()

    for entry in read_task_catalog(project_root):
        entry_key = entry.get("taskKey") or ""
        if not isinstance(entry_key, str) or entry_key.lower() != requested_ci:
            continue
        rel = entry.get("taskRootPath") or entry.get("taskRoot") or ""
        if isinstance(rel, str) and rel:
            abs_path = project_root / rel if not Path(rel).is_absolute() else Path(rel)
            if abs_path.is_dir():
                _reconcile_task_root_best_effort(project_root, abs_path)
                return abs_path

    slug_path = project_root / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
    if slug_path.is_dir():
        _reconcile_task_root_best_effort(project_root, slug_path)
        return slug_path
    return None


def resolve_task_id(
    project_root: Path,
    task_id: str,
    *,
    task_group: Optional[str] = None,
) -> list[dict]:
    """bare task-id(옵션 task-group)로 catalog 후보 entry 목록을 돌려준다.

    full task-key 해석(find_task_root)이 다루지 않는 한 겹: skill markdown 이
    복제하던 "catalog 를 case-insensitive 로 훑어 taskId(필요시 taskGroup)가
    일치하는 entry 를 모은다" 를 단일 SSOT 로 노출한다. 매칭은 find_task_root
    의 taskKey 비교와 동일한 .lower() 케이스무시 규칙. entry dict 를 그대로
    반환하므로 호출자가 분기·경로조립에 바로 쓴다(0개=없음, 1개=확정, N개=모호).
    """
    wanted_id = (task_id or "").strip().lower()
    if not wanted_id:
        return []
    wanted_group = task_group.strip().lower() if task_group else None
    matches: list[dict] = []
    for entry in read_task_catalog(Path(project_root)):
        entry_id = entry.get("taskId")
        if not isinstance(entry_id, str) or entry_id.lower() != wanted_id:
            continue
        if wanted_group is not None:
            entry_group = entry.get("taskGroup")
            if not isinstance(entry_group, str) or entry_group.lower() != wanted_group:
                continue
        matches.append(entry)
    return matches


def resolve_task_reference(
    project_root: Path,
    token: str,
    *,
    task_group: Optional[str] = None,
) -> list[dict]:
    """bare token 을 task-id 또는 task-group 으로 매칭한 후보 목록을 돌려준다.

    resolve_task_id 는 token 을 task-id 로만 봤다. 여기서는 같은 token 을 (a) task-id
    (선택 task_group 스코프 유지) 와 (b) task-group 두 축으로 매칭해 합집합을 낸다.
    task-group 매칭은 그 group 의 모든 멤버 task 로 펼쳐진다. 두 축 모두에 걸리면
    양쪽 후보를 모두 담는다(taskKey 로 dedup). 각 entry 에 어느 축으로 걸렸는지
    `_matchedVia`("taskId" | "taskGroup") 를 덧붙여 호출자가 picker 에서 구분한다.
    """
    wanted = (token or "").strip().lower()
    if not wanted:
        return []
    out: list[dict] = []
    seen: set[str] = set()

    def _add(entry: dict, matched_via: str) -> None:
        key = entry.get("taskKey")
        if not isinstance(key, str) or not key or key in seen:
            return
        seen.add(key)
        out.append({**entry, "_matchedVia": matched_via})

    for entry in resolve_task_id(project_root, token, task_group=task_group):
        _add(entry, "taskId")
    for entry in read_task_catalog(Path(project_root)):
        entry_group = entry.get("taskGroup")
        if isinstance(entry_group, str) and entry_group.lower() == wanted:
            _add(entry, "taskGroup")
    return out


def list_project_tasks(
    project_root: Path,
    *,
    task_type: Optional[str] = None,
    latest_run_status: Optional[str] = None,
    task_group: Optional[str] = None,
    limit: Optional[int] = None,
) -> list[dict]:
    """skill UI 에 보여줄 task 후보 목록을 돌려준다.

    각 항목은 task-catalog.json 의 entry 그대로 + 디스크 존재 여부 확인.
    존재하지 않는 task root 는 skip(stale catalog 항목)한다. 선택 필터(taskType /
    latestRunStatus / taskGroup)는 AND 결합, limit 은 카탈로그 순서(updatedAt desc
    로 저장됨)에서 앞쪽 N개. 모든 필터 인자가 기본값이면 기존 전체 목록과 동일.
    """
    project_root = Path(project_root)
    out = []
    for entry in read_task_catalog(project_root):
        entry_key = entry.get("taskKey") or ""
        if not isinstance(entry_key, str) or not entry_key:
            continue
        if task_type and entry.get("taskType") != task_type:
            continue
        if latest_run_status and entry.get("latestRunStatus") != latest_run_status:
            continue
        if task_group and entry.get("taskGroup") != task_group:
            continue
        root = find_task_root(project_root, entry_key)
        if root is None:
            continue
        reconciled_entry = _entry_with_manifest_state(entry, root)
        out.append({**reconciled_entry, "_resolvedTaskRoot": str(root)})
    if limit is not None and limit >= 0:
        out = out[:limit]
    return out


def resolve_task_identity(project_root: Path, task_key: str) -> dict:
    """task-key 한 줄로 manifest + 경로를 한 번에 반환.

    skill / okstra.sh / okstra-ctl 모두가 이 함수를 호출해 같은 dict 를 받는다.
    StateError: task root 또는 manifest 없음.
    """
    task_root = find_task_root(project_root, task_key)
    if task_root is None:
        raise StateError(
            f"task root not found for {task_key!r}",
            stage="task_root_missing",
        )
    manifest = read_task_manifest(task_root)
    if manifest is None:
        raise StateError(
            f"task-manifest.json missing under {task_root}",
            stage="manifest_missing",
        )
    workflow = manifest.get("workflow") or {}
    if not isinstance(workflow, dict):
        workflow = {}
    project_id, task_group, task_id = parse_task_key(task_key)
    return {
        "projectId": project_id,
        "projectRoot": str(project_root),
        "taskGroup": task_group,
        "taskId": task_id,
        "taskKey": task_key,
        "taskRoot": str(task_root),
        "taskType": manifest.get("taskType") or "",
        "currentPhase": workflow.get("currentPhase") or "",
        "currentPhaseState": workflow.get("currentPhaseState") or "",
        "nextRecommendedPhase": _next_phase().promote(
            workflow.get("nextRecommendedPhase")
        ),
        "lastCompletedPhase": workflow.get("lastCompletedPhase") or "",
        "awaitingApproval": bool(workflow.get("awaitingApproval", False)),
        "taskBriefPath": manifest.get("taskBriefPath") or "",
        "manifest": manifest,
    }


def task_read_side_snapshot(project_root: Path, task_key: str) -> dict:
    """task-show / inspect 용 curated task 상태 view 를 반환한다.

    resolve_task_identity 가 내부 raw manifest 를 들고 있지만, 이 accessor 는
    caller 에게 manifest shape 를 노출하지 않는 read-side 계약이다.
    """
    identity = resolve_task_identity(project_root, task_key)
    manifest = identity["manifest"]
    workflow = manifest.get("workflow")
    if not isinstance(workflow, dict):
        workflow = {}
    return {
        "projectRoot": identity["projectRoot"],
        "taskKey": manifest.get("taskKey"),
        "taskType": manifest.get("taskType"),
        "taskRoot": identity["taskRoot"],
        "taskBriefPath": manifest.get("taskBriefPath"),
        "workflow": {
            "currentPhase": workflow.get("currentPhase"),
            "currentPhaseState": workflow.get("currentPhaseState"),
            "lastCompletedPhase": workflow.get("lastCompletedPhase"),
            "nextRecommendedPhase": _next_phase().promote(
                workflow.get("nextRecommendedPhase")
            ),
            "phaseStates": workflow.get("phaseStates"),
        },
        "status": derived_task_status(manifest),
        "resultContract": manifest.get("resultContract"),
        "artifacts": manifest.get("artifacts"),
        "modelAssignments": manifest.get("modelAssignments"),
        "latestRunPath": manifest.get("latestRunPath"),
    }


def stage_map_read_side_snapshot(project_root: Path, task_key: str) -> dict:
    """stage-map inspect 용 read-side view: Stage Map + done 처리된 stage 번호.

    Stage Map 파싱과 planning run-root 위치를 caller 에게 노출하지 않는다 —
    Node inspect 가 okstra_ctl 의 private 심볼을 import 하거나 `runs/` 경로를
    직접 조립하지 않도록 하는 어댑터다.
    """
    from okstra_ctl.consumers import read_stage_consumer_state
    from okstra_ctl.paths import RunRef
    from okstra_ctl.stage_map import (
        PlanningDetail,
        StageMapError,
        load_planning_detail,
        load_task_stage_map,
        merge_planning_detail,
    )

    identity = resolve_task_identity(project_root, task_key)
    task_root = Path(identity["taskRoot"])
    try:
        stage_snapshot = load_task_stage_map(task_root, identity["manifest"])
        detail = (
            load_planning_detail(Path(stage_snapshot.source_plan_path))
            if stage_snapshot.source_plan_path
            else PlanningDetail({}, {})
        )
    except StageMapError as exc:
        raise StateError(str(exc), stage=exc.code) from exc
    plan_run_root = RunRef.from_task_root(
        task_root, "implementation-planning"
    ).run_dir
    done: list[int] = []
    if plan_run_root.is_dir():
        done = sorted(
            read_stage_consumer_state(
                plan_run_root, recover_from_carry=True
            ).done_stages
        )
    return {
        "taskKey": identity["taskKey"],
        "taskRoot": identity["taskRoot"],
        "state": stage_snapshot.state,
        "sourcePlanPath": stage_snapshot.source_plan_path,
        "stages": merge_planning_detail(stage_snapshot.stages, detail),
        "doneStages": done,
        "planning": detail.task_narratives,
    }


def code_review_target_snapshot(
    project_root: Path, task_key: str, stage: int
) -> dict:
    """stage 코드 리뷰가 무엇을 읽고 결과를 어디에 쓰는지 해소한다.

    stage_map_read_side_snapshot 과 같은 결: caller 는 registry 행도, run-root
    레이아웃도, stage base 규칙도 보지 않는다. stage_targets 의
    StageTargetError 도 StateError 로 바꿔 던진다 — caller 가 그 예외를 잡으려고
    okstra_ctl 을 import 하지 않도록.

    base 는 stage worktree 를 만들 때 registry 행에 적힌 `base_ref` 다 — 그
    stage 가 실제로 갈라져 나온 커밋. 리뷰 시점에 규칙으로 다시 계산하면
    표류한다: 다중의존 stage 는 오늘의 task-key worktree HEAD 를 받게 되어
    whole-task final-verification 이 stage 들을 병합한 뒤에는 base..head 구간이
    비거나 뒤집히고, 단일의존 stage 는 선행 stage 가 재실행되면 어긋난다.
    """
    from okstra_ctl import code_review_paths, worktree_registry
    from okstra_ctl.stage_map import StageMapError, load_task_stage_map

    identity = resolve_task_identity(project_root, task_key)
    task_root = Path(identity["taskRoot"])
    try:
        stage_snapshot = load_task_stage_map(task_root, identity["manifest"])
    except StageMapError as exc:
        raise StateError(str(exc), stage=exc.code) from exc
    if stage_snapshot.state != "ready":
        raise StateError(
            "implementation-planning Stage Map is missing",
            stage="missing",
        )
    stages = stage_snapshot.stages
    selected = next((s for s in stages if s["stage_number"] == stage), None)
    if selected is None:
        raise StateError(
            f"stage {stage} is not in the Stage Map for {task_key}",
            stage="stage_missing",
        )

    coords = (identity["projectId"], identity["taskGroup"], identity["taskId"])
    stage_row = worktree_registry.get_stage_row(*coords, stage) or {}
    worktree_view = _stage_worktree_view(project_root, stage_row, stage)
    base_commit = stage_row.get("base_ref") or _legacy_stage_base_commit(
        identity, stages, selected
    )
    review_path, round_no = code_review_paths.next_stage_review(
        code_review_paths.stage_review_dir(
            project_root, identity["taskGroup"], identity["taskId"]
        ),
        stage,
    )
    return {
        "mode": "stage",
        "taskKey": identity["taskKey"],
        "taskRoot": identity["taskRoot"],
        "stage": stage,
        **worktree_view,
        "baseCommit": base_commit,
        "reviewPath": str(review_path),
        "round": round_no,
    }


def _legacy_stage_base_commit(
    identity: dict, stages: list[dict], selected: dict
) -> str:
    """`base_ref` 를 남기지 않은 옛 registry 행의 base 를 규칙에서 되살린다.

    provision 이 base_ref 를 기록하기 전에 만들어진 stage 행에만 쓰인다. 규칙
    자체는 stage_targets 가 소유하며 여기서 다시 유도하지 않는다.
    """
    from okstra_ctl import stage_targets, worktree_registry
    from okstra_ctl.paths import RunRef

    coords = (identity["projectId"], identity["taskGroup"], identity["taskId"])
    plan_run_root = RunRef.from_task_root(
        Path(identity["taskRoot"]), "implementation-planning"
    ).run_dir
    lifecycle = stage_targets.read_stage_lifecycle_snapshot(
        stages, plan_run_root, recover_from_carry=True
    )
    # The anchor and the multi-dep candidate base must come from the task-key
    # worktree HEAD, where stage work accumulates — not from the invocation
    # cwd, which may sit on an older or unrelated commit.
    task_entry = worktree_registry.lookup(*coords)
    task_worktree = Path(
        (task_entry.worktree_path if task_entry else "") or identity["projectRoot"]
    )
    anchor = worktree_registry.get_implementation_base(*coords) or ""
    try:
        return stage_targets.resolve_stage_base_commit(
            selected,
            lifecycle.done_rows,
            anchor_base_commit=anchor,
            candidate_base=_git_out(task_worktree, "rev-parse", "HEAD"),
            project_root=task_worktree,
            plan_run_root=plan_run_root,
        )
    except stage_targets.StageTargetError as exc:
        raise StateError(
            f"stage {selected['stage_number']} has no recorded base_ref and its "
            f"base could not be derived: {exc}",
            stage="stage_base_unresolved",
        ) from exc


def _stage_worktree_view(project_root: Path, stage_row: dict, stage: int) -> dict:
    """한 stage 의 worktree 경로 / 브랜치 / head 커밋.

    철거된 stage worktree 는 오류가 아니다: whole-task final-verification 이
    디렉터리를 지워도 브랜치는 남고, 리뷰가 필요한 커밋은 그 브랜치에 그대로
    있다.
    """
    branch = stage_row.get("branch") or ""
    if not branch:
        raise StateError(
            f"stage {stage} has no registry branch — review it in branch mode",
            stage="stage_branch_missing",
        )
    worktree_path = ""
    if stage_row.get("status") == "active" and stage_row.get("worktree_path"):
        candidate = Path(stage_row["worktree_path"])
        if candidate.is_dir():
            worktree_path = str(candidate)
    head_source = Path(worktree_path) if worktree_path else Path(project_root)
    head_ref = "HEAD" if worktree_path else branch
    head_commit = _git_out(head_source, "rev-parse", head_ref)
    if not head_commit:
        raise StateError(
            f"stage {stage} head could not be resolved: branch {branch!r} is "
            f"not readable from {head_source}",
            stage="stage_head_unresolved",
        )
    return {
        "worktreePath": worktree_path,
        "branch": branch,
        "headCommit": head_commit,
    }


def _git_out(repo: Path, *args: str) -> str:
    """git 호출의 stdout. 실패는 빈 문자열 — 호출자가 부재를 판단한다."""
    result = subprocess.run(
        ["git", "-C", str(repo), *args],
        capture_output=True,
        text=True,
        check=False,
    )
    return result.stdout.strip() if result.returncode == 0 else ""
