#!/usr/bin/env python3
"""task-2959: done-watcher.py 성공 완료 알림(process_success_done_files 등) 테스트.

★ 대상: `.done` 파일 성공 완료 → Telegram 알림 파이프라인
  - _load_or_bootstrap_watermark() / scan_success_done_files() /
    build_success_message() / process_success_done_files()

★ 절대 규칙:
  - 실제 텔레그램 API 호출 금지 → send_telegram_notification 을 monkeypatch 로 가로챈다.
  - 모든 모듈 전역(EVENTS_DIR/WORKSPACE_ROOT/WATERMARK_FILE/DONE_PROTOCOL_LOG/
    BOT_ACTIVITY_FILE)을 tmp_path 기반으로 재지정해 실제 워크스페이스를 절대 건드리지 않는다.
  - 제품 코드(scripts/done-watcher.py)는 읽기 전용. 이 파일만 생성/수정한다.
"""

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

import pytest

_SCRIPTS_DIR = Path(__file__).parent.parent
_MODULE_PATH = _SCRIPTS_DIR / "done-watcher.py"


def _load_module():
    """done-watcher.py 를 매 테스트마다 독립된 모듈 인스턴스로 새로 로드한다.

    - 파일명이 하이픈을 포함하므로 importlib.util.spec_from_file_location 사용.
    - sys.modules 에 등록하지 않아 test_done_watcher.py 등 다른 테스트 파일이
      `__import__("done-watcher")` 로 캐시해 둔 모듈과 전역 상태를 공유하지 않는다.
    - 매 테스트마다 파일에서 새로 로드하므로, 루가 동시 수정 중인
      build_success_message() 의 "현재 시점" 구현을 그대로 검증한다.
    """
    spec = importlib.util.spec_from_file_location(
        f"done_watcher_success_notify_test_{id(object())}", _MODULE_PATH
    )
    assert spec is not None
    module = importlib.util.module_from_spec(spec)
    assert spec.loader is not None
    spec.loader.exec_module(module)
    return module


@pytest.fixture
def dw(tmp_path, monkeypatch):
    """격리된 done-watcher 모듈 인스턴스 + tmp_path 기반 전역 재지정."""
    module = _load_module()

    events_dir = tmp_path / "memory" / "events"
    events_dir.mkdir(parents=True)
    reports_dir = tmp_path / "memory" / "reports"
    reports_dir.mkdir(parents=True)
    logs_dir = tmp_path / "logs"
    logs_dir.mkdir(parents=True)

    module.WORKSPACE_ROOT = str(tmp_path)
    module.EVENTS_DIR = events_dir
    module.WATERMARK_FILE = events_dir / ".done-notify-watermark.json"
    module.DONE_PROTOCOL_LOG = logs_dir / "done-protocol.log"
    module.BOT_ACTIVITY_FILE = events_dir / "bot-activity.json"

    # 안전장치: 실수로라도 실제 텔레그램 API가 호출되면 즉시 실패시킨다.
    def _forbidden_telegram_call(message):
        raise AssertionError(
            "실제 send_telegram_notification 이 monkeypatch 없이 호출됨 — 테스트에서 반드시 가로채야 함"
        )

    monkeypatch.setattr(module, "send_telegram_notification", _forbidden_telegram_call)
    monkeypatch.delenv("DONE_WATCHER_SUCCESS_NOTIFY", raising=False)
    monkeypatch.delenv("ANU_BOT_TOKEN", raising=False)

    return module


class _TelegramRecorder:
    """send_telegram_notification 을 가로채 호출 이력을 기록하는 스텁."""

    def __init__(self, result: bool = True):
        self.result = result
        self.calls: list[str] = []

    def __call__(self, message: str) -> bool:
        self.calls.append(message)
        return self.result


def _write_done(events_dir: Path, name: str, status: str = "success", extra: dict | None = None) -> Path:
    payload = {"task_id": name.replace(".done", ""), "status": status}
    if extra:
        payload.update(extra)
    path = events_dir / name
    path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
    return path


def _set_mtime(path: Path, epoch: float) -> None:
    os.utime(path, (epoch, epoch))


def _write_watermark(dw_module, epoch: float) -> None:
    payload = {"created_at": "irrelevant-for-test", "epoch": epoch}
    dw_module.WATERMARK_FILE.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")


