"""Regression tests for merge_ready_classifier (task-2632).

규칙:
- 순수 함수 호출만 (merge 실행 0 · GitHub write 0 · live git status 조회 0 · subprocess 0)
- frozen fixture 만 입력 (tests/fixtures/merge_ready/) · live workspace 의존 0
- pytest 로 실행 가능

검증 범위:
- 17 fixture 각각 verdict + chair_triggers + credential_tier + critical7_hits + completeness 일치
- verdict precedence (UNKNOWN > CHAIR_REQUIRED > HOLD > PASS) 단언
- 각 verdict enum 도달 경로 커버
- credential 3계층 · Critical7 1:1 · Gemini auto-remediation · lifecycle incident 반영
- classifier 순수성(merge/write/live-git/subprocess 호출 0) 정적 검증

dev6 담당: 페룬
단일소스 스펙: memory/specs/system_merge_ready_executor_spec_260522.md
"""

import json
import re
import sys
from pathlib import Path

# worktree root 를 sys.path 에 추가 (안전망)
_WORKTREE_ROOT = Path(__file__).resolve().parents[2]
if str(_WORKTREE_ROOT) not in sys.path:
    sys.path.insert(0, str(_WORKTREE_ROOT))

import pytest

from utils.merge_ready_classifier import classify_merge_ready
from utils import merge_ready_states as S

# frozen fixture root
FIXTURE_ROOT = Path(__file__).resolve().parents[1] / "fixtures" / "merge_ready"

ALL_FIXTURES = [
    "pass_all_green",
    "hold_ci_pending",
    "hold_gemini_medium",
    "hold_gemini_stale",
    "hold_unresolved_medium",
    "chair_forbidden_path",
    "chair_blocking_secret",
    "chair_net_new_identifier",
    "chair_out_of_scope",
    "chair_replacement_fail",
    "chair_smoke_fail",
    "chair_dependency_cycle",
    "chair_serial_collision",
    "chair_admin_override",
    "chair_lifecycle_incident",
    "existing_identifier_passthrough",
    "unknown_insufficient_evidence",
]

EXPECTED_FILES = ["utils/merge_ready_classifier.py", "utils/merge_ready_states.py"]


# ─────────────────────────────────────────────────────────────────────────────
# 헬퍼
# ─────────────────────────────────────────────────────────────────────────────

def _load_fixture(name: str):
    base = FIXTURE_ROOT / 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 _classify(evidence):
    declared = (evidence.get("scope") or {}).get("expected_files") or EXPECTED_FILES
    return classify_merge_ready(evidence, anu_keys=S.DEFAULT_ANU_KEYS, expected_files=declared)


# ─────────────────────────────────────────────────────────────────────────────
# 17 fixture 모두 존재 + 별도 fixture 분리(ANCHOR-2) 검증
# ─────────────────────────────────────────────────────────────────────────────

def test_seventeen_fixtures_present():
    """fixture 17종이 정확히 존재하고 각 dir 에 3파일이 있는지 검증."""
    dirs = sorted(p.name for p in FIXTURE_ROOT.iterdir() if p.is_dir())
    assert dirs == sorted(ALL_FIXTURES), f"fixture set mismatch: {dirs}"
    assert len(dirs) == 17, f"expected 17 fixtures, got {len(dirs)}"
    for name in ALL_FIXTURES:
        base = FIXTURE_ROOT / name
        for fn in ("evidence.json", "expected.json", "PROVENANCE.md"):
            assert (base / fn).exists(), f"{name}/{fn} missing"


def test_dependency_cycle_and_serial_collision_are_separate_fixtures():
    """ANCHOR-2: chair_dependency_cycle 와 chair_serial_collision 은 분리된 별도 fixture."""
    assert (FIXTURE_ROOT / "chair_dependency_cycle").is_dir()
    assert (FIXTURE_ROOT / "chair_serial_collision").is_dir()
    cyc_ev, _ = _load_fixture("chair_dependency_cycle")
    ser_ev, _ = _load_fixture("chair_serial_collision")
    # 서로 다른 신호로 구성 (합쳐지지 않았음을 증명)
    assert cyc_ev["critical7"]["dependency_cycle"] is True
    assert cyc_ev["critical7"]["serial_only_collision"] is False
    assert ser_ev["critical7"]["serial_only_collision"] is True
    assert ser_ev["critical7"]["dependency_cycle"] is False


