#!/usr/bin/env python3
"""activity-watcher.py 테스트 스위트 (task-902.1 반영)"""

import json
import os
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import requests as real_requests

# 테스트용 환경변수 설정 (activity-watcher 모듈 임포트 시에만 필요)
_ORIGINAL_WORKSPACE_ROOT = os.environ.get("WORKSPACE_ROOT")
_TEMP_WORKSPACE = tempfile.mkdtemp()
os.environ["WORKSPACE_ROOT"] = _TEMP_WORKSPACE
os.environ["COKACDIR_CHAT_ID"] = "test_chat_id"
# ANU_BOT_TOKEN으로 변경 (COKACDIR_KEY_ANU 더 이상 불필요)
os.environ["ANU_BOT_TOKEN"] = "test_bot_token"

# scripts 디렉토리를 path에 추가
sys.path.insert(0, str(Path(__file__).parent.parent))

# 모듈 import
aw = __import__("activity-watcher")

# 임포트 완료 후 WORKSPACE_ROOT 복원 (다른 테스트에 영향 방지)
if _ORIGINAL_WORKSPACE_ROOT is None:
    os.environ.pop("WORKSPACE_ROOT", None)
else:
    os.environ["WORKSPACE_ROOT"] = _ORIGINAL_WORKSPACE_ROOT


