# -*- coding: utf-8 -*-
"""dispatch.terminal_artifact_index — task-2778_a Terminal Artifact Surfacing (surfacing layer only).

worktree-local 에 존재하는 terminal artifact(report, .done, callback envelope)를
ANU 가 canonical `/home/jay/workspace` 에서 authoritative 하게 볼 수 있도록
**canonical manifest(index)** 로 surface 한다.

이 모듈의 역할:
  - 읽기 + manifest write 만. worktree 의 report/.done/envelope 는 읽기 전용.
  - callback fire 0, cron 0, subprocess 0, network 0, cokacdir 호출 0.
  - driver 실행 0, real fire 0, flag ON 0.

canonical index 경로 규약:
  ``memory/events/terminal_artifact_index/<task_id>.json`` (canonical_root 기준 상대).

★ raw key 0: ANU key literal 하드코딩 절대 금지.
"""
from __future__ import annotations

import datetime
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional

# ── 상태 enum 상수 (7종) ─────────────────────────────────────────────────────
STATUS_WORKTREE_LOCAL_PRESENT = "WORKTREE_LOCAL_TERMINAL_ARTIFACT_PRESENT"
STATUS_CANONICAL_MISSING = "CANONICAL_TERMINAL_ARTIFACT_MISSING"
STATUS_SURFACED_TO_CANONICAL = "TERMINAL_ARTIFACT_SURFACED_TO_CANONICAL"
STATUS_CALLBACK_ENVELOPE_ONLY = "CALLBACK_ENVELOPE_ONLY"
STATUS_CALLBACK_ENVELOPE_DANGLING = "CALLBACK_ENVELOPE_DANGLING_TARGETS"
STATUS_ANU_PICKUP_FROM_WORKTREE_REQUIRED = "ANU_PICKUP_FROM_WORKTREE_REQUIRED"
STATUS_TERMINAL_CALLBACK_MISSING = "TERMINAL_CALLBACK_MISSING"
STATUS_PUSH_ATTEMPT_WITHOUT_TERMINAL = "PUSH_ATTEMPT_WITHOUT_TERMINAL_MARKER"

# canonical index 디렉토리 상대 경로 (canonical_root 기준)
CANONICAL_INDEX_DIR_REL = "memory/events/terminal_artifact_index"

# 스키마 버전
SCHEMA_VERSION = "2778a-1"

# task_id 허용 문자 whitelist
_TASK_ID_SAFE_RE = re.compile(r"^[A-Za-z0-9._+-]+$")


def _validate_task_id_safe(task_id: str) -> bool:
    """task_id 가 경로 구성에 안전한지 검증.

    거부: 빈 문자열 / path separator(`/`, `\\`) / `..` traversal sequence /
          absolute path / whitelist(`[A-Za-z0-9._+-]`) 밖 문자.
    """
    if not (isinstance(task_id, str) and task_id):
        return False
    if "/" in task_id or "\\" in task_id:
        return False
    if os.sep in task_id or (os.altsep and os.altsep in task_id):
        return False
    if ".." in task_id:
        return False
    if os.path.isabs(task_id):
        return False
    if not _TASK_ID_SAFE_RE.match(task_id):
        return False
    return True


def _rel_or_abs(path: str, base: str) -> str:
    """path 를 base 기준 상대경로로 변환. 변환 불가 시 path 그대로 반환.

    절대경로 저장을 피하기 위한 헬퍼. side-effect 0.
    """
    try:
        return os.path.relpath(path, base)
    except (ValueError, OSError):
        # 다른 드라이브 등 상대화 불가 — 원본 반환
        return path


def _utcnow_iso() -> str:
    """ISO8601 UTC 타임스탬프 반환 (마이크로초 포함). timezone-aware."""
    return datetime.datetime.now(datetime.timezone.utc).strftime(
        "%Y-%m-%dT%H:%M:%S.%f"
    ) + "Z"


