"""tests/regression/test_callback_fallback_prune_2728.py
task-2728 Phase 2 regression: idempotent fallback prune, 6 PruneCause,
self-check, cancel_not_wired, canonical_root injection, result.json contract,
and §9-R legacy regression guard.

All fixtures are fully isolated via tempfile registry_path injection.
No subprocess / cokacdir / cron calls. Fake remover only.
"""
from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest

from utils.fallback_schedule_registry import (
    register_fallback,
    pending_for,
    resolve_registry_path,
)
from utils.completion_callback_fallback_cancel import (
    PruneCause,
    PruneOutcome,
    SelfCheckDecision,
    CancelClassification,
    RemoverResult,
    prune_fallbacks_for_key,
    detect_unwired_fallback,
    fallback_self_check,
    evaluate_safe_remove,
    evaluate_durable_evidence,
)
from utils.normal_completion_callback_collector_entrypoint import (
    collect_and_prune,
    resolve_canonical_root,
    resolve_result_json_path,
    RESULT_JSON_MISSING_RECOVERED,
)


# ── helper: fake removers ────────────────────────────────────────────────────


def _remover_removed(cron_id: str, *, dry_run: bool) -> RemoverResult:
    return RemoverResult(status="removed", detail=f"fake removed {cron_id}")


def _remover_already_gone(cron_id: str, *, dry_run: bool) -> RemoverResult:
    return RemoverResult(status="already_gone", detail=f"fake already_gone {cron_id}")


def _remover_failed(cron_id: str, *, dry_run: bool) -> RemoverResult:
    return RemoverResult(status="failed", detail=f"fake failed {cron_id}")


def _remover_already_fired(cron_id: str, *, dry_run: bool) -> RemoverResult:
    return RemoverResult(status="already_fired", detail=f"fake already_fired {cron_id}")


# ── fixture-A: normal callback → prune PASS, stale fire 차단 ─────────────────