class TestLoadBotActivity:
    """bot-activity.json 로드 테스트"""

    def test_load_valid_json(self, tmp_path):
        """유효한 JSON 로드"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        bot_activity_file.parent.mkdir(parents=True)
        bot_activity_file.write_text(
            json.dumps(
                {
                    "bots": {
                        "dev1": {"status": "idle", "since": "2026-03-16T00:00:00Z"},
                        "dev2": {"status": "processing", "since": "2026-03-16T00:01:00Z"},
                    }
                }
            )
        )

        aw.BOT_ACTIVITY_FILE = bot_activity_file
        result = aw.load_bot_activity()

        assert result["bots"]["dev1"]["status"] == "idle"
        assert result["bots"]["dev2"]["status"] == "processing"

    def test_load_missing_file(self, tmp_path):
        """파일이 없으면 빈 dict 반환"""
        aw.BOT_ACTIVITY_FILE = tmp_path / "nonexistent.json"
        result = aw.load_bot_activity()
        assert result == {"bots": {}}

    def test_load_invalid_json(self, tmp_path):
        """JSON 파싱 에러 시 빈 dict 반환"""
        bot_activity_file = tmp_path / "bot-activity.json"
        bot_activity_file.write_text("invalid json {")
        aw.BOT_ACTIVITY_FILE = bot_activity_file

        result = aw.load_bot_activity()
        assert result == {"bots": {}}


class TestFindDoneFile:
    """done 파일 찾기 테스트 (팀 기반 매칭)"""

    def test_find_done_file_team_based(self, tmp_path):
        """task-timers.json 기반 팀 매칭으로 .done 파일 반환"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        done_file = events_dir / "task-123.1.done"
        done_file.write_text("{}")

        # task-timers.json 생성
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-123.1": {
                            "team_id": "dev2-team",
                            "status": "running",
                        }
                    }
                }
            )
        )

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")

        assert result == done_file

    def test_find_done_file_not_exists(self, tmp_path):
        """done 파일 없으면 None 반환"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        # task-timers.json: 활성 태스크 있지만 .done 파일 없음
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-456.1": {
                            "team_id": "dev2-team",
                            "status": "running",
                        }
                    }
                }
            )
        )

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")

        assert result is None

    def test_find_done_file_ignores_processed_extensions(self, tmp_path):
        """.done.notified, .done.acked 등이 있으면 해당 .done은 무시"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        (events_dir / "task-123.1.done").write_text("{}")
        (events_dir / "task-123.1.done.notified").write_text("")  # 이미 알림됨

        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-123.1": {
                            "team_id": "dev2-team",
                            "status": "running",
                        }
                    }
                }
            )
        )

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")

        assert result is None

    def test_find_done_file_ignores_acked(self, tmp_path):
        """.done.acked 존재 시 무시"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        (events_dir / "task-123.1.done").write_text("{}")
        (events_dir / "task-123.1.done.acked").write_text("")

        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-123.1": {
                            "team_id": "dev2-team",
                            "status": "running",
                        }
                    }
                }
            )
        )

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")

        assert result is None

    def test_find_done_file_no_active_task(self, tmp_path):
        """활성 태스크 없으면 None 반환"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        (events_dir / "task-123.1.done").write_text("{}")

        # task-timers.json: dev2 팀 없음
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(json.dumps({"tasks": {}}))

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")

        assert result is None

    def test_find_done_file_anu_returns_none(self, tmp_path):
        """anu는 BOT_TEAM_MAP에서 None이므로 항상 None"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        aw.EVENTS_DIR = events_dir
        result = aw.find_done_file("anu")
        assert result is None

    def test_find_done_file_dev4_to_dev8(self, tmp_path):
        """dev4~dev8 팀 매칭 지원"""
        for dev_num in [4, 5, 6, 7, 8]:
            events_dir = tmp_path / "memory" / "events"
            events_dir.mkdir(parents=True, exist_ok=True)
            done_file = events_dir / f"task-{dev_num}00.1.done"
            done_file.write_text("{}")

            timer_file = tmp_path / "memory" / "task-timers.json"
            timer_file.parent.mkdir(parents=True, exist_ok=True)
            timer_file.write_text(
                json.dumps(
                    {
                        "tasks": {
                            f"task-{dev_num}00.1": {
                                "team_id": f"dev{dev_num}-team",
                                "status": "running",
                            }
                        }
                    }
                )
            )

            aw.EVENTS_DIR = events_dir
            with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
                result = aw.find_done_file(f"dev{dev_num}")

            assert result == done_file, f"dev{dev_num} 팀 매칭 실패"

            # 다음 반복을 위해 파일 삭제
            done_file.unlink()


class TestGetActiveTaskForTeam:
    """get_active_task_for_team 함수 테스트"""

    def test_returns_task_for_matching_team(self, tmp_path):
        """팀에 매칭되는 활성 task_id 반환"""
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-100.1": {"team_id": "dev1-team", "status": "running"},
                        "task-200.1": {"team_id": "dev2-team", "status": "running"},
                    }
                }
            )
        )

        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.get_active_task_for_team("dev1")

        assert result == "task-100.1"

    def test_returns_none_when_no_match(self, tmp_path):
        """매칭 팀 없으면 None 반환"""
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(json.dumps({"tasks": {}}))

        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.get_active_task_for_team("dev9")

        assert result is None

    def test_ignores_non_running_tasks(self, tmp_path):
        """status != running 태스크는 무시"""
        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-100.1": {"team_id": "dev1-team", "status": "done"},
                    }
                }
            )
        )

        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.get_active_task_for_team("dev1")

        assert result is None

    def test_returns_none_when_file_missing(self, tmp_path):
        """task-timers.json 없으면 None 반환"""
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.get_active_task_for_team("dev1")

        assert result is None


class TestCheckAlreadyNotified:
    """이미 알림 보냈는지 확인 테스트 (.done.notified 마커 파일 기반)"""

    def test_already_notified_marker_exists(self, tmp_path):
        """.done.notified 마커 파일 존재 시 True"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        notified_file = events_dir / "task-123.1.done.notified"
        notified_file.touch()

        aw.EVENTS_DIR = events_dir
        result = aw.check_already_notified("task-123.1")
        assert result is True

    def test_not_notified_marker_missing(self, tmp_path):
        """.done.notified 마커 파일 없으면 False"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        aw.EVENTS_DIR = events_dir
        result = aw.check_already_notified("task-123.1")
        assert result is False

    def test_different_task_id_not_confused(self, tmp_path):
        """다른 task_id의 마커는 영향 없음"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        # 다른 태스크의 마커만 있음
        (events_dir / "task-999.1.done.notified").touch()

        aw.EVENTS_DIR = events_dir
        result = aw.check_already_notified("task-123.1")
        assert result is False