# ── TerminalArtifactManifest ────────────────────────────────────────────────
@dataclass
class TerminalArtifactManifest:
    """terminal artifact surfacing manifest.

    - task_id: 대상 task ID.
    - branch: git branch (제공된 경우).
    - head: git HEAD SHA (제공된 경우).
    - worktree_path: worktree root 경로 (canonical_root 기준 상대, 불가 시 원본).
    - report_path: 발견된 report 파일 상대경로 또는 None.
    - done_path: 발견된 .done 파일 상대경로 또는 None.
    - envelope_path: 발견된 callback envelope 상대경로 또는 None.
    - status: primary 상태 (STATUS_* 상수 중 하나).
    - statuses: 적용 가능한 모든 상태 마커 리스트.
    - schema_version: 스키마 버전.
    - generated_at: ISO8601 UTC 생성 시각.
    """

    task_id: str
    branch: Optional[str]
    head: Optional[str]
    worktree_path: str
    report_path: Optional[str]
    done_path: Optional[str]
    envelope_path: Optional[str]
    status: str
    statuses: List[str] = field(default_factory=list)
    schema_version: str = SCHEMA_VERSION
    generated_at: str = field(default_factory=_utcnow_iso)

    def to_dict(self) -> dict:
        """manifest 를 JSON 직렬화 가능한 dict 로 반환. raw key 0."""
        return {
            "task_id": self.task_id,
            "branch": self.branch,
            "head": self.head,
            "worktree_path": self.worktree_path,
            "report_path": self.report_path,
            "done_path": self.done_path,
            "envelope_path": self.envelope_path,
            "status": self.status,
            "statuses": list(self.statuses),
            "schema_version": self.schema_version,
            "generated_at": self.generated_at,
        }


def _parse_envelope_targets(envelope_abs: str) -> list:
    """envelope JSON 파일을 파싱하여 타겟 경로 목록을 반환.

    fail-open: 파싱 실패 시 빈 리스트 반환. result_path/report_path/done_path 키 추출.
    side-effect 0 (read-only).
    """
    try:
        with open(envelope_abs, encoding="utf-8") as fh:
            data = json.load(fh)
        if not isinstance(data, dict):
            return []
        targets = []
        for key in ("result_path", "report_path", "done_path"):
            val = data.get(key)
            if isinstance(val, str) and val:
                targets.append(val)
        return targets
    except (OSError, ValueError, TypeError):
        # fail-open: 파싱 실패 무시, 빈 타겟 취급
        return []


def _is_under(base: str, candidate) -> bool:
    """candidate 가 base root 자신이거나 그 하위 경로인지 검사.

    문자열 prefix 가 아니라 **resolved path containment** 로 판정한다.
    (sibling-prefix 우회 방지: base ``/tmp/root`` vs candidate ``/tmp/root_evil`` 은 under 아님)
    resolve 실패 시 보수적으로 False(거부).
    """
    try:
        base_resolved = Path(base).resolve(strict=False)
        cand_resolved = Path(candidate).resolve(strict=False)
    except (OSError, ValueError, RuntimeError):
        return False
    return cand_resolved == base_resolved or base_resolved in cand_resolved.parents


def _safe_contained_target(target: str, base: str):
    """relative target 을 base 하위로만 해석. 경계 밖이면 None(거부).

    - target 이 absolute path 이면 거부(None) — external existence check 금지.
    - ``(base / target).resolve(strict=False)`` 결과가 base 밖이면 거부(None).
    - symlink 가 base 밖으로 resolve 되면 거부(None).
    - parse/resolve 실패 시 거부(None) — traceback 없이.
    정상일 때만 resolved Path 를 반환한다(호출측이 .exists() 수행).
    """
    if not (isinstance(target, str) and target):
        return None
    if os.path.isabs(target):
        return None
    try:
        candidate = (Path(base) / target).resolve(strict=False)
    except (OSError, ValueError, RuntimeError):
        return None
    if not _is_under(base, candidate):
        return None
    return candidate


