"""Regression tests for callback lifecycle classifier **wiring** (task-2630, L4).

L3 가 classifier core(utils/callback_lifecycle_classifier.py)를 검증한다면, 본
파일은 그 core 를 executor completion contract 에 **실결선**한 경로
(dispatch.executor_completion_contract)를 검증한다.

회장 verbatim 필수 regression 10항 (memory/tasks/task-2630.md):
  1. task-2625 fixture → SELF_KEY_FIRED_NON_AUTHORITATIVE
  2. task-2628 fixture → FINISH_TASK_GIT_GATE_BLOCKED_BEFORE_CALLBACK + FOREIGN_DIRTY_BLOCKER
  3. task-2628+1 fixture → FALLBACK_COLLECTOR_APPLIED + ENVELOPE_PREPARED_NOT_FIRED
  4. UNKNOWN / INSUFFICIENT_EVIDENCE fixture
  5. 기존 callback contract 9 fields 유지
  6. fields 10~14 append 확인
  7. callback_lifecycle.json idempotent artifact 생성
  8. live workspace 의존 0
  9. foreign dirty 미접촉
  10. callback 재발사 0

규칙:
- 순수 함수 + 격리 tmp_path 만 (실 cron 0 · 실 발사 0 · subprocess 0 · live workspace 의존 0)
- frozen fixture 만 입력 (tests/fixtures/callback_lifecycle/) — fixture 수정/날조 0
- pytest 로 실행 가능
"""

import importlib.util
import json
import os
import sys
from pathlib import Path

# worktree root 를 sys.path 에 추가 (안전망 — 기존 classifier 테스트와 동일 패턴)
_WORKTREE_ROOT = Path(__file__).resolve().parents[2]
if str(_WORKTREE_ROOT) not in sys.path:
    sys.path.insert(0, str(_WORKTREE_ROOT))

import pytest


def _load_real(modname: str, relpath: str):
    """tests/dispatch 패키지 shadow 를 우회해 실 dispatch 모듈을 파일 경로로 로드.

    (기존 test_callback_runtime_enforcement_2626.py 와 동일 패턴.)
    """
    existing = sys.modules.get(modname)
    if existing is not None and getattr(existing, "__file__", "").endswith(relpath):
        return existing
    spec = importlib.util.spec_from_file_location(modname, _WORKTREE_ROOT / relpath)
    assert spec is not None and spec.loader is not None
    mod = importlib.util.module_from_spec(spec)
    sys.modules[modname] = mod
    spec.loader.exec_module(mod)
    return mod


# 의존 순서대로 실 dispatch 모듈 선등록 (callback_owner_enforcer ← helper)
_load_real("dispatch.callback_owner_enforcer", "dispatch/callback_owner_enforcer.py")
_load_real("dispatch.normal_fallback_callback_helper", "dispatch/normal_fallback_callback_helper.py")
ecc = _load_real("dispatch.executor_completion_contract", "dispatch/executor_completion_contract.py")

from dispatch.normal_fallback_callback_helper import (  # noqa: E402
    _contract_fields,
    CALLBACK_KIND_NORMAL,
)

# frozen fixture root (live workspace 아님 — repo 내 frozen 자료)
FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures" / "callback_lifecycle"


def _load_fixture(fixture_dir_name):
    base = FIXTURE_ROOT / fixture_dir_name
    evidence = json.loads((base / "evidence.json").read_text(encoding="utf-8"))
    expected = json.loads((base / "expected.json").read_text(encoding="utf-8"))
    return evidence, expected


def _real_nine_fields():
    """단일소스(_contract_fields)에서 실제 9-field callback contract dict 생성."""
    return _contract_fields(
        callback_prompt="task_id=task-2630 done",
        kind=CALLBACK_KIND_NORMAL,
        cron_id="DEAD1234",
        status="ENVELOPE_PREPARED_NOT_FIRED",
        envelope_only=True,
        fallback_prompt="",
        fallback_registered=False,
    )