class TestSendTelegramNotification:
    """텔레그램 알림 테스트 (requests.post 방식)"""

    def test_send_notification_success(self):
        """알림 전송 성공 (200 응답)"""
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", return_value=mock_resp) as mock_post:
                result = aw.send_telegram_notification("test message", "chat_id")

        assert result is True
        mock_post.assert_called_once()
        call_kwargs = mock_post.call_args[1]
        payload = call_kwargs.get("json", {})
        assert payload.get("text") == "test message"
        assert payload.get("chat_id") == "chat_id"

    def test_send_notification_no_token_returns_false(self):
        """ANU_BOT_TOKEN 없으면 False 반환"""
        env = {k: v for k, v in os.environ.items() if k != "ANU_BOT_TOKEN"}
        with patch.dict("os.environ", env, clear=True):
            result = aw.send_telegram_notification("test message", "chat_id")
        assert result is False

    def test_send_notification_failure_non_200(self):
        """400 응답 시 False 반환"""
        mock_resp = MagicMock()
        mock_resp.status_code = 400
        mock_resp.headers = {}

        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", return_value=mock_resp):
                result = aw.send_telegram_notification("test message", "chat_id")

        assert result is False

    def test_send_notification_429_retry(self):
        """429 응답 시 재시도"""
        mock_429 = MagicMock()
        mock_429.status_code = 429
        mock_429.headers = {"Retry-After": "1"}

        mock_200 = MagicMock()
        mock_200.status_code = 200

        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", side_effect=[mock_429, mock_200]) as mock_post:
                with patch("time.sleep"):
                    result = aw.send_telegram_notification("test message", "chat_id")

        assert result is True
        assert mock_post.call_count == 2

    def test_send_notification_request_exception(self):
        """RequestException 시 재시도 후 False"""
        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", side_effect=real_requests.RequestException("err")):
                with patch("time.sleep"):
                    result = aw.send_telegram_notification("test message", "chat_id")
        assert result is False

    def test_send_notification_timeout_10s(self):
        """timeout=10 사용 확인"""
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", return_value=mock_resp) as mock_post:
                aw.send_telegram_notification("test message", "chat_id")

        call_kwargs = mock_post.call_args[1]
        assert call_kwargs.get("timeout") == 10

    def test_no_cokacdir_in_send(self):
        """cokacdir 명령을 사용하지 않음 (subprocess.run 미호출)"""
        mock_resp = MagicMock()
        mock_resp.status_code = 200

        with patch.dict("os.environ", {"ANU_BOT_TOKEN": "test-token"}):
            with patch("requests.post", return_value=mock_resp):
                with patch("subprocess.run") as mock_run:
                    aw.send_telegram_notification("test message", "chat_id")

        mock_run.assert_not_called()


class TestBotTeamMap:
    """BOT_TEAM_MAP 확장 확인"""

    def test_dev1_to_dev3_mapped(self):
        """dev1~dev3 매핑 확인"""
        for dev in ["dev1", "dev2", "dev3"]:
            assert aw.BOT_TEAM_MAP.get(dev) == dev, f"{dev} 매핑 없음"

    def test_dev4_to_dev8_mapped(self):
        """dev4~dev8 매핑 확인"""
        for dev in ["dev4", "dev5", "dev6", "dev7", "dev8"]:
            assert aw.BOT_TEAM_MAP.get(dev) == dev, f"{dev} 매핑 없음"

    def test_anu_mapped_to_none(self):
        """anu는 None으로 매핑"""
        assert aw.BOT_TEAM_MAP.get("anu") is None