def _safe_artifact_path(base: str, rel: str):
    """외부 입력성 상대경로 rel 을 base 하위로만 안전 해석.

    모든 external path input(report_rel/done_rel/envelope_rel/envelope targets)이
    이 helper 를 통과한다. 경계 밖이면 None(거부) — 호출측은 .exists() 호출 금지.

    - rel 이 absolute path 이면 거부(None).
    - ``(base / rel).resolve(strict=False)`` 가 base 밖이면 거부(None) (resolved containment, 문자열 prefix 아님).
    - symlink 가 base 밖으로 resolve 되면 거부(None).
    - parse/resolve 실패 시 거부(None), traceback 없이.
    정상일 때만 resolved Path 반환.
    """
    return _safe_contained_target(rel, base)


def _resolve_target_presence(targets: list, canonical_root: str, worktree_root: str):
    """envelope 타겟들의 canonical/worktree 존재 여부를 확인.

    반환: (in_canonical: bool, in_worktree: bool)

    보안: target 은 canonical_root 또는 worktree_root **밖으로 해석되면 거부**한다.
      - absolute target → 거부 (external existence check 안 함)
      - ``../../etc/passwd`` 같은 traversal → resolved containment 검사로 거부
      - 거부된 target 은 외부 파일 존재 확인을 수행하지 않는다.
    경계 안으로 안전하게 해석된 경우에만 .exists() 로 존재를 확인한다. side-effect 0.
    """
    in_canonical = False
    in_worktree = False

    for target in targets:
        if not (isinstance(target, str) and target):
            continue
        # absolute target 은 즉시 거부 — external existence check 금지
        if os.path.isabs(target):
            continue
        # canonical_root 경계 안으로 안전 해석된 경우에만 존재 확인
        cand_c = _safe_contained_target(target, canonical_root)
        if cand_c is not None and cand_c.exists():
            in_canonical = True
        # worktree_root 경계 안으로 안전 해석된 경우에만 존재 확인
        cand_w = _safe_contained_target(target, worktree_root)
        if cand_w is not None and cand_w.exists():
            in_worktree = True

    return in_canonical, in_worktree