# ─────────────────────────────────────────────────────────────────────────────
# 1~4: 4 fixture 가 결선 경로(build_callback_lifecycle_artifact)를 통해
#      expected.json 의 (delivery_outcome, miss_cause, root_cause_tags) 정확 일치
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.parametrize("fixture_dir", [
    "task-2625",            # regression 1: SELF_KEY_FIRED_NON_AUTHORITATIVE
    "task-2628",            # regression 2: GIT_GATE_BLOCKED + FOREIGN_DIRTY_BLOCKER
    "task-2628_plus_1",     # regression 3: FALLBACK_COLLECTOR_APPLIED + ENVELOPE_PREPARED_NOT_FIRED
    "unknown_insufficient", # regression 4: UNKNOWN / INSUFFICIENT_EVIDENCE
])
def test_wiring_path_matches_expected(fixture_dir):
    """결선 경로(classify_completion_lifecycle → artifact)가 회장 매핑과 일치."""
    evidence, expected = _load_fixture(fixture_dir)
    artifact = ecc.build_callback_lifecycle_artifact(
        evidence.get("task_id", fixture_dir), evidence
    )

    assert artifact["delivery_outcome"] == expected["delivery_outcome"]
    assert artifact["normal_callback_miss_cause"] == expected["normal_callback_miss_cause"]
    # root_cause_tags: 순서 무관 집합 동치 + 길이 동치(중복 0)
    assert set(artifact["root_cause_tags"]) == set(expected["root_cause_tags"])
    assert len(artifact["root_cause_tags"]) == len(expected["root_cause_tags"])
    assert artifact["evidence_completeness"] == expected["evidence_completeness"]
    if "classification" in expected:
        assert artifact["classification"] == expected["classification"]


def test_regression_1_self_key_fired_non_authoritative():
    """regression 1 — task-2625: self-key 실발사 = incident(SELF_KEY_FIRED_NON_AUTHORITATIVE).

    SELF_KEY_FAIL_CLOSED_BEFORE_FIRE 와 분리 유지(ANCHOR-3 / 필수구현 9).
    """
    evidence, _ = _load_fixture("task-2625")
    res = ecc.classify_completion_lifecycle(evidence)
    assert res["normal_callback_miss_cause"] == "SELF_KEY_FIRED_NON_AUTHORITATIVE"
    assert res["normal_callback_miss_cause"] != "SELF_KEY_FAIL_CLOSED_BEFORE_FIRE"
    assert res["classification"] == "incident"


def test_regression_2_git_gate_blocked_with_foreign_dirty():
    """regression 2 — task-2628: GIT-GATE 차단 + FOREIGN_DIRTY_BLOCKER root cause."""
    evidence, _ = _load_fixture("task-2628")
    res = ecc.classify_completion_lifecycle(evidence)
    assert res["normal_callback_miss_cause"] == "FINISH_TASK_GIT_GATE_BLOCKED_BEFORE_CALLBACK"
    assert "FOREIGN_DIRTY_BLOCKER" in res["root_cause_tags"]
    # CALLBACK_DELIVERY_GAP residual-only (필수구현 8 / ANCHOR-3) — 여기서 미발생
    assert res["normal_callback_miss_cause"] != "CALLBACK_DELIVERY_GAP"


def test_regression_3_fallback_collector_applied():
    """regression 3 — task-2628+1: FALLBACK_COLLECTOR_APPLIED + ENVELOPE_PREPARED_NOT_FIRED."""
    evidence, _ = _load_fixture("task-2628_plus_1")
    res = ecc.classify_completion_lifecycle(evidence)
    assert res["delivery_outcome"] == "FALLBACK_COLLECTOR_APPLIED"
    assert res["normal_callback_miss_cause"] == "ENVELOPE_PREPARED_NOT_FIRED"


def test_regression_4_unknown_insufficient_evidence_no_guess():
    """regression 4 — 증거 결핍 → UNKNOWN/INSUFFICIENT_EVIDENCE (추정 0 · 필수구현 7)."""
    evidence, _ = _load_fixture("unknown_insufficient")
    res = ecc.classify_completion_lifecycle(evidence)
    assert res["delivery_outcome"] == "UNKNOWN_INSUFFICIENT_EVIDENCE"
    assert res["normal_callback_miss_cause"] == "UNKNOWN_INSUFFICIENT_EVIDENCE"
    assert res["evidence_completeness"] == "MISSING"
    assert res["root_cause_tags"] == []  # 추정 태그 0