def test_fixture_a_normal_callback_prune_cancels_fallback():
    """A: +1 16:46 normal callback → prune_fallbacks_for_key CANCELLED,
    pending_for afterwards → [] (stale fire blocked).
    """
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-2726+1",
        round=1,
        head_sha="head1",
        cron_id="FALLBACK_PLUS1",
        owner_key="anu",
        registry_path=reg,
    )

    outcomes = prune_fallbacks_for_key(
        task_id="task-2726+1",
        round=1,
        head_sha="head1",
        trigger="normal_callback_collected",
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    assert len(outcomes) == 1
    o = outcomes[0]
    assert o.classification == CancelClassification.CANCELLED, (
        f"Expected CANCELLED, got {o.classification}"
    )
    assert o.pruned is True
    assert o.cron_remove_invoked is True

    # stale fire 차단: 재조회 시 pending 없음
    remaining = pending_for("task-2726+1", round=1, head_sha="head1", registry_path=reg)
    assert remaining == [], f"Expected [], got {remaining}"


# ── fixture-B: idempotent prune via collect_and_prune ───────────────────────


def test_fixture_b_idempotent_prune_collect_and_prune():
    """B: +2 A86DB611 → collect_and_prune CANCELLED, 2nd call idempotent (no error, empty)."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-2726+2",
        round=2,
        head_sha="80416faa",
        cron_id="A86DB611",
        owner_key="anu",
        registry_path=reg,
    )

    result1 = collect_and_prune(
        task_id="task-2726+2",
        round=2,
        head_sha="80416faa",
        trigger="normal_callback_collected",
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    outcomes1 = result1["prune_outcomes"]
    assert len(outcomes1) == 1, f"Expected 1 outcome, got {len(outcomes1)}"
    assert outcomes1[0]["classification"] == CancelClassification.CANCELLED.value

    # idempotent: second call must return empty prune_outcomes without error
    result2 = collect_and_prune(
        task_id="task-2726+2",
        round=2,
        head_sha="80416faa",
        trigger="normal_callback_collected",
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )
    assert result2["prune_outcomes"] == [], (
        f"2nd idempotent call should yield [], got {result2['prune_outcomes']}"
    )


# ── fixture-C: stale self-check — 4 sub-cases ───────────────────────────────


def test_fixture_c_self_check_normal_callback_already_collected():
    """C-1: normal_callback_collected=True → proceed==False, cause==normal_callback_already_collected."""
    dec = fallback_self_check(
        task_id="task-x",
        round=1,
        head_sha="h1",
        normal_callback_collected=True,
    )
    assert dec.proceed is False
    assert dec.cause == PruneCause.NORMAL_CALLBACK_ALREADY_COLLECTED.value, (
        f"Expected normal_callback_already_collected, got {dec.cause!r}"
    )


def test_fixture_c_self_check_stale_round():
    """C-2: round=1, current_round=2 → proceed==False, cause==stale_round."""
    dec = fallback_self_check(
        task_id="task-x",
        round=1,
        head_sha="h1",
        current_round=2,
        normal_callback_collected=False,
    )
    assert dec.proceed is False
    assert dec.cause == PruneCause.STALE_ROUND.value, (
        f"Expected stale_round, got {dec.cause!r}"
    )


def test_fixture_c_self_check_stale_head():
    """C-3: head_sha != current_head_sha → proceed==False, cause==stale_head."""
    dec = fallback_self_check(
        task_id="task-x",
        round=1,
        head_sha="old",
        current_head_sha="new",
        normal_callback_collected=False,
    )
    assert dec.proceed is False
    assert dec.cause == PruneCause.STALE_HEAD.value, (
        f"Expected stale_head, got {dec.cause!r}"
    )


def test_fixture_c_self_check_no_stale_proceed_true():
    """C-4: no stale conditions → proceed==True, cause is None."""
    dec = fallback_self_check(
        task_id="task-x",
        round=1,
        head_sha="h1",
        current_round=1,
        current_head_sha="h1",
        normal_callback_collected=False,
    )
    assert dec.proceed is True
    assert dec.cause is None, f"Expected None cause, got {dec.cause!r}"


# ── fixture-D: schedule_not_found vs cancel_failed ──────────────────────────


def test_fixture_d_already_gone_classified_schedule_not_found():
    """D-1: already_gone → ALREADY_GONE, cause==schedule_not_found (NOT cancel_failed)."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-d",
        round=1,
        head_sha="hd1",
        cron_id="CRON_GONE",
        owner_key="anu",
        registry_path=reg,
    )

    outcomes = prune_fallbacks_for_key(
        task_id="task-d",
        round=1,
        head_sha="hd1",
        trigger="normal_callback_collected",
        remover=_remover_already_gone,
        dry_run=True,
        registry_path=reg,
    )

    assert len(outcomes) == 1
    o = outcomes[0]
    assert o.classification == CancelClassification.ALREADY_GONE, (
        f"Expected ALREADY_GONE, got {o.classification}"
    )
    assert o.cause == PruneCause.SCHEDULE_NOT_FOUND.value, (
        f"Expected schedule_not_found, got {o.cause!r}"
    )
    assert o.cause != PruneCause.CANCEL_FAILED.value, (
        "cause must NOT be cancel_failed for already_gone"
    )