# ── build_manifest ──────────────────────────────────────────────────────────
def build_manifest(
    task_id: str,
    *,
    canonical_root: str,
    worktree_root: str,
    branch: Optional[str] = None,
    head: Optional[str] = None,
    report_rel: Optional[str] = None,
    done_rel: Optional[str] = None,
    envelope_rel: Optional[str] = None,
) -> TerminalArtifactManifest:
    """terminal artifact 존재 확인 후 manifest 객체 반환.

    읽기 전용 — 디스크 write 0.
    worktree 와 canonical 양쪽에서 artifact 존재만 확인하고 manifest 반환.

    각 후보 상대경로 결정:
      - report_rel 미지정 시 기본: ``memory/reports/<task_id>.md``
      - done_rel 미지정 시 기본: ``memory/events/<task_id>.done``
      - envelope_rel 미지정 시 기본: ``memory/events/anu_callback/<task_id>-normal-completion.json``

    fail-open: 예외 발생 시 STATUS_TERMINAL_CALLBACK_MISSING 가진 manifest 반환.
    """
    # task_id path traversal 방어
    if not _validate_task_id_safe(task_id):
        _ts = _utcnow_iso()
        return TerminalArtifactManifest(
            task_id=task_id,
            branch=branch,
            head=head,
            worktree_path=worktree_root,
            report_path=None,
            done_path=None,
            envelope_path=None,
            status=STATUS_TERMINAL_CALLBACK_MISSING,
            statuses=[STATUS_TERMINAL_CALLBACK_MISSING],
            generated_at=_ts,
        )

    try:
        # ── 후보 상대경로 결정 ──────────────────────────────────────────────
        _report_rel = report_rel if report_rel is not None else os.path.join(
            "memory", "reports", f"{task_id}.md"
        )
        _done_rel = done_rel if done_rel is not None else os.path.join(
            "memory", "events", f"{task_id}.done"
        )
        _envelope_rel = envelope_rel if envelope_rel is not None else os.path.join(
            "memory", "events", "anu_callback", f"{task_id}-normal-completion.json"
        )

        # ── artifact 존재 확인 (양쪽 root) ─────────────────────────────────
        # 외부 입력성 경로는 전부 _safe_artifact_path 통과 (resolved containment).
        # 경계 밖/absolute/traversal 은 None → .exists() 호출 금지(거부 = absent 취급).
        worktree_report_p = _safe_artifact_path(worktree_root, _report_rel)
        canonical_report_p = _safe_artifact_path(canonical_root, _report_rel)
        worktree_report = worktree_report_p is not None and worktree_report_p.exists()
        canonical_report = canonical_report_p is not None and canonical_report_p.exists()

        worktree_done_p = _safe_artifact_path(worktree_root, _done_rel)
        canonical_done_p = _safe_artifact_path(canonical_root, _done_rel)
        worktree_done = worktree_done_p is not None and worktree_done_p.exists()
        canonical_done = canonical_done_p is not None and canonical_done_p.exists()

        # envelope: canonical 또는 worktree 어느 쪽이든 존재 확인
        canonical_envelope_p = _safe_artifact_path(canonical_root, _envelope_rel)
        worktree_envelope_p = _safe_artifact_path(worktree_root, _envelope_rel)
        envelope_in_canonical = canonical_envelope_p is not None and canonical_envelope_p.exists()
        envelope_in_worktree = worktree_envelope_p is not None and worktree_envelope_p.exists()
        envelope_present = envelope_in_canonical or envelope_in_worktree

        # ── envelope 파싱 (dangling 오판 방지) ─────────────────────────────
        # envelope 파일이 존재하면 JSON 파싱하여 타겟 경로 추출
        env_targets: list = []
        if envelope_present:
            # canonical 우선, 없으면 worktree — resolved Path 재사용 (재-join 없음)
            env_abs = str(canonical_envelope_p) if envelope_in_canonical else str(worktree_envelope_p)
            env_targets = _parse_envelope_targets(env_abs)

        env_targets_in_canonical, env_targets_in_worktree = _resolve_target_presence(
            env_targets, canonical_root, worktree_root
        )

        # ── 존재 여부 boolean ────────────────────────────────────────────────
        # worktree_local_present: worktree 에 report/done/envelope-target 하나라도 있음
        worktree_local_present = worktree_report or worktree_done or env_targets_in_worktree
        # canonical_present: canonical 에 report/done/envelope-target 하나라도 있음
        canonical_present = canonical_report or canonical_done or env_targets_in_canonical

        # ── 발견된 경로 필드 결정 (상대경로 중심) ───────────────────────────
        # report: worktree 우선, 없으면 canonical
        found_report_path: Optional[str] = None
        if worktree_report:
            # worktree_report=True → worktree_report_p is not None (resolved Path 재사용)
            found_report_path = _rel_or_abs(str(worktree_report_p), canonical_root)
        elif canonical_report:
            found_report_path = _report_rel

        # done: worktree 우선, 없으면 canonical
        found_done_path: Optional[str] = None
        if worktree_done:
            # worktree_done=True → worktree_done_p is not None (resolved Path 재사용)
            found_done_path = _rel_or_abs(str(worktree_done_p), canonical_root)
        elif canonical_done:
            found_done_path = _done_rel

        # envelope: canonical 우선, 없으면 worktree
        found_envelope_path: Optional[str] = None
        if envelope_in_canonical:
            found_envelope_path = _envelope_rel
        elif envelope_in_worktree:
            # worktree_envelope_p is not None (resolved Path 재사용)
            found_envelope_path = _rel_or_abs(str(worktree_envelope_p), canonical_root)

        # worktree_path: canonical_root 기준 상대화 시도
        wt_path = _rel_or_abs(worktree_root, canonical_root)

        # ── 상태 결정 로직 ───────────────────────────────────────────────────
        # 아무 artifact 도 어디에도 없는 경우 (all absent)
        no_artifacts_at_all = (
            not worktree_report
            and not canonical_report
            and not worktree_done
            and not canonical_done
            and not envelope_present
        )

        statuses: List[str] = []

        if no_artifacts_at_all:
            statuses = [STATUS_TERMINAL_CALLBACK_MISSING]
        else:
            # worktree local present 마커
            if worktree_local_present:
                statuses.append(STATUS_WORKTREE_LOCAL_PRESENT)

            # canonical 미존재 + worktree 존재 → 픽업 필요
            if not canonical_present and worktree_local_present:
                statuses.append(STATUS_CANONICAL_MISSING)
                statuses.append(STATUS_ANU_PICKUP_FROM_WORKTREE_REQUIRED)

            # envelope 관련 상태
            if envelope_present:
                # env_target 이 envelope 에 명시적으로 존재하는지 (빈 리스트 = target 자체 없음).
                # 빈 리스트일 때 any()/all()/falsy 로 dangling 이 참이 되는 구조를 제거한다.
                has_env_targets = bool(env_targets)
                if not has_env_targets:
                    # envelope 는 있으나 target 필드가 전혀 없음 → dangling 아님(target 부재).
                    # CALLBACK_ENVELOPE_ONLY 계열로 분리.
                    statuses.append(STATUS_CALLBACK_ENVELOPE_ONLY)
                elif not env_targets_in_canonical and not env_targets_in_worktree:
                    # target 이 명시적으로 존재하지만 canonical/worktree 어디에도 없음 → dangling.
                    statuses.append(STATUS_CALLBACK_ENVELOPE_DANGLING)
                elif env_targets_in_worktree and not env_targets_in_canonical:
                    # target 이 worktree 에만 있음 → dangling 아님, envelope-only.
                    # STATUS_WORKTREE_LOCAL_PRESENT 가 이미 추가됨.
                    statuses.append(STATUS_CALLBACK_ENVELOPE_ONLY)
                else:
                    # target 이 canonical 에 present. envelope 만 있고 report/done 이 없으면 envelope-only 표기.
                    if not worktree_report and not canonical_report \
                            and not worktree_done and not canonical_done:
                        statuses.append(STATUS_CALLBACK_ENVELOPE_ONLY)

        # 빈 리스트 방어
        if not statuses:
            statuses = [STATUS_TERMINAL_CALLBACK_MISSING]

        # ── primary status 우선순위 결정 ────────────────────────────────────
        # 우선순위: TERMINAL_CALLBACK_MISSING > CALLBACK_ENVELOPE_DANGLING >
        #           WORKTREE_LOCAL_PRESENT > CALLBACK_ENVELOPE_ONLY >
        #           ANU_PICKUP_FROM_WORKTREE_REQUIRED
        _priority = [
            STATUS_TERMINAL_CALLBACK_MISSING,
            STATUS_CALLBACK_ENVELOPE_DANGLING,
            STATUS_WORKTREE_LOCAL_PRESENT,
            STATUS_CALLBACK_ENVELOPE_ONLY,
            STATUS_ANU_PICKUP_FROM_WORKTREE_REQUIRED,
        ]
        primary_status = STATUS_TERMINAL_CALLBACK_MISSING
        for candidate in _priority:
            if candidate in statuses:
                primary_status = candidate
                break

        return TerminalArtifactManifest(
            task_id=task_id,
            branch=branch,
            head=head,
            worktree_path=wt_path,
            report_path=found_report_path,
            done_path=found_done_path,
            envelope_path=found_envelope_path,
            status=primary_status,
            statuses=statuses,
        )

    except Exception:  # noqa: BLE001 — fail-open: 예외 시 TERMINAL_CALLBACK_MISSING 반환
        return TerminalArtifactManifest(
            task_id=task_id,
            branch=branch,
            head=head,
            worktree_path=worktree_root,
            report_path=None,
            done_path=None,
            envelope_path=None,
            status=STATUS_TERMINAL_CALLBACK_MISSING,
            statuses=[STATUS_TERMINAL_CALLBACK_MISSING],
        )