# ─────────────────────────────────────────────────────────────────────────────
# 5: 기존 callback contract 9 fields 유지 (대체 금지 · ANCHOR-2)
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_5_nine_fields_preserved():
    evidence, _ = _load_fixture("task-2628_plus_1")
    nine = _real_nine_fields()
    # manifest 가 단일소스 _contract_fields 키와 정확 일치 (drift guard)
    assert tuple(nine.keys()) == ecc.CALLBACK_CONTRACT_9_FIELDS
    merged = ecc.append_lifecycle_fields(nine, evidence)
    # 9 field 값/키 전부 보존
    for k in ecc.CALLBACK_CONTRACT_9_FIELDS:
        assert k in merged
        assert merged[k] == nine[k]
    # 입력 dict 불변(부작용 0)
    assert set(nine.keys()) == set(ecc.CALLBACK_CONTRACT_9_FIELDS)


def test_regression_5b_append_only_collision_rejected():
    """9-field 키와 충돌하는 lifecycle field 가 있으면 ValueError (덮어쓰기 금지)."""
    evidence, _ = _load_fixture("task-2625")
    poisoned = {"delivery_outcome": "SHOULD_NOT_BE_OVERWRITTEN"}
    with pytest.raises(ValueError):
        ecc.append_lifecycle_fields(poisoned, evidence)


# ─────────────────────────────────────────────────────────────────────────────
# 6: fields 10~14 append 확인
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_6_fields_10_to_14_appended():
    evidence, expected = _load_fixture("task-2628")
    nine = _real_nine_fields()
    merged = ecc.append_lifecycle_fields(nine, evidence)
    # fields 10~14 (6 keys) 전부 존재
    for k in ecc.LIFECYCLE_RESULT_FIELDS:
        assert k in merged, f"field 10~14 누락: {k}"
    # 동시 존재: 9 fields + 10~14 = 9 + 6
    assert len(merged) == 9 + len(ecc.LIFECYCLE_RESULT_FIELDS)
    # field 13 lifecycle_state_evidence 는 판정 근거 dict
    assert isinstance(merged["lifecycle_state_evidence"], dict)
    # field 14 classified_by
    assert merged["classified_by"] == "auto-classifier"
    assert merged["delivery_outcome"] == expected["delivery_outcome"]


def test_regression_6b_stage_separation_recorded():
    """필수구현 5 — gate PASS / notification sent / collector received 분리 기록."""
    evidence, _ = _load_fixture("task-2628_plus_1")
    artifact = ecc.build_callback_lifecycle_artifact("task-2628+1", evidence)
    sep = artifact["callback_stage_separation"]
    assert set(sep.keys()) == {"callback_gate_pass", "notification_sent", "collector_received"}
    # task-2628+1: gate 통과 · normal 미발사 · fallback collector 수집
    assert sep["callback_gate_pass"] is True
    assert sep["notification_sent"] is False
    assert sep["collector_received"] is True


def test_regression_6c_fallback_artifact_distinguished():
    """필수구현 6 — lifecycle classifier artifact 가 fallback collector artifact 와 구분."""
    evidence, _ = _load_fixture("task-2628_plus_1")
    artifact = ecc.build_callback_lifecycle_artifact("task-2628+1", evidence)
    assert artifact["artifact_kind"] == ecc.CALLBACK_LIFECYCLE_ARTIFACT_KIND
    assert artifact["schema"] == ecc.CALLBACK_LIFECYCLE_ARTIFACT_SCHEMA
    # 파일 suffix 가 fallback collector artifact 와 다름
    path = ecc.callback_lifecycle_artifact_path("task-2628+1", "/tmp/anywhere")
    assert path.endswith(".callback_lifecycle.json")
    assert "independent-anu-collector" not in path
    assert "fallback_collector_applied" not in path


# ─────────────────────────────────────────────────────────────────────────────
# 7: callback_lifecycle.json idempotent artifact 생성
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_7_artifact_idempotent(tmp_path):
    evidence, _ = _load_fixture("task-2628_plus_1")
    events_dir = tmp_path / "memory" / "events"

    p1 = ecc.write_callback_lifecycle_artifact(
        "task-2628+1", evidence, events_dir=str(events_dir)
    )
    b1 = Path(p1).read_bytes()
    m1 = os.path.getmtime(p1)

    p2 = ecc.write_callback_lifecycle_artifact(
        "task-2628+1", evidence, events_dir=str(events_dir)
    )
    b2 = Path(p2).read_bytes()
    m2 = os.path.getmtime(p2)

    assert p1 == p2
    assert b1 == b2, "동일 입력 2회 → byte-identical 이어야 함(idempotent)"
    assert m1 == m2, "동일 내용이면 재기록 금지(mtime 보존)"
    # 디렉터리에 lifecycle artifact 단일 파일만
    assert os.listdir(events_dir) == ["task-2628+1.callback_lifecycle.json"]
    # 내용이 valid JSON
    parsed = json.loads(b1)
    assert parsed["task_id"] == "task-2628+1"