class TestBootstrapSafety:
    """1. 워터마크 없음 + 과거 .done 백로그 → 전부 스킵 (202건 폭발 방지 가드)"""

    def test_bootstrap_skips_backlog_and_creates_watermark(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        old_epoch = time.time() - (10 * 86400)  # 10일 전
        for i in range(3):
            done_file = _write_done(dw.EVENTS_DIR, f"task-9{i}00.done")
            _set_mtime(done_file, old_epoch)

        assert not dw.WATERMARK_FILE.exists()

        result = dw.process_success_done_files()

        assert result == 0
        assert recorder.calls == []
        assert dw.WATERMARK_FILE.exists()
        # 워터마크 파일 유효 JSON + epoch 필드 보유
        wm = json.loads(dw.WATERMARK_FILE.read_text(encoding="utf-8"))
        assert "epoch" in wm


class TestNewCompletionNotify:
    """2. 워터마크 이후 mtime 을 가진 신규 .done → 알림 1회, 원본 파일 보존"""

    def test_new_done_after_watermark_notifies_once(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        epoch = time.time() - 60
        _write_watermark(dw, epoch)

        done_file = _write_done(dw.EVENTS_DIR, "task-3001.done")
        _set_mtime(done_file, epoch + 30)

        result = dw.process_success_done_files()

        assert result == 1
        assert len(recorder.calls) == 1
        marker = dw.EVENTS_DIR / "task-3001.done.notified"
        assert marker.exists()
        # 원본 .done 파일이 삭제/이름변경 되지 않고 그대로 존재
        assert done_file.exists()
        assert done_file.read_text(encoding="utf-8")  # 내용도 그대로 읽힘


class TestDuplicatePrevention:
    """3. 동일 상태로 재실행 → 추가 전송 0회"""

    def test_second_run_sends_nothing(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        done_file = _write_done(dw.EVENTS_DIR, "task-3002.done")
        _set_mtime(done_file, epoch + 30)

        first = dw.process_success_done_files()
        assert first == 1
        assert len(recorder.calls) == 1

        second = dw.process_success_done_files()
        assert second == 0
        assert len(recorder.calls) == 1  # 추가 호출 없음


class TestFailExclusion:
    """4. status=fail/failed/error 또는 동일 task_id .failed 마커 존재 → 전송 0회"""

    @pytest.mark.parametrize("status", ["fail", "failed", "error"])
    def test_fail_status_excluded(self, dw, monkeypatch, status):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        done_file = _write_done(dw.EVENTS_DIR, f"task-40{ord(status[0])}.done", status=status)
        _set_mtime(done_file, epoch + 30)

        result = dw.process_success_done_files()
        assert result == 0
        assert recorder.calls == []

    def test_failed_marker_excludes_even_if_status_success(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        done_file = _write_done(dw.EVENTS_DIR, "task-4001.done", status="success")
        _set_mtime(done_file, epoch + 30)
        (dw.EVENTS_DIR / "task-4001.failed").write_text("{}", encoding="utf-8")

        result = dw.process_success_done_files()
        assert result == 0
        assert recorder.calls == []


class TestRateLimitPerCycle:
    """5. 신규 .done 7건 → 1주기 5건, 2주기 나머지 2건"""

    def test_seven_new_done_split_across_two_cycles(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        assert dw.MAX_NOTIFY_PER_CYCLE == 5

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        for i in range(7):
            done_file = _write_done(dw.EVENTS_DIR, f"task-50{i:02d}.done")
            _set_mtime(done_file, epoch + 30)

        first_cycle = dw.process_success_done_files()
        assert first_cycle == 5
        assert len(recorder.calls) == 5

        markers_after_first = list(dw.EVENTS_DIR.glob("*.done.notified"))
        assert len(markers_after_first) == 5

        second_cycle = dw.process_success_done_files()
        assert second_cycle == 2
        assert len(recorder.calls) == 7

        markers_after_second = list(dw.EVENTS_DIR.glob("*.done.notified"))
        assert len(markers_after_second) == 7


class TestSendFailureNoMarker:
    """6. 전송 실패 시 .notified 마커 미생성 → 다음 주기 재시도 보장"""

    def test_marker_not_created_when_send_fails(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=False)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        done_file = _write_done(dw.EVENTS_DIR, "task-6001.done")
        _set_mtime(done_file, epoch + 30)

        result = dw.process_success_done_files()

        assert result == 0
        assert len(recorder.calls) == 1  # 시도는 했음
        marker = dw.EVENTS_DIR / "task-6001.done.notified"
        assert not marker.exists()
        assert done_file.exists()  # 원본은 그대로


class TestKillSwitch:
    """7. DONE_WATCHER_SUCCESS_NOTIFY=0 → 전송 0회"""

    def test_kill_switch_disables_all_sends(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)
        monkeypatch.setenv("DONE_WATCHER_SUCCESS_NOTIFY", "0")

        epoch = time.time() - 60
        _write_watermark(dw, epoch)
        done_file = _write_done(dw.EVENTS_DIR, "task-7001.done")
        _set_mtime(done_file, epoch + 30)

        result = dw.process_success_done_files()
        assert result == 0
        assert recorder.calls == []


class TestCorruptWatermark:
    """8. 워터마크 손상 → 전송 0회(스팸보다 미알림이 안전) + 재생성"""

    def test_corrupt_watermark_skips_and_regenerates(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        dw.EVENTS_DIR.mkdir(parents=True, exist_ok=True)
        dw.WATERMARK_FILE.write_text("not json!!", encoding="utf-8")

        done_file = _write_done(dw.EVENTS_DIR, "task-8001.done")
        _set_mtime(done_file, time.time())

        result = dw.process_success_done_files()

        assert result == 0
        assert recorder.calls == []
        assert dw.WATERMARK_FILE.exists()
        # 재생성된 워터마크는 유효 JSON 이어야 함
        regenerated = json.loads(dw.WATERMARK_FILE.read_text(encoding="utf-8"))
        assert "epoch" in regenerated
        assert isinstance(regenerated["epoch"], (int, float))


class TestPrNumberFalsePositiveRegression:
    """9. ★ 가장 중요: 선행 task 의 PR 번호를 자기 것처럼 표기하는 오탐 회귀 테스트.

    실제 사고: task-2953 리포트 본문에 선행 task-2952(PR #213)가 언급되어
    있었는데, build_success_message() 가 본문 어디서든 첫 `#\\d+` 를 주워
    "PR#213" 으로 잘못 표기했다 (task-2953 의 실제 PR 은 #214).
    """

    def test_prior_task_pr_number_must_not_leak(self, dw):
        reports_dir = Path(dw.WORKSPACE_ROOT) / "memory" / "reports"
        report_path = reports_dir / "task-2953.md"
        report_path.write_text(
            "# task-2953 · 실손 표준약관 대조\n"
            "\n"
            "**S (상황)** — 선행 task-2952(PR #213 MERGED `ea3691c`)로 "
            "실손 세대지식 최종본이 확정됐으나...\n",
            encoding="utf-8",
        )

        done_file = dw.EVENTS_DIR / "task-2953.done"
        done_file.write_text(json.dumps({"task_id": "task-2953", "status": "success"}), encoding="utf-8")

        message = dw.build_success_message(done_file)

        assert "PR#213" not in message, (
            "선행 task-2952 의 PR 번호(#213)가 task-2953 자신의 PR 번호로 잘못 표기됨 "
            f"— 실제 메시지: {message!r}"
        )


class TestNormalPrExtractionStillWorks:
    """10. 자기 task 줄의 정상 PR 번호는 계속 추출되어야 함 (9번 규칙의 과잉필터 방지 확인)"""

    def test_own_pr_number_is_extracted(self, dw):
        reports_dir = Path(dw.WORKSPACE_ROOT) / "memory" / "reports"
        report_path = reports_dir / "task-2959.md"
        report_path.write_text(
            "# task-2959 · done-watcher 성공 완료 알림 배선\n"
            "\n"
            "**PR#219** 정상 처리 완료 — 성공 .done 감지 시 텔레그램 알림 전송\n",
            encoding="utf-8",
        )

        done_file = dw.EVENTS_DIR / "task-2959.done"
        done_file.write_text(json.dumps({"task_id": "task-2959", "status": "success"}), encoding="utf-8")

        message = dw.build_success_message(done_file)

        assert "PR#219" in message


class TestReportMissingFallback:
    """11. 리포트 부재 → task_id + 리포트 경로 포함, 예외 없음"""

    def test_missing_report_falls_back_gracefully(self, dw):
        done_file = dw.EVENTS_DIR / "task-1199.done"
        done_file.write_text(json.dumps({"task_id": "task-1199", "status": "success"}), encoding="utf-8")

        message = dw.build_success_message(done_file)

        assert "task-1199" in message
        assert "memory/reports/task-1199.md" in message


class TestNoRegressionOnExistingPaths:
    """12. process_failed_files() FAIL 알림 / process_done_files() idle 전환이 성공 알림 추가로 안 깨졌는지 확인"""

    def test_process_failed_files_still_notifies_and_acks(self, dw, monkeypatch):
        recorder = _TelegramRecorder(result=True)
        monkeypatch.setattr(dw, "send_telegram_notification", recorder)

        failed_file = dw.EVENTS_DIR / "task-1301.failed"
        failed_file.write_text(
            json.dumps({"task_id": "task-1301", "fail_reason": "QC FAIL 테스트"}), encoding="utf-8"
        )

        processed = dw.process_failed_files()

        assert processed == 1
        assert len(recorder.calls) == 1
        assert "[QC FAIL]" in recorder.calls[0]
        assert not failed_file.exists()
        assert (dw.EVENTS_DIR / "task-1301.failed.acked").exists()

    def test_process_done_files_still_sets_bot_idle(self, dw):
        dw.BOT_ACTIVITY_FILE.write_text(
            json.dumps({"bots": {"dev1": {"status": "processing", "since": "2026-01-01T00:00:00Z"}}}),
            encoding="utf-8",
        )
        (dw.EVENTS_DIR / "task-1302.1.dev1.done").write_text("{}", encoding="utf-8")

        processed = dw.process_done_files()

        assert processed == 1
        saved = json.loads(dw.BOT_ACTIVITY_FILE.read_text(encoding="utf-8"))
        assert saved["bots"]["dev1"]["status"] == "idle"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