# ── surface_manifest ────────────────────────────────────────────────────────
def surface_manifest(manifest: TerminalArtifactManifest, *, canonical_root: str) -> str:
    """manifest 를 canonical index 파일에 JSON write.

    유일한 write: ``memory/events/terminal_artifact_index/<task_id>.json``.
    write 성공 시 manifest.statuses 에 STATUS_SURFACED_TO_CANONICAL 추가하고 경로 반환.
    실패 시 fail-open: 빈 문자열 반환 + statuses 변경 없음.

    task_id path traversal 방어: _validate_task_id_safe 통과 후 write.
    """
    if not _validate_task_id_safe(manifest.task_id):
        # path traversal 위협 — 쓰기 거부, fail-open
        return ""

    try:
        index_dir = os.path.join(canonical_root, CANONICAL_INDEX_DIR_REL)
        os.makedirs(index_dir, exist_ok=True)

        index_path = os.path.join(index_dir, f"{manifest.task_id}.json")

        # JSON 직렬화 (raw key 없음 보장: manifest 에 key 필드 없음)
        payload = manifest.to_dict()
        blob = json.dumps(payload, ensure_ascii=False, indent=2)

        with open(index_path, "w", encoding="utf-8") as fh:
            fh.write(blob)

        # 성공 시 SURFACED_TO_CANONICAL 마커 추가
        if STATUS_SURFACED_TO_CANONICAL not in manifest.statuses:
            manifest.statuses.append(STATUS_SURFACED_TO_CANONICAL)

        return index_path

    except Exception:  # noqa: BLE001 — fail-open: 빈 문자열 반환
        return ""