def test_fixture_d_failed_classified_cancel_failed_pending_remains():
    """D-2: remover failed → cause==cancel_failed, pruned==False, still PENDING."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-d2",
        round=1,
        head_sha="hd2",
        cron_id="CRON_FAIL",
        owner_key="anu",
        registry_path=reg,
    )

    outcomes = prune_fallbacks_for_key(
        task_id="task-d2",
        round=1,
        head_sha="hd2",
        trigger="normal_callback_collected",
        remover=_remover_failed,
        dry_run=True,
        registry_path=reg,
    )

    assert len(outcomes) == 1
    o = outcomes[0]
    assert o.cause == PruneCause.CANCEL_FAILED.value, (
        f"Expected cancel_failed, got {o.cause!r}"
    )
    assert o.pruned is False, "pruned must be False when remover fails"

    # PENDING still remains (mark_pruned was NOT called)
    remaining = pending_for("task-d2", round=1, head_sha="hd2", registry_path=reg)
    assert len(remaining) == 1, (
        f"Expected 1 PENDING record after cancel_failed, got {remaining}"
    )


# ── fixture-AF: already_fired 전용 regression (HIGH id 3347386331 false-positive 종결) ──


def test_already_fired_maps_to_classification_no_attributeerror():
    """AF-1: remover status='already_fired' → CancelClassification.ALREADY_FIRED 매핑,
    AttributeError 0 (Gemini HIGH false positive 종결 — enum line 82 실재 증명).
    """
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-af",
        round=1,
        head_sha="haf1",
        cron_id="CRON_FIRED",
        owner_key="anu",
        registry_path=reg,
    )

    # enum 속성 접근 자체가 AttributeError를 던지지 않음을 명시 단언
    assert hasattr(CancelClassification, "ALREADY_FIRED"), (
        "CancelClassification.ALREADY_FIRED enum이 실재해야 함 (Gemini HIGH false positive)"
    )

    outcomes = prune_fallbacks_for_key(
        task_id="task-af",
        round=1,
        head_sha="haf1",
        trigger="normal_callback_collected",
        remover=_remover_already_fired,
        dry_run=True,
        registry_path=reg,
    )

    assert len(outcomes) == 1
    o = outcomes[0]
    assert o.classification == CancelClassification.ALREADY_FIRED, (
        f"Expected ALREADY_FIRED, got {o.classification}"
    )
    assert o.classification.value == "ALREADY_FIRED"


def test_already_fired_cause_and_pruned_true():
    """AF-2: already_fired → cause==normal_callback_already_collected, pruned==True,
    재조회 시 PENDING 없음(tombstone 기록됨).
    """
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    register_fallback(
        task_id="task-af2",
        round=1,
        head_sha="haf2",
        cron_id="CRON_FIRED2",
        owner_key="anu",
        registry_path=reg,
    )

    outcomes = prune_fallbacks_for_key(
        task_id="task-af2",
        round=1,
        head_sha="haf2",
        trigger="normal_callback_collected",
        remover=_remover_already_fired,
        dry_run=True,
        registry_path=reg,
    )

    assert len(outcomes) == 1
    o = outcomes[0]
    assert o.cause == PruneCause.NORMAL_CALLBACK_ALREADY_COLLECTED.value, (
        f"Expected normal_callback_already_collected, got {o.cause!r}"
    )
    assert o.pruned is True, "already_fired는 pruned=True (tombstone 기록)"
    assert o.cron_remove_invoked is True

    remaining = pending_for("task-af2", round=1, head_sha="haf2", registry_path=reg)
    assert remaining == [], f"Expected [] after already_fired prune, got {remaining}"


# ── fixture-E: canonical_root injection ─────────────────────────────────────


def test_fixture_e_resolve_canonical_root_injected():
    """E-1: resolve_canonical_root(canonical_root=...) → (root, 'canonical_root_injected')."""
    root, source = resolve_canonical_root(canonical_root="/tmp/injected_root")
    assert root == "/tmp/injected_root"
    assert source == "canonical_root_injected"


def test_fixture_e_resolve_canonical_root_worktree():
    """E-2: resolve_canonical_root(worktree_path=...) → (root, 'worktree_path')."""
    root, source = resolve_canonical_root(worktree_path="/tmp/wt")
    assert root == "/tmp/wt"
    assert source == "worktree_path"


def test_fixture_e_collect_and_prune_canonical_root_in_result():
    """E-3: collect_and_prune with canonical_root → result dict preserves it."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    result = collect_and_prune(
        task_id="task-e",
        round=1,
        head_sha="he1",
        trigger="normal_callback_collected",
        canonical_root="/tmp/injected_root",
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    assert result["canonical_root"] == "/tmp/injected_root", (
        f"Expected /tmp/injected_root, got {result['canonical_root']!r}"
    )
    assert result["canonical_root_source"] == "canonical_root_injected", (
        f"Expected canonical_root_injected, got {result['canonical_root_source']!r}"
    )


def test_fixture_e_resolve_registry_path_rel_path():
    """E-4: resolve_registry_path uses canonical_root and ends with proper relative path."""
    p = resolve_registry_path(canonical_root="/tmp/xyz")
    assert str(p).endswith("memory/state/fallback_schedule_registry.jsonl"), (
        f"Unexpected registry path: {p}"
    )
    assert str(p).startswith("/tmp/xyz"), f"Path does not start with canonical_root: {p}"


# ── fixture-F: result.json contract ─────────────────────────────────────────


