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

Skills and okstra.sh 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,
)
# 포인터 승격은 순수 값 연산이라 하위 계층이 소유한다. 예전에는 함수 본문에서
# okstra_ctl.next_phase 를 지연 import 했다 — okstra_ctl 패키지 __init__ 이
# 이 모듈의 slugify 를 도로 끌어와 순환이 됐기 때문이다.
from .phase_pointer import promote as promote_phase_pointer
# slug 규칙은 leaf 가 소유한다 — okstra_ctl 이 이 모듈을 거치지 않고 규칙만
# 가져갈 수 있어야 패키지 __init__ 을 통한 순환이 생기지 않는다.
from .slug import slugify  # noqa: F401 — 재노출: 기존 호출부가 state 에서 끌어온다


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 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"] = promote_phase_pointer(
        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:
    """카탈로그가 가리키는 task-root 를 정본 산출물로 치유한다(best-effort).

    import 를 함수 본문에 두는 이유는 순환이 아니다 — 순환은 없다. 하위 계층인
    okstra_project 가 상위 계층 okstra_ctl 을 import 시점에 끌어오지 않게 하려는
    것이다. 이 치유는 읽기측 해석의 부가 동작이므로, 실패해도 해석 자체는
    계속돼야 한다.
    """
    try:
        from okstra_ctl.implementation_outcome import reconcile_implementation_outcome

        reconcile_implementation_outcome(project_root, task_root)
    except Exception:
        # expected-miss: 치유는 부가 동작이다. 여기서 던지면 단순 조회가
        # 실패한다. 무엇이 치유되지 않았는지는 다음 run 의 정합성 검사가 잡는다.
        return


_DERIVED_STATUS_STRING_FIELDS = (
    "workStatus",
    "currentStatus",
    "latestRunStatus",
    "latestReportRecordPath",
    "latestWorkRecordPath",
    "workStatusNote",
    "workStatusUpdatedAt",
)


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"] = promote_phase_pointer(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 모두가 이 함수를 호출해 같은 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": promote_phase_pointer(
            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": promote_phase_pointer(
                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"),
        "latestWorkRecordPath": manifest.get("latestWorkRecordPath"),
    }