class TestProcessingToIdleDetection:
    """processing → idle 전환 감지 통합 테스트"""

    def test_idle_to_idle_no_notification(self, tmp_path):
        """idle → idle (변화 없음) 시 알림 안 보냄"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        bot_activity_file.parent.mkdir(parents=True)
        bot_activity_file.write_text(
            json.dumps({"bots": {"dev2": {"status": "idle", "since": "2026-03-16T00:00:00Z"}}})
        )

        aw.BOT_ACTIVITY_FILE = bot_activity_file

        prev_activity = aw.load_bot_activity()
        prev_statuses = {}
        for bot_name, bot_data in prev_activity.get("bots", {}).items():
            prev_statuses[bot_name] = bot_data.get("status", "idle")

        curr_statuses = prev_statuses.copy()

        notifications_sent = []
        for bot_name, curr_status in curr_statuses.items():
            prev_status = prev_statuses.get(bot_name, "idle")
            if bot_name == "anu":
                continue
            if prev_status == "processing" and curr_status == "idle":
                notifications_sent.append(bot_name)

        assert len(notifications_sent) == 0

    def test_processing_to_idle_sends_notification(self, tmp_path):
        """processing → idle 전환 시 알림 전송"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)
        done_file = events_dir / "task-123.1.done"
        done_file.write_text("{}")

        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(
            json.dumps(
                {
                    "tasks": {
                        "task-123.1": {
                            "team_id": "dev2-team",
                            "status": "running",
                        }
                    }
                }
            )
        )

        aw.EVENTS_DIR = events_dir
        aw.DONE_PROTOCOL_LOG = tmp_path / "done-protocol.log"

        prev_statuses = {"dev2": "processing"}
        curr_statuses = {"dev2": "idle"}

        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            with patch.object(aw, "send_telegram_notification") as mock_notify:
                mock_notify.return_value = True

                for bot_name, curr_status in curr_statuses.items():
                    prev_status = prev_statuses.get(bot_name, "idle")
                    if bot_name == "anu":
                        continue
                    if prev_status == "processing" and curr_status == "idle":
                        found_done = aw.find_done_file(bot_name)
                        if found_done:
                            task_id = found_done.stem
                            if not aw.check_already_notified(task_id):
                                aw.send_telegram_notification(task_id, "chat_id")

                mock_notify.assert_called_once()

    def test_anu_status_change_ignored(self):
        """아누(anu) 상태 변화 무시"""
        prev_statuses = {"anu": "processing"}
        curr_statuses = {"anu": "idle"}

        notifications_sent = []
        for bot_name, curr_status in curr_statuses.items():
            prev_status = prev_statuses.get(bot_name, "idle")
            if bot_name == "anu":
                continue
            if prev_status == "processing" and curr_status == "idle":
                notifications_sent.append(bot_name)

        assert len(notifications_sent) == 0

    def test_no_done_file_logs_only(self, tmp_path):
        """done 파일 없으면 None 반환"""
        events_dir = tmp_path / "memory" / "events"
        events_dir.mkdir(parents=True)

        timer_file = tmp_path / "memory" / "task-timers.json"
        timer_file.parent.mkdir(parents=True, exist_ok=True)
        timer_file.write_text(json.dumps({"tasks": {}}))

        aw.EVENTS_DIR = events_dir
        with patch.object(aw, "WORKSPACE_ROOT", str(tmp_path)):
            result = aw.find_done_file("dev2")
        assert result is None


class TestInitialState:
    """초기 상태 로드 테스트"""

    def test_initial_state_prevents_false_positive(self, tmp_path):
        """시작 시 초기 상태 로드로 오알림 방지"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        bot_activity_file.parent.mkdir(parents=True)
        bot_activity_file.write_text(
            json.dumps({"bots": {"dev2": {"status": "idle", "since": "2026-03-16T00:00:00Z"}}})
        )

        aw.BOT_ACTIVITY_FILE = bot_activity_file

        prev_activity = aw.load_bot_activity()
        prev_statuses = {}
        for bot_name, bot_data in prev_activity.get("bots", {}).items():
            prev_statuses[bot_name] = bot_data.get("status", "idle")

        curr_statuses = prev_statuses.copy()

        transitions = []
        for bot_name, curr_status in curr_statuses.items():
            prev_status = prev_statuses.get(bot_name, "idle")
            if bot_name == "anu":
                continue
            if prev_status == "processing" and curr_status == "idle":
                transitions.append(bot_name)

        assert len(transitions) == 0


class TestLogProtocol:
    """로그 기록 테스트"""

    def test_log_protocol_writes(self, tmp_path):
        """로그 파일에 기록"""
        log_file = tmp_path / "done-protocol.log"
        aw.DONE_PROTOCOL_LOG = log_file

        aw.log_protocol("test message")

        assert log_file.exists()
        content = log_file.read_text()
        assert "test message" in content
        assert "[activity-watcher]" in content


class TestExtractReportSummary:
    """보고서 요약 추출 테스트"""

    def test_extract_with_scqa(self, tmp_path):
        """SCQA 형식 보고서에서 S와 A 추출"""
        reports_dir = tmp_path / "memory" / "reports"
        reports_dir.mkdir(parents=True)
        report_file = reports_dir / "task-123.1.md"

        report_content = """# task-123.1 완료 보고서