def test_fixture_f_result_json_missing_recovered():
    """F-1: result_json_path missing → RESULT_JSON_MISSING_RECOVERED + follow_up_required."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name
    # Use a guaranteed-nonexistent path
    missing_path = "/tmp/does_not_exist_task_2728_regression.result.json"
    # Ensure it doesn't exist
    Path(missing_path).unlink(missing_ok=True)

    result = collect_and_prune(
        task_id="task-f",
        round=1,
        head_sha="hf1",
        trigger="normal_callback_collected",
        result_json_path=missing_path,
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    assert result["result_json_status"] == RESULT_JSON_MISSING_RECOVERED, (
        f"Expected RESULT_JSON_MISSING_RECOVERED_BY_COLLECTOR, got {result['result_json_status']!r}"
    )
    assert result["follow_up_required"] is True, (
        "follow_up_required must be True when result.json is missing"
    )


def test_fixture_f_result_json_present_ok():
    """F-2: result.json exists → result_json_status=='OK', follow_up_required==False."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    with tempfile.NamedTemporaryFile(
        suffix=".result.json", mode="w", delete=False, encoding="utf-8"
    ) as rjf:
        json.dump({"status": "completed"}, rjf)
        rj_path = rjf.name

    result = collect_and_prune(
        task_id="task-f2",
        round=1,
        head_sha="hf2",
        trigger="normal_callback_collected",
        result_json_path=rj_path,
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    assert result["result_json_status"] == "OK", (
        f"Expected OK, got {result['result_json_status']!r}"
    )
    assert result["follow_up_required"] is False, (
        "follow_up_required must be False when result.json exists"
    )


# ── fixture-G: cancel_not_wired detection ───────────────────────────────────


def test_fixture_g_detect_unwired_fallback_empty_registry():
    """G-1: empty registry + dispatch_fired=True → detect_unwired_fallback returns
    cause==cancel_not_wired, classification==SKIPPED_UNTRUSTED.
    """
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    result = detect_unwired_fallback(
        task_id="task-x",
        round=1,
        head_sha="h",
        dispatch_fired=True,
        registry_path=reg,
    )

    assert result is not None, "detect_unwired_fallback must return a PruneOutcome, not None"
    assert result.cause == PruneCause.CANCEL_NOT_WIRED.value, (
        f"Expected cancel_not_wired, got {result.cause!r}"
    )
    assert result.classification == CancelClassification.SKIPPED_UNTRUSTED, (
        f"Expected SKIPPED_UNTRUSTED, got {result.classification}"
    )


def test_fixture_g_collect_and_prune_unwired_in_result():
    """G-2: empty registry → collect_and_prune returns unwired dict with cause==cancel_not_wired."""
    with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
        reg = f.name

    result = collect_and_prune(
        task_id="task-g2",
        round=1,
        head_sha="hg2",
        trigger="normal_callback_collected",
        dispatch_fired=True,
        remover=_remover_removed,
        dry_run=True,
        registry_path=reg,
    )

    assert result["unwired"] is not None, (
        "collect_and_prune must populate 'unwired' for an empty registry"
    )
    assert result["unwired"]["cause"] == PruneCause.CANCEL_NOT_WIRED.value, (
        f"Expected cancel_not_wired, got {result['unwired']['cause']!r}"
    )


# ── fixture-9R: §9-R legacy cancel regression guard ─────────────────────────


def test_fixture_9r_evaluate_safe_remove_no_marker():
    """9R-1: evaluate_safe_remove without dispatch-fired marker →
    all_satisfied==False, marker_present==False (legacy 기존 동작 회귀 없음).
    """
    with tempfile.TemporaryDirectory() as tmpdir:
        missing_marker = Path(tmpdir) / "dispatch_fired.json"
        # Do NOT create the file

        checks = evaluate_safe_remove(
            task_id="task-9r",
            target_cron_id="CRON_9R",
            dispatch_fired_marker_path=missing_marker,
        )

    assert checks["all_satisfied"] is False, (
        f"Expected all_satisfied==False without marker, got {checks['all_satisfied']}"
    )
    assert checks["marker_present"] is False, (
        f"Expected marker_present==False, got {checks['marker_present']}"
    )


def test_fixture_9r_evaluate_durable_evidence_no_result_json():
    """9R-2: evaluate_durable_evidence without result.json → satisfied==False."""
    with tempfile.TemporaryDirectory() as tmpdir:
        td = Path(tmpdir)
        missing_rj = td / "missing.result.json"
        report = td / "report.md"
        marker = td / "collector_result.marker"
        # None of them exist

        ev = evaluate_durable_evidence(
            result_json_path=missing_rj,
            report_path=report,
            collector_result_marker_path=marker,
        )

    assert ev["satisfied"] is False, (
        f"Expected satisfied==False without result.json, got {ev['satisfied']}"
    )
    assert ev["result_json_exists"] is False, (
        f"Expected result_json_exists==False, got {ev['result_json_exists']}"
    )