# ─────────────────────────────────────────────────────────────────────────────
# 핵심: 17 fixture 회장 매핑 정확 일치 (parametrize)
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.parametrize("name", ALL_FIXTURES)
def test_fixture_chairman_mapping_exact(name):
    """각 fixture 의 evidence → classify → expected 정확 일치 검증."""
    evidence, expected = _load_fixture(name)
    result = _classify(evidence)

    assert result["verdict"] == expected["verdict"], (
        f"[{name}] verdict: got={result['verdict']!r} want={expected['verdict']!r}"
    )
    assert result["verdict"] in S.VERDICTS, f"[{name}] verdict not in enum: {result['verdict']!r}"
    assert result["credential_tier"] == expected["credential_tier"], (
        f"[{name}] credential_tier: got={result['credential_tier']!r} want={expected['credential_tier']!r}"
    )
    assert result["evidence_completeness"] == expected["evidence_completeness"], (
        f"[{name}] evidence_completeness: got={result['evidence_completeness']!r} "
        f"want={expected['evidence_completeness']!r}"
    )
    # chair_triggers / critical7_hits: 순서 무관 집합 동치 + 길이 동치(중복 없음)
    assert set(result["chair_triggers"]) == set(expected["chair_triggers"]), (
        f"[{name}] chair_triggers set: got={sorted(result['chair_triggers'])} "
        f"want={sorted(expected['chair_triggers'])}"
    )
    assert len(result["chair_triggers"]) == len(expected["chair_triggers"]), (
        f"[{name}] chair_triggers length(중복 의심): got={result['chair_triggers']}"
    )
    assert set(result["critical7_hits"]) == set(expected["critical7_hits"]), (
        f"[{name}] critical7_hits set: got={sorted(result['critical7_hits'])} "
        f"want={sorted(expected['critical7_hits'])}"
    )
    # auto_remediable (expected 에 있으면 검증)
    if "auto_remediable" in expected:
        assert set(result["auto_remediable"]) == set(expected["auto_remediable"]), (
            f"[{name}] auto_remediable set: got={sorted(result['auto_remediable'])} "
            f"want={sorted(expected['auto_remediable'])}"
        )
    # 반환 dict 필수 필드 모두 존재
    for field in ("verdict", "blocking_reasons", "chair_triggers", "auto_remediable",
                  "auto_merge_10_conditions", "critical7_hits", "credential_tier",
                  "evidence_completeness", "next_action", "classified_by"):
        assert field in result, f"[{name}] missing return field: {field}"
    assert result["classified_by"] == "merge-ready-classifier"


# ─────────────────────────────────────────────────────────────────────────────
# 각 verdict enum 도달 경로 커버 (ANCHOR-1)
# ─────────────────────────────────────────────────────────────────────────────