## SCQA

**S**: 사용자가 로그인할 때 500 에러가 발생했다.

**C**: 프로덕션 환경에서 재현이 어렵다.

**Q**: 어떻게 빠르게 원인을 파악할 수 있는가?

**A**: 로그 분석 도구를 도입하여 에러 추적을 자동화했다.

## 구현 상세
"""
        report_file.write_text(report_content)

        aw.WORKSPACE_ROOT = str(tmp_path)
        result = aw.extract_report_summary("task-123.1", "dev2")

        assert "task-123.1" in result
        assert "dev2" in result
        assert "S:" in result
        assert "500 에러" in result
        assert "A:" in result
        assert "로그 분석" in result

    def test_extract_without_report(self, tmp_path):
        """보고서 파일 없을 때 폴백 - 절대 경로 사용"""
        aw.WORKSPACE_ROOT = str(tmp_path)
        result = aw.extract_report_summary("task-404.1", "dev1")

        assert "보고서:" in result
        assert "task-404.1" in result
        assert str(tmp_path) in result or "/home/jay/workspace" in result

    def test_extract_non_scqa_report(self, tmp_path):
        """SCQA 형식이 아닌 보고서도 크래시 없이 처리"""
        reports_dir = tmp_path / "memory" / "reports"
        reports_dir.mkdir(parents=True)
        report_file = reports_dir / "task-456.1.md"

        report_content = """# task-456.1 완료

## 작업 내용
- 버그 수정
- 테스트 추가
"""
        report_file.write_text(report_content)

        aw.WORKSPACE_ROOT = str(tmp_path)
        result = aw.extract_report_summary("task-456.1", "dev3")

        assert "task-456.1" in result
        assert "dev3" in result

    def test_extract_with_files_count(self, tmp_path):
        """수정 파일 수 포함"""
        reports_dir = tmp_path / "memory" / "reports"
        reports_dir.mkdir(parents=True)
        report_file = reports_dir / "task-789.1.md"

        report_content = """# task-789.1 완료

## SCQA

**S**: 문제 발생

**A**: 해결 완료

## 수정 내역
- 수정: 5건
"""
        report_file.write_text(report_content)

        aw.WORKSPACE_ROOT = str(tmp_path)
        result = aw.extract_report_summary("task-789.1", "dev2")

        assert "수정: 5건" in result

    def test_extract_long_text_truncated(self, tmp_path):
        """긴 텍스트는 150자로 잘림"""
        reports_dir = tmp_path / "memory" / "reports"
        reports_dir.mkdir(parents=True)
        report_file = reports_dir / "task-long.1.md"

        long_text = "A" * 200
        report_content = f"""# task-long.1 완료

## SCQA

**S**: {long_text}