# ── read_manifest ───────────────────────────────────────────────────────────
def read_manifest(task_id: str, *, canonical_root: str) -> Optional[dict]:
    """canonical index 파일을 읽어 dict 반환.

    파일 없거나 파싱 실패 시 None 반환. side-effect 0 (read-only).
    """
    if not _validate_task_id_safe(task_id):
        return None

    index_path = os.path.join(
        canonical_root, CANONICAL_INDEX_DIR_REL, f"{task_id}.json"
    )
    if not os.path.exists(index_path):
        return None
    try:
        with open(index_path, encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return None


# ── surface (편의 함수) ─────────────────────────────────────────────────────
def surface(
    task_id: str,
    *,
    canonical_root: str,
    worktree_root: str,
    **kw,
) -> TerminalArtifactManifest:
    """build_manifest + surface_manifest 를 편의로 묶은 함수.

    manifest 를 build 하고 canonical index 에 write 후 manifest 반환.
    """
    manifest = build_manifest(
        task_id,
        canonical_root=canonical_root,
        worktree_root=worktree_root,
        **kw,
    )
    surface_manifest(manifest, canonical_root=canonical_root)
    return manifest


# ── __all__ ──────────────────────────────────────────────────────────────────
__all__ = [
    # dataclass
    "TerminalArtifactManifest",
    # 함수
    "build_manifest",
    "surface_manifest",
    "read_manifest",
    "surface",
    # 상태 상수
    "STATUS_WORKTREE_LOCAL_PRESENT",
    "STATUS_CANONICAL_MISSING",
    "STATUS_SURFACED_TO_CANONICAL",
    "STATUS_CALLBACK_ENVELOPE_ONLY",
    "STATUS_CALLBACK_ENVELOPE_DANGLING",
    "STATUS_ANU_PICKUP_FROM_WORKTREE_REQUIRED",
    "STATUS_TERMINAL_CALLBACK_MISSING",
    "STATUS_PUSH_ATTEMPT_WITHOUT_TERMINAL",
    # 기타 상수
    "CANONICAL_INDEX_DIR_REL",
    "SCHEMA_VERSION",
]