def test_all_four_verdicts_reachable():
    """17 fixture 가 4개 verdict enum 을 모두 도달하는지 검증."""
    verdicts = set()
    for name in ALL_FIXTURES:
        evidence, _ = _load_fixture(name)
        verdicts.add(_classify(evidence)["verdict"])
    assert verdicts == {S.PASS, S.HOLD, S.CHAIR_REQUIRED, S.UNKNOWN}, (
        f"not all verdicts reached: {verdicts}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# verdict precedence: UNKNOWN > CHAIR_REQUIRED > HOLD > PASS (상태기계)
# ─────────────────────────────────────────────────────────────────────────────

def _green_evidence():
    base = json.loads((FIXTURE_ROOT / "pass_all_green" / "evidence.json").read_text(encoding="utf-8"))
    return base


def test_precedence_pass_baseline():
    """깨끗한 evidence → PASS (terminal)."""
    assert _classify(_green_evidence())["verdict"] == S.PASS


def test_precedence_hold_beats_pass():
    """auto_remediable(예: CI pending) 존재 → PASS 아닌 HOLD."""
    e = _green_evidence()
    e["gates"]["ci_all_pass"] = False
    e["gates"]["ci_checks"] = {"build": "pending"}
    r = _classify(e)
    assert r["verdict"] == S.HOLD
    assert S.AR_CI_PENDING in r["auto_remediable"]


def test_precedence_chair_beats_hold():
    """chair trigger + auto_remediable 동시 존재 → CHAIR_REQUIRED (chair 우선)."""
    e = _green_evidence()
    # HOLD 신호(CI pending) + CHAIR 신호(dependency cycle) 공존
    e["gates"]["ci_all_pass"] = False
    e["gates"]["ci_checks"] = {"build": "pending"}
    e["critical7"]["dependency_cycle"] = True
    r = _classify(e)
    assert r["verdict"] == S.CHAIR_REQUIRED, f"chair must beat hold, got {r['verdict']}"
    # HOLD 신호가 실제로 존재했음을 함께 증명 (precedence 가 의미 있으려면)
    assert S.AR_CI_PENDING in r["auto_remediable"]
    assert S.CHAIR_DEPENDENCY_OR_SERIAL in r["chair_triggers"]


def test_precedence_unknown_beats_chair():
    """core gate evidence 결핍 + chair 신호(blocking secret) 공존 → UNKNOWN (UNKNOWN 우선)."""
    e = {
        "task_id": "precedence-unknown-beats-chair",
        # scope/gates 부재 → MISSING. 단 credential 에 blocking secret 신호 존재.
        "credential": {"blocking_secret_hits": ["<redacted-placeholder>"]},
        "critical7": {"dependency_cycle": True},
    }
    r = classify_merge_ready(e, anu_keys=S.DEFAULT_ANU_KEYS, expected_files=EXPECTED_FILES)
    assert r["verdict"] == S.UNKNOWN, f"UNKNOWN must beat chair, got {r['verdict']}"
    assert r["evidence_completeness"] == S.MISSING
    # 추정 금지: MISSING 시 chair_triggers/critical7 를 단언하지 않는다(빈 리스트)
    assert r["chair_triggers"] == []
    assert r["critical7_hits"] == []


def test_precedence_rank_monotonic():
    """precedence rank 헬퍼가 UNKNOWN<CHAIR<HOLD<PASS 순(우선순위 높을수록 낮은 rank)."""
    assert S.verdict_precedes(S.UNKNOWN, S.CHAIR_REQUIRED)
    assert S.verdict_precedes(S.CHAIR_REQUIRED, S.HOLD)
    assert S.verdict_precedes(S.HOLD, S.PASS)
    assert not S.verdict_precedes(S.PASS, S.UNKNOWN)


# ─────────────────────────────────────────────────────────────────────────────
# credential 3계층 doctrine
# ─────────────────────────────────────────────────────────────────────────────

def test_credential_existing_is_not_a_fail():
    """EXISTING_SYSTEM_IDENTIFIER 재사용은 fail 아님 · PASS 영향 0."""
    e = _green_evidence()
    e["credential"]["existing_system_identifier_reuse"] = True
    r = _classify(e)
    assert r["credential_tier"] == S.CRED_EXISTING_SYSTEM_IDENTIFIER
    assert r["verdict"] == S.PASS
    assert S.CHAIR_CREDENTIAL_PERMISSION_EXPANSION not in r["chair_triggers"]


def test_credential_blocking_and_net_new_escalate():
    """BLOCKING_SECRET·NET_NEW → CHAIR_REQUIRED (credential·permission expansion)."""
    e1 = _green_evidence()
    e1["credential"]["blocking_secret_hits"] = ["<redacted-placeholder>"]
    r1 = _classify(e1)
    assert r1["credential_tier"] == S.CRED_BLOCKING_SECRET
    assert r1["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_CREDENTIAL_PERMISSION_EXPANSION in r1["chair_triggers"]

    e2 = _green_evidence()
    e2["credential"]["net_new_identifier_exposure"] = True
    r2 = _classify(e2)
    assert r2["credential_tier"] == S.CRED_NET_NEW
    assert r2["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_CREDENTIAL_PERMISSION_EXPANSION in r2["chair_triggers"]


def test_credential_tier_precedence_blocking_over_net_new():
    """blocking + net_new 공존 시 더 위험한 BLOCKING_SECRET 채택."""
    e = _green_evidence()
    e["credential"]["blocking_secret_hits"] = ["<redacted-placeholder>"]
    e["credential"]["net_new_identifier_exposure"] = True
    e["credential"]["existing_system_identifier_reuse"] = True
    assert _classify(e)["credential_tier"] == S.CRED_BLOCKING_SECRET


# ─────────────────────────────────────────────────────────────────────────────
# Critical7 1:1 mapping (각 chair 7종 critical fixture)
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.parametrize("name,c7_enum", [
    ("chair_forbidden_path", S.C7_FORBIDDEN_PATH),
    ("chair_out_of_scope", S.C7_SCOPE_EXPANSION),
    ("chair_admin_override", S.C7_OVERRIDE),
    ("chair_dependency_cycle", S.C7_DEPENDENCY_CYCLE_OR_SERIAL),
    ("chair_serial_collision", S.C7_DEPENDENCY_CYCLE_OR_SERIAL),
    ("chair_replacement_fail", S.C7_REPLACEMENT_FAIL),
    ("chair_smoke_fail", S.C7_POST_MERGE_SMOKE_FAIL),
])
def test_critical7_mapping(name, c7_enum):
    """Critical7 신호 → 해당 C7 enum hit → CHAIR_REQUIRED."""
    evidence, _ = _load_fixture(name)
    r = _classify(evidence)
    assert c7_enum in r["critical7_hits"], f"[{name}] {c7_enum} not in {r['critical7_hits']}"
    assert r["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_CRITICAL7 in r["chair_triggers"]


def test_out_of_scope_replacement_fail_combo_c7_2():
    """out_of_expected + replacement_pr_failure 동시 → Critical7 #2 (C7_OUT_OF_SCOPE_REPLACEMENT_FAIL)."""
    e = _green_evidence()
    e["scope"]["out_of_expected_files_modification"] = True
    e["scope"]["exact_match"] = False
    e["critical7"]["replacement_pr_failure"] = True
    r = _classify(e)
    assert S.C7_OUT_OF_SCOPE_REPLACEMENT_FAIL in r["critical7_hits"]
    assert S.C7_REPLACEMENT_FAIL in r["critical7_hits"]
    assert r["verdict"] == S.CHAIR_REQUIRED


# ─────────────────────────────────────────────────────────────────────────────
# Gemini auto-remediation doctrine
# ─────────────────────────────────────────────────────────────────────────────

def test_gemini_medium_within_expected_is_hold_auto_remediable():
    """medium + expected_files 내부 + Critical7 0 + credential 0 → HOLD(auto_remediable)."""
    evidence, _ = _load_fixture("hold_gemini_medium")
    r = _classify(evidence)
    assert r["verdict"] == S.HOLD
    assert S.AR_GEMINI_MEDIUM_WITHIN_EXPECTED in r["auto_remediable"]
    assert r["chair_triggers"] == []


def test_gemini_noncritical_high_within_expected_is_hold():
    """non-critical HIGH + expected_files 내부(반복 아님·scope 확장 아님) → HOLD(auto_remediable)."""
    e = _green_evidence()
    e["gemini"]["gemini_findings"] = [
        {"severity": "high", "path": "utils/merge_ready_classifier.py",
         "within_expected_files": True, "resolved": False}
    ]
    e["gemini"]["high_or_critical_unresolved"] = 0
    r = _classify(e)
    assert r["verdict"] == S.HOLD
    assert S.AR_GEMINI_NONCRITICAL_HIGH_WITHIN_EXPECTED in r["auto_remediable"]
    assert r["chair_triggers"] == []


def test_gemini_repeated_high_same_function_escalates_chair():
    """같은 함수 HIGH 반복 → auto-remediation loop boundary → CHAIR_REQUIRED."""
    e = _green_evidence()
    e["gemini"]["gemini_findings"] = [
        {"severity": "high", "path": "utils/merge_ready_classifier.py",
         "within_expected_files": True, "resolved": False}
    ]
    e["gemini"]["repeated_high_same_function"] = True
    r = _classify(e)
    assert r["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_AUTO_REMEDIATION_LOOP_BOUNDARY in r["chair_triggers"]


def test_gemini_high_out_of_expected_escalates_chair():
    """HIGH/medium 이 expected_files 밖/scope 확장 요구 → CHAIR_REQUIRED (Critical7 #3)."""
    e = _green_evidence()
    e["gemini"]["gemini_findings"] = [
        {"severity": "high", "path": "anu_v3/runtime/x.py",
         "within_expected_files": False, "resolved": False}
    ]
    r = _classify(e)
    assert r["verdict"] == S.CHAIR_REQUIRED
    assert S.C7_SCOPE_EXPANSION in r["critical7_hits"]


# ─────────────────────────────────────────────────────────────────────────────
# callback lifecycle (fields 10~14) incident 반영
# ─────────────────────────────────────────────────────────────────────────────

def test_lifecycle_incident_escalates_chair():
    """classification=INCIDENT → chair_trigger LIFECYCLE_INCIDENT → CHAIR_REQUIRED."""
    evidence, _ = _load_fixture("chair_lifecycle_incident")
    r = _classify(evidence)
    assert r["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_LIFECYCLE_INCIDENT in r["chair_triggers"]


def test_lifecycle_self_key_fired_non_authoritative_via_owner_key():
    """closeout callback 이 ANU 가 아닌 self-key 로 fired → owner key 교차확인 → incident → CHAIR."""
    e = _green_evidence()
    # classification 필드 없이 owner_key/fired 만으로 self-key 발사 감지
    e["lifecycle"]["classification"] = "normal"
    e["lifecycle"]["owner_key"] = "1e41a2324a3ccdd0"  # executor self-key (non-authoritative)
    e["lifecycle"]["fired"] = True
    r = _classify(e)
    assert r["verdict"] == S.CHAIR_REQUIRED
    assert S.CHAIR_LIFECYCLE_INCIDENT in r["chair_triggers"]


def test_lifecycle_anu_key_fired_is_normal():
    """ANU authoritative key 로 fired → incident 아님 → PASS 유지."""
    e = _green_evidence()
    e["lifecycle"]["owner_key"] = "c119085addb0f8b7"  # ANU authoritative
    e["lifecycle"]["fired"] = True
    r = _classify(e)
    assert r["verdict"] == S.PASS
    assert S.CHAIR_LIFECYCLE_INCIDENT not in r["chair_triggers"]


# ─────────────────────────────────────────────────────────────────────────────
# 자동 머지 10조건 dict 형태/키 검증
# ─────────────────────────────────────────────────────────────────────────────

def test_auto_merge_10_conditions_shape():
    """auto_merge_10_conditions 는 정확히 10개 키의 bool dict · pass_all_green 은 전부 True."""
    evidence, _ = _load_fixture("pass_all_green")
    cond = _classify(evidence)["auto_merge_10_conditions"]
    assert isinstance(cond, dict)
    assert sorted(cond.keys()) == sorted(S.AUTO_MERGE_10_CONDITION_KEYS)
    assert len(cond) == 10
    assert all(isinstance(v, bool) for v in cond.values())
    assert all(cond.values()), f"pass_all_green 10조건 전부 True 여야 함: {cond}"


def test_unknown_returns_missing_core_sources():
    """UNKNOWN fixture → MISSING + 핵심 source(scope/gates) 가 missing 목록에 포함."""
    evidence, _ = _load_fixture("unknown_insufficient_evidence")
    r = _classify(evidence)
    assert r["verdict"] == S.UNKNOWN
    assert r["evidence_completeness"] == S.MISSING
    assert "scope" in r["missing_evidence_sources"]
    assert "gates" in r["missing_evidence_sources"]


# ─────────────────────────────────────────────────────────────────────────────
# 결정성 (동일 evidence 2회 → 동일 결과)
# ─────────────────────────────────────────────────────────────────────────────

@pytest.mark.parametrize("name", ["pass_all_green", "chair_out_of_scope", "unknown_insufficient_evidence"])
def test_determinism(name):
    evidence, _ = _load_fixture(name)
    a = _classify(evidence)
    b = _classify(evidence)
    assert a == b, f"[{name}] 비결정적 결과"


# ─────────────────────────────────────────────────────────────────────────────
# ANCHOR-3: classifier 순수성 — merge/write/live-git/subprocess 호출 0 정적 검증
# ─────────────────────────────────────────────────────────────────────────────

def _code_only(src: str) -> str:
    """문자열 리터럴/주석/f-string 텍스트를 제거하고 실제 코드 토큰만 남긴다.

    docstring·주석에서 'subprocess' 등을 '언급'하는 것은 허용하기 위함 —
    금지되는 것은 실제 import/호출 코드뿐이다.
    """
    import io
    import tokenize

    out = []
    for tok in tokenize.generate_tokens(io.StringIO(src).readline):
        if tok.type in (tokenize.STRING, tokenize.COMMENT):
            continue
        if tok.type == getattr(tokenize, "FSTRING_MIDDLE", -1):
            continue
        out.append(tok.string)
    return " ".join(out)


def test_classifier_sources_have_no_io_or_merge_calls():
    """classifier/states 소스에 subprocess/network/file-write/live-git/merge 호출이 없음을 정적 검증.

    docstring/주석에서 단어를 언급하는 것은 허용(코드 토큰만 검사). 금지: 실제 import/호출 구문.
    """
    utils_dir = _WORKTREE_ROOT / "utils"
    targets = [
        utils_dir / "merge_ready_classifier.py",
        utils_dir / "merge_ready_states.py",
    ]
    forbidden_import = re.compile(
        r"\b(import\s+(subprocess|requests|urllib|socket|http|os|sys|pathlib)\b"
        r"|from\s+(subprocess|requests|urllib|socket|http|os|sys|pathlib|anu_v3)\b)"
    )
    forbidden_call = re.compile(
        r"(subprocess\s*\.|os\s*\.\s*system|os\s*\.\s*popen|requests\s*\.|urllib\s*\.|"
        r"socket\s*\.|check_output|Popen)"
    )
    for fpath in targets:
        assert fpath.exists(), f"{fpath} not found"
        src = fpath.read_text(encoding="utf-8")
        code = _code_only(src)
        m1 = forbidden_import.search(code)
        assert m1 is None, (
            f"{fpath.name}: 금지된 import 발견 ({m1.group().strip()!r}). "
            f"classifier 는 evidence-only decoupled · I/O 0 이어야 함."
        )
        m2 = forbidden_call.search(code)
        assert m2 is None, (
            f"{fpath.name}: 금지된 I/O/merge 호출 발견 ({m2.group()!r})."
        )
        # 실제 코드 토큰 기준 merge/push/commit/open 부재
        for token in ("subprocess", "Popen", "check_output"):
            assert token not in code, f"{fpath.name}: 금지 코드 토큰 '{token}' 발견"


def test_classifier_is_importable_without_runtime():
    """classifier import 만으로 부작용/네트워크/daemon 이 없음(순수 import)."""
    import importlib
    import utils.merge_ready_classifier as m
    importlib.reload(m)
    assert callable(m.classify_merge_ready)