**A**: 해결
"""
        report_file.write_text(report_content)

        aw.WORKSPACE_ROOT = str(tmp_path)
        result = aw.extract_report_summary("task-long.1", "dev1")

        lines = result.split("\n")
        s_line = [line for line in lines if line.startswith("S:")][0]
        assert len(s_line) <= 160  # "S: " + 150자 + 여유


class TestCheckAndRecoverStuckBotsAW:
    """activity-watcher.py의 check_and_recover_stuck_bots 테스트 (워치독 통합)"""

    def test_stuck_bot_recovered(self):
        """40분 경과 processing 봇 → idle 전환"""
        forty_min_ago = (datetime.now(timezone.utc) - timedelta(minutes=40)).strftime("%Y-%m-%dT%H:%M:%SZ")
        data = {"bots": {"dev1": {"status": "processing", "since": forty_min_ago}}}
        recovered = aw.check_and_recover_stuck_bots(data)
        assert recovered == 1
        assert data["bots"]["dev1"]["status"] == "idle"

    def test_recent_processing_not_recovered(self):
        """10분 경과 processing 봇 → 유지"""
        ten_min_ago = (datetime.now(timezone.utc) - timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ")
        data = {"bots": {"dev2": {"status": "processing", "since": ten_min_ago}}}
        recovered = aw.check_and_recover_stuck_bots(data)
        assert recovered == 0
        assert data["bots"]["dev2"]["status"] == "processing"

    def test_idle_bot_not_affected(self):
        """idle 봇 → 변경 없음"""
        data = {"bots": {"dev3": {"status": "idle", "since": "2026-01-01T00:00:00Z"}}}
        recovered = aw.check_and_recover_stuck_bots(data)
        assert recovered == 0

    def test_invalid_since_skipped(self):
        """잘못된 since 값은 스킵"""
        data = {"bots": {"dev1": {"status": "processing", "since": "not-a-date"}}}
        recovered = aw.check_and_recover_stuck_bots(data)
        assert recovered == 0


class TestSaveBotActivityAW:
    """activity-watcher.py의 save_bot_activity 테스트"""

    def test_save_creates_file(self, tmp_path):
        """파일 저장 성공"""
        bot_file = tmp_path / "bot-activity.json"
        aw.BOT_ACTIVITY_FILE = bot_file
        data = {"bots": {"dev1": {"status": "idle"}}}
        result = aw.save_bot_activity(data)
        assert result is True
        assert bot_file.exists()

    def test_save_atomic_no_tmp_left(self, tmp_path):
        """원자적 쓰기 후 .tmp 파일 없음"""
        bot_file = tmp_path / "bot-activity.json"
        aw.BOT_ACTIVITY_FILE = bot_file
        aw.save_bot_activity({"bots": {}})
        assert not (tmp_path / "bot-activity.tmp").exists()


class TestUpdateBotSince:
    """since 필드 갱신 테스트"""

    def test_update_bot_since_success(self, tmp_path):
        """since 필드 갱신 성공"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        bot_activity_file.parent.mkdir(parents=True)
        bot_activity_file.write_text(
            json.dumps(
                {
                    "bots": {
                        "dev2": {"status": "idle", "since": "2026-03-01T00:00:00Z"},
                    }
                }
            )
        )

        aw.BOT_ACTIVITY_FILE = bot_activity_file
        aw.update_bot_since("dev2")

        with open(bot_activity_file, "r") as f:
            data = json.load(f)

        assert data["bots"]["dev2"]["since"].startswith("2026-03")

    def test_update_bot_since_nonexistent_bot(self, tmp_path):
        """존재하지 않는 봇은 무시"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        bot_activity_file.parent.mkdir(parents=True)
        bot_activity_file.write_text(
            json.dumps({"bots": {"dev1": {"status": "idle", "since": "2026-03-01T00:00:00Z"}}})
        )

        aw.BOT_ACTIVITY_FILE = bot_activity_file
        aw.update_bot_since("dev99")

        with open(bot_activity_file, "r") as f:
            data = json.load(f)
        assert data["bots"]["dev1"]["since"] == "2026-03-01T00:00:00Z"

    def test_update_bot_since_creates_file(self, tmp_path):
        """파일이 없어도 생성"""
        bot_activity_file = tmp_path / "memory" / "events" / "bot-activity.json"
        aw.BOT_ACTIVITY_FILE = bot_activity_file

        aw.update_bot_since("dev2")

        assert not bot_activity_file.exists()


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