# ─────────────────────────────────────────────────────────────────────────────
# 8: live workspace 의존 0
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_8_no_live_workspace_dependency(tmp_path, monkeypatch):
    """WORKSPACE_ROOT override 로 events dir 가 격리 경로로 해석(live 의존 0)."""
    monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
    events_dir = ecc.default_events_dir()
    assert events_dir == str(tmp_path / "memory" / "events")
    # classify 자체는 입력 dict 만으로 동작(파일/네트워크 read 0)
    evidence, _ = _load_fixture("task-2625")
    res = ecc.classify_completion_lifecycle(evidence)
    assert res["delivery_outcome"]  # 비어있지 않음
    # 기본 경로로 write 해도 격리 tmp 아래에만 생성
    p = ecc.write_callback_lifecycle_artifact("task-2625", evidence)
    assert str(tmp_path) in p
    assert Path(p).exists()


# ─────────────────────────────────────────────────────────────────────────────
# 9: foreign dirty 미접촉
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_9_foreign_dirty_untouched(tmp_path):
    """writer 는 lifecycle artifact 1개만 만들고, foreign dirty 파일은 미접촉."""
    events_dir = tmp_path / "memory" / "events"
    events_dir.mkdir(parents=True)
    # task-2628 evidence 에 등장하는 foreign dirty 파일명을 sentinel 로 배치
    sentinels = {
        "utils__replacement_pr_runner.py": "ORIGINAL-DO-NOT-TOUCH",
        "anu-system-spec.md": "ORIGINAL-SPEC",
    }
    for name, content in sentinels.items():
        (events_dir / name).write_text(content, encoding="utf-8")

    evidence, _ = _load_fixture("task-2628")
    ecc.write_callback_lifecycle_artifact(
        "task-2628", evidence, events_dir=str(events_dir)
    )

    # sentinel 내용 불변
    for name, content in sentinels.items():
        assert (events_dir / name).read_text(encoding="utf-8") == content
    # 신규 생성 파일은 lifecycle artifact 1개뿐
    created = set(os.listdir(events_dir)) - set(sentinels.keys())
    assert created == {"task-2628.callback_lifecycle.json"}


# ─────────────────────────────────────────────────────────────────────────────
# 10: callback 재발사 0 (cron register/remove 0 · subprocess 0 · 실발사 0)
# ─────────────────────────────────────────────────────────────────────────────

def test_regression_10_no_callback_refire(tmp_path, monkeypatch):
    """전체 결선 경로가 subprocess/os.system 을 단 한 번도 호출하지 않음."""
    calls = []

    def _boom(*a, **k):
        calls.append(("subprocess", a, k))
        raise AssertionError("subprocess 호출 발생 — callback 재발사 금지 위반")

    def _boom_system(*a, **k):
        calls.append(("os.system", a, k))
        raise AssertionError("os.system 호출 발생 — callback 재발사 금지 위반")

    import subprocess
    monkeypatch.setattr(subprocess, "run", _boom, raising=True)
    monkeypatch.setattr(subprocess, "Popen", _boom, raising=True)
    monkeypatch.setattr(subprocess, "call", _boom, raising=True)
    monkeypatch.setattr(os, "system", _boom_system, raising=True)

    evidence, _ = _load_fixture("task-2625")
    nine = _real_nine_fields()
    ecc.append_lifecycle_fields(nine, evidence)
    ecc.build_callback_lifecycle_artifact("task-2625", evidence)
    ecc.write_callback_lifecycle_artifact(
        "task-2625", evidence, events_dir=str(tmp_path / "ev")
    )

    assert calls == [], f"발사/subprocess 호출 0 이어야 함, got {calls}"
    # 결선 모듈에 cron 발사/등록 public 함수가 없음(설계상 데이터-only)
    for forbidden in ("fire_callback", "register_cron", "remove_cron", "send_callback"):
        assert not hasattr(ecc, forbidden)
