"""StepRunner.load_checkpoint archive fallback 단위 테스트 (Fix 4 / M5 해소).

오늘 사고 (2026-05-01 13:19) 시퀀스:
1. force 호출 → clear_checkpoint() → archive 만들고 manifest.json 삭제
2. step 새로 실행 시작 → backend kill로 save_checkpoint() 미호출
3. 디렉토리에 archive(`manifest_TIMESTAMP.json`)만 있고 manifest.json 비어있음
4. dispatcher가 cp 못 찾음 → 사람 수동 복구 필요했음

Fix: load_checkpoint가 manifest.json 빈/없음/손상 시 가장 최신 archive를
자동으로 manifest.json에 복원 + 데이터 반환.
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from app.core.step_runner import StepRunner


@pytest.fixture
def fake_runner(tmp_path):
    """StepRunner 인스턴스 — 파일 I/O만 사용. DB는 MagicMock."""
    inst = StepRunner.__new__(StepRunner)
    inst.step_id = "background_classify"
    inst.project_id = "p1"
    inst.episode_id = "e1"
    inst.db = MagicMock()
    inst.run_id = "r1"
    inst.manifest = {}
    inst.project_config = {}
    inst._cp_dir = tmp_path / "cp"
    inst._cp_dir.mkdir(parents=True, exist_ok=True)
    return inst


def _write(path: Path, payload: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload), encoding="utf-8")


# ── primary path ────────────────────────────────────────────────────────


def test_load_returns_manifest_when_present(fake_runner):
    """manifest.json 존재 + 정상 → 그 데이터를 반환 (archive fallback 미작동)."""
    payload = {"status": "completed", "data": {"x": 1}}
    _write(fake_runner._cp_dir / "manifest.json", payload)

    result = fake_runner.load_checkpoint()
    assert result == payload


def test_load_returns_manifest_when_present_valid_data(fake_runner):
    """manifest.json + archive 둘 다 있어도 manifest가 우선."""
    primary = {"status": "completed", "data": {"primary": True}}
    archive = {"status": "completed", "data": {"archived": True}}
    _write(fake_runner._cp_dir / "manifest.json", primary)
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", archive)

    result = fake_runner.load_checkpoint()
    assert result == primary


# ── fallback path ───────────────────────────────────────────────────────


def test_load_falls_back_to_archive_when_manifest_missing(fake_runner):
    """manifest.json 없으면 최신 archive를 자동 복원 + 반환."""
    payload = {"status": "completed", "data": {"recovered": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", payload)

    result = fake_runner.load_checkpoint()
    assert result == payload


def test_load_falls_back_to_archive_when_manifest_empty(fake_runner):
    """manifest.json이 빈 파일이면 archive로 fallback (오늘 사고 시퀀스)."""
    (fake_runner._cp_dir / "manifest.json").write_text("", encoding="utf-8")
    payload = {"status": "completed", "data": {"recovered": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", payload)

    result = fake_runner.load_checkpoint()
    assert result == payload


def test_load_falls_back_to_archive_when_manifest_corrupted(fake_runner):
    """manifest.json이 잘못된 JSON이면 archive로 fallback."""
    (fake_runner._cp_dir / "manifest.json").write_text("{not valid json", encoding="utf-8")
    payload = {"status": "completed", "data": {"recovered": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", payload)

    result = fake_runner.load_checkpoint()
    assert result == payload


def test_load_picks_latest_archive(fake_runner):
    """여러 archive 중 timestamp 기준 가장 최신을 사용."""
    older = {"status": "completed", "version": "older"}
    middle = {"status": "completed", "version": "middle"}
    latest = {"status": "completed", "version": "latest"}
    _write(fake_runner._cp_dir / "manifest_20260501_100000.json", older)
    _write(fake_runner._cp_dir / "manifest_20260501_120000.json", middle)
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", latest)

    result = fake_runner.load_checkpoint()
    assert result == latest


def test_load_returns_none_when_no_archive_either(fake_runner):
    """manifest.json 없고 archive도 없으면 None (legacy 동작 유지)."""
    result = fake_runner.load_checkpoint()
    assert result is None


def test_archive_pattern_matches_real_format(fake_runner):
    """실 환경의 archive 파일명 패턴 검증.

    예: manifest_20260501_131932.json  (clear_checkpoint 경로)
        manifest_20260501_131932_a1b2c3d4.json  (collision 시 runid suffix)
    """
    # _archive_manifest 가 만들 수 있는 두 패턴 모두 인식해야 함.
    payload_a = {"status": "completed", "data": {"variant": "ts_only"}}
    payload_b = {"status": "completed", "data": {"variant": "ts_runid"}}
    _write(fake_runner._cp_dir / "manifest_20260101_080000.json", payload_a)
    _write(fake_runner._cp_dir / "manifest_20260501_131932_a1b2c3d4.json", payload_b)

    result = fake_runner.load_checkpoint()
    # 최신은 20260501_131932 (suffix 무관, timestamp 기준)
    assert result == payload_b


def test_load_returns_none_when_archive_corrupted(fake_runner):
    """archive마저 잘못된 JSON이면 None (best-effort 한계)."""
    (fake_runner._cp_dir / "manifest_20260501_131932.json").write_text(
        "{still not valid", encoding="utf-8"
    )
    result = fake_runner.load_checkpoint()
    assert result is None


def test_archive_auto_restored_to_manifest_json(fake_runner):
    """fallback 시 archive 데이터를 manifest.json에 자동 기록 (이후 호출은 primary 경로)."""
    payload = {"status": "completed", "data": {"recovered": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", payload)

    # 1회 fallback 호출
    fake_runner.load_checkpoint()

    # manifest.json이 archive 내용으로 복구되어야 함
    manifest_path = fake_runner._cp_dir / "manifest.json"
    assert manifest_path.exists()
    restored = json.loads(manifest_path.read_text(encoding="utf-8"))
    assert restored == payload


def test_unrelated_files_in_cp_dir_ignored(fake_runner):
    """archive 패턴과 무관한 파일은 _find_latest_archive가 무시."""
    # 무관 파일들
    (fake_runner._cp_dir / "scratch.json").write_text("{}", encoding="utf-8")
    (fake_runner._cp_dir / "manifest_invalid_format.json").write_text("{}", encoding="utf-8")
    (fake_runner._cp_dir / "stuff.txt").write_text("noise", encoding="utf-8")

    payload = {"status": "completed", "data": {"recovered": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", payload)

    result = fake_runner.load_checkpoint()
    assert result == payload


def test_find_latest_archive_returns_none_when_dir_missing(tmp_path):
    """_cp_dir 자체가 없으면 None."""
    inst = StepRunner.__new__(StepRunner)
    inst.step_id = "x"
    inst._cp_dir = tmp_path / "nonexistent"
    assert inst._find_latest_archive() is None


# ── Codex P2-1: archive running snapshot 거부 ───────────────────────────


def test_archive_running_snapshot_skipped(fake_runner):
    """P2-1 핵심: status='running' archive는 복구 후보에서 제외.

    incremental save_checkpoint(status='running') archive를 복원하면
    step_run.status='completed'로 남고 cp는 running snapshot이 되어
    sync 누락 + downstream silent skip 사고 발생.
    """
    running_payload = {"status": "running", "data": {"in_progress": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_140000.json", running_payload)

    # manifest.json 없음 + 후보가 running 뿐 → None 반환
    assert fake_runner._find_latest_archive() is None
    assert fake_runner.load_checkpoint() is None


def test_archive_picks_latest_completed_skipping_running(fake_runner):
    """P2-1: 최신은 running, 이전은 completed → completed 선택.

    실 시나리오: incremental running snapshot 작성 후 backend kill.
        archive 디렉토리:
            manifest_20260501_140000.json  (running, 가장 최신)
            manifest_20260501_120000.json  (completed)
        → completed archive 복구해야 함.
    """
    running = {"status": "running", "data": {"order": "newer"}}
    completed = {"status": "completed", "data": {"order": "older"}}
    _write(fake_runner._cp_dir / "manifest_20260501_140000.json", running)
    _write(fake_runner._cp_dir / "manifest_20260501_120000.json", completed)

    result = fake_runner.load_checkpoint()
    assert result == completed


def test_archive_returns_none_when_only_running_archives(fake_runner):
    """모두 running snapshot이면 None — 사용자가 force 재실행 필요."""
    for ts in ("100000", "110000", "120000"):
        _write(
            fake_runner._cp_dir / f"manifest_20260501_{ts}.json",
            {"status": "running", "ts": ts},
        )

    assert fake_runner._find_latest_archive() is None
    assert fake_runner.load_checkpoint() is None


def test_archive_partial_status_accepted(fake_runner):
    """status='partial'은 final 상태로 인정 (completed와 동급)."""
    partial_payload = {"status": "partial", "data": {"completed": 3, "total": 5}}
    _write(fake_runner._cp_dir / "manifest_20260501_140000.json", partial_payload)

    result = fake_runner.load_checkpoint()
    assert result == partial_payload


def test_archive_legacy_no_status_field_accepted(fake_runner):
    """status 필드 부재 (legacy archive)는 final로 간주 — 보수적 호환."""
    legacy_payload = {"data": {"legacy": True}}  # status 키 없음
    _write(fake_runner._cp_dir / "manifest_20260501_140000.json", legacy_payload)

    result = fake_runner.load_checkpoint()
    assert result == legacy_payload


def test_archive_corrupted_skipped_falls_through_to_next(fake_runner):
    """손상된 archive는 skip하고 다음 후보 시도."""
    completed_older = {"status": "completed", "data": {"order": "older"}}
    # 최신 archive는 손상
    (fake_runner._cp_dir / "manifest_20260501_140000.json").write_text(
        "{not valid json", encoding="utf-8"
    )
    _write(fake_runner._cp_dir / "manifest_20260501_120000.json", completed_older)

    result = fake_runner.load_checkpoint()
    assert result == completed_older


def test_archive_running_then_completed_then_running_picks_completed(fake_runner):
    """running → completed → running 순서에서 completed 선택 (가장 최신 final)."""
    _write(
        fake_runner._cp_dir / "manifest_20260501_140000.json",
        {"status": "running", "data": {"order": "running_newest"}},
    )
    _write(
        fake_runner._cp_dir / "manifest_20260501_130000.json",
        {"status": "completed", "data": {"order": "completed_middle"}},
    )
    _write(
        fake_runner._cp_dir / "manifest_20260501_120000.json",
        {"status": "running", "data": {"order": "running_oldest"}},
    )

    result = fake_runner.load_checkpoint()
    assert result["data"]["order"] == "completed_middle"


# ── Claude F4-1: force_cleared marker 패턴 ──────────────────────────────


def test_clear_checkpoint_creates_force_marker(fake_runner):
    """clear_checkpoint 호출 시 .force_cleared marker 작성."""
    # 기존 cp 있음 → clear 호출
    _write(fake_runner._cp_dir / "manifest.json", {"status": "completed", "x": 1})

    fake_runner.clear_checkpoint()

    marker = fake_runner._cp_dir / ".force_cleared"
    assert marker.exists()
    # manifest.json은 삭제되고 archive로 보관됨
    assert not (fake_runner._cp_dir / "manifest.json").exists()


def test_clear_checkpoint_creates_marker_even_when_no_existing_cp(fake_runner):
    """기존 cp 없어도 marker는 작성 — force 의도 명확화."""
    # cp_dir 비어있음
    fake_runner.clear_checkpoint()

    marker = fake_runner._cp_dir / ".force_cleared"
    assert marker.exists()


def test_force_cleared_marker_blocks_archive_restore(fake_runner):
    """F4-1 핵심: marker 있으면 archive 있어도 복원 안 함.

    실 시나리오 (2026-05-01 13:13 사고):
        1. force 호출 → clear_checkpoint() → archive + marker 작성
        2. step 실행 시작 → backend kill (save 누락)
        3. 재시작 → load_checkpoint() → archive 있지만 marker로 차단 → None
        4. 다음 실행 = stale 인식하여 force 진행 (force 의도 보존)
    """
    archive_payload = {"status": "completed", "data": {"old_run": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_131932.json", archive_payload)
    # marker 작성 (clear_checkpoint이 한 효과 흉내)
    (fake_runner._cp_dir / ".force_cleared").touch()

    result = fake_runner.load_checkpoint()
    assert result is None  # archive 있어도 복원 안 됨


def test_force_cleared_marker_blocks_manifest_load(fake_runner):
    """marker 있으면 manifest.json 정상이어도 stale 처리.

    archive fallback이 manifest.json으로 복원했을 가능성 — 그래도 force 의도 보존.
    """
    _write(fake_runner._cp_dir / "manifest.json", {"status": "completed", "x": 1})
    (fake_runner._cp_dir / ".force_cleared").touch()

    result = fake_runner.load_checkpoint()
    assert result is None


def test_save_checkpoint_completed_clears_force_marker(fake_runner, monkeypatch):
    """force 후 정상 진행 완료 → marker 자동 제거.

    save_checkpoint이 status='completed'/'partial' 로 호출되면 marker 제거.
    이후 load_checkpoint은 정상 manifest.json 반환 (stale 무효화 종료).
    """
    monkeypatch.setattr(fake_runner, "_resolve_model", lambda: "test-model")

    # marker 작성된 상태에서 save_checkpoint(completed)
    (fake_runner._cp_dir / ".force_cleared").touch()
    fake_runner.save_checkpoint({"status": "completed", "data": {"new_run": True}})

    # marker 제거됨
    assert not (fake_runner._cp_dir / ".force_cleared").exists()
    # 다음 load는 새 manifest.json 정상 반환
    result = fake_runner.load_checkpoint()
    assert result is not None
    assert result["status"] == "completed"
    assert result["data"]["new_run"] is True


def test_save_checkpoint_running_keeps_force_marker(fake_runner, monkeypatch):
    """incremental save_checkpoint(status='running')는 marker 유지 — force 의도 보존."""
    monkeypatch.setattr(fake_runner, "_resolve_model", lambda: "test-model")

    (fake_runner._cp_dir / ".force_cleared").touch()
    fake_runner.save_checkpoint({"status": "running", "data": {"in_progress": True}})

    # marker 그대로 (다음 backend kill 시 force 보존)
    assert (fake_runner._cp_dir / ".force_cleared").exists()


def test_save_checkpoint_partial_clears_force_marker(fake_runner, monkeypatch):
    """status='partial'도 final 상태 — marker 제거."""
    monkeypatch.setattr(fake_runner, "_resolve_model", lambda: "test-model")

    (fake_runner._cp_dir / ".force_cleared").touch()
    fake_runner.save_checkpoint({"status": "partial", "data": {"some": 1}})

    assert not (fake_runner._cp_dir / ".force_cleared").exists()


def test_full_force_kill_restart_scenario(fake_runner, monkeypatch):
    """F4-1 통합 시나리오 (실 사고 재현):

    1. 기존 archive 있음 (이전 completed run).
    2. clear_checkpoint() → archive + .force_cleared 작성, manifest.json 삭제.
    3. backend kill 흉내 — save_checkpoint 안 함.
    4. load_checkpoint() → marker로 archive/manifest 모두 차단 → None.
    5. 다음 force 진행 → save_checkpoint(completed) → marker 제거.
    6. 다음 load_checkpoint() → 새 manifest.json 정상 반환.
    """
    monkeypatch.setattr(fake_runner, "_resolve_model", lambda: "test-model")

    # 1. 사전 archive
    pre_archive = {"status": "completed", "data": {"pre_force_run": True}}
    _write(fake_runner._cp_dir / "manifest_20260501_120000.json", pre_archive)

    # 2. clear_checkpoint
    fake_runner.clear_checkpoint()
    assert (fake_runner._cp_dir / ".force_cleared").exists()
    assert not (fake_runner._cp_dir / "manifest.json").exists()

    # 3. backend kill 흉내 — save 호출 안 함

    # 4. load_checkpoint이 archive 있어도 None
    result = fake_runner.load_checkpoint()
    assert result is None, "Archive should be blocked by .force_cleared marker"

    # 5. 다음 force 진행 — save_checkpoint(completed)
    new_data = {"status": "completed", "data": {"after_force": True}}
    fake_runner.save_checkpoint(new_data)
    assert not (fake_runner._cp_dir / ".force_cleared").exists()

    # 6. load는 새 데이터
    result2 = fake_runner.load_checkpoint()
    assert result2 is not None
    assert result2["status"] == "completed"
    assert result2["data"]["after_force"] is True


def test_force_marker_resilient_to_unwritable_dir(fake_runner, monkeypatch):
    """marker 작성 실패는 best-effort — fix 자체 깨지지 않음.

    파일시스템 에러 시 marker는 못 만들지만 clear는 진행되어야 함 (legacy 동작).
    """
    _write(fake_runner._cp_dir / "manifest.json", {"status": "completed"})

    # touch 호출을 일부러 실패시킴
    original_touch = type(fake_runner._cp_dir / ".force_cleared").touch

    def _fail_touch(self, *args, **kwargs):
        raise OSError("simulated readonly fs")

    monkeypatch.setattr(Path, "touch", _fail_touch)

    # 예외 없이 진행 — fix가 best-effort
    fake_runner.clear_checkpoint()
    # manifest는 정상 삭제됨 (clear는 진행)
    assert not (fake_runner._cp_dir / "manifest.json").exists()

    # 복원
    monkeypatch.setattr(Path, "touch", original_touch)


def test_load_no_archive_no_manifest_no_marker_returns_none(fake_runner):
    """marker도 없고 cp도 없으면 None — legacy 동작 (sanity check)."""
    assert fake_runner.load_checkpoint() is None
    assert not (fake_runner._cp_dir / ".force_cleared").exists()
