"""
test_whisper_compile.py

scripts/whisper-compile.py 단위 테스트 (TDD)

테스트 항목:
1. 빈 상태 (모든 파일 없음) → 빈 브리핑 정상 출력
2. 정상 상태 → 모든 섹션 포함
3. 부분 파일만 존재 → 해당 섹션만 포함
4. 유휴 팀 3시간 이상 → [유휴경고] 출력
5. 질문 있음/없음
6. 보고서 SCQA 추출 정확성
7. bot-activity.json 파일 손상 시 graceful 처리
"""

import importlib.util
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

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

# whisper-compile.py는 하이픈이 있으므로 importlib으로 임포트
_MODULE_PATH = _SCRIPTS_DIR / "whisper-compile.py"
spec = importlib.util.spec_from_file_location("whisper_compile", _MODULE_PATH)
assert spec is not None
whisper_compile = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(whisper_compile)


# ---------------------------------------------------------------------------
# Helper: 공통 픽스처
# ---------------------------------------------------------------------------


def make_bot_activity(tmp_path: Path, data: dict) -> Path:
    events_dir = tmp_path / "events"
    events_dir.mkdir(parents=True, exist_ok=True)
    f = events_dir / "bot-activity.json"
    f.write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


def make_task_timers(tmp_path: Path, tasks: dict) -> Path:
    f = tmp_path / "task-timers.json"
    f.write_text(json.dumps({"tasks": tasks}), encoding="utf-8")
    return tmp_path


def make_done_file(tmp_path: Path, filename: str, data: dict) -> Path:
    events_dir = tmp_path / "events"
    events_dir.mkdir(parents=True, exist_ok=True)
    f = events_dir / filename
    f.write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


def make_report(tmp_path: Path, filename: str, content: str) -> Path:
    reports_dir = tmp_path / "reports"
    reports_dir.mkdir(parents=True, exist_ok=True)
    f = reports_dir / filename
    f.write_text(content, encoding="utf-8")
    return tmp_path


def make_guidance(tmp_path: Path, data: dict) -> Path:
    whisper_dir = tmp_path / "whisper"
    whisper_dir.mkdir(parents=True, exist_ok=True)
    f = whisper_dir / "session-guidance.json"
    f.write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


def make_question(tmp_path: Path, filename: str, data: dict) -> Path:
    q_dir = tmp_path / "events" / "questions"
    q_dir.mkdir(parents=True, exist_ok=True)
    f = q_dir / filename
    f.write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


def make_project_context(tmp_path: Path, project: str, content: str) -> Path:
    proj_dir = tmp_path / "projects" / project
    proj_dir.mkdir(parents=True, exist_ok=True)
    f = proj_dir / "context.md"
    f.write_text(content, encoding="utf-8")
    return tmp_path


# ---------------------------------------------------------------------------
# 1. 빈 상태 테스트
# ---------------------------------------------------------------------------


class TestEmptyState:
    """모든 파일이 없을 때 graceful 처리"""

    def test_load_bot_activity_missing(self, tmp_path: Path):
        result = whisper_compile.load_bot_activity(base_dir=tmp_path)
        assert result == {}

    def test_load_task_timers_missing(self, tmp_path: Path):
        result = whisper_compile.load_task_timers(base_dir=tmp_path)
        assert result == {}

    def test_scan_done_files_missing(self, tmp_path: Path):
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert result == []

    def test_load_guidance_missing(self, tmp_path: Path):
        result = whisper_compile.load_guidance(base_dir=tmp_path)
        assert result == {}

    def test_load_questions_missing(self, tmp_path: Path):
        result = whisper_compile.load_questions(base_dir=tmp_path)
        assert result == []

    def test_load_project_context_missing(self, tmp_path: Path):
        result = whisper_compile.load_project_context(base_dir=tmp_path)
        assert result == {}

    def test_compile_briefing_empty(self, tmp_path: Path):
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert output.startswith("<whisper-briefing>")
        assert output.strip().endswith("</whisper-briefing>")
        # 빈 상태에서도 기본 섹션은 존재해야 함
        assert "[팀]" in output
        assert "[가이던스]" in output

    def test_compile_briefing_no_exception(self, tmp_path: Path):
        """어떤 파일도 없어도 예외 없이 실행"""
        try:
            whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        except Exception as e:
            pytest.fail(f"빈 상태에서 예외 발생: {e}")


# ---------------------------------------------------------------------------
# 2. bot-activity.json 테스트
# ---------------------------------------------------------------------------


class TestBotActivity:
    """bot-activity.json 로드 및 팀 상태 파싱"""

    def test_load_bot_activity_normal(self, tmp_path: Path):
        now = datetime.now(timezone.utc).isoformat()
        data = {
            "bots": {
                "dev1": {"status": "processing", "since": now},
                "dev2": {"status": "idle", "since": now},
            }
        }
        make_bot_activity(tmp_path, data)
        result = whisper_compile.load_bot_activity(base_dir=tmp_path)
        assert "dev1" in result
        assert result["dev1"]["status"] == "processing"

    def test_team_name_mapping(self, tmp_path: Path):
        """dev1→1팀, dev2→2팀, dev3→3팀, anu→아누 매핑 확인"""
        mapping = whisper_compile.TEAM_NAME_MAP
        assert mapping.get("dev1") == "1팀"
        assert mapping.get("dev2") == "2팀"
        assert mapping.get("dev3") == "3팀"
        assert mapping.get("anu") == "아누"

    def test_load_bot_activity_corrupted(self, tmp_path: Path):
        """손상된 JSON → graceful 처리 (빈 dict 반환)"""
        events_dir = tmp_path / "events"
        events_dir.mkdir(parents=True, exist_ok=True)
        f = events_dir / "bot-activity.json"
        f.write_text("{ not valid json !!!", encoding="utf-8")
        result = whisper_compile.load_bot_activity(base_dir=tmp_path)
        assert result == {}

    def test_load_bot_activity_empty_json(self, tmp_path: Path):
        """빈 JSON 객체 → 빈 dict"""
        make_bot_activity(tmp_path, {})
        result = whisper_compile.load_bot_activity(base_dir=tmp_path)
        assert result == {}

    def test_load_bot_activity_no_bots_key(self, tmp_path: Path):
        """bots 키 없는 JSON → 빈 dict"""
        make_bot_activity(tmp_path, {"other": "data"})
        result = whisper_compile.load_bot_activity(base_dir=tmp_path)
        assert result == {}


# ---------------------------------------------------------------------------
# 3. task-timers.json 테스트
# ---------------------------------------------------------------------------


class TestTaskTimers:
    """task-timers.json 로드 및 running/completed 필터링"""

    def test_load_running_tasks(self, tmp_path: Path):
        now = datetime.now(timezone.utc).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-1.1": {
                    "task_id": "task-1.1",
                    "team_id": "dev1-team",
                    "description": "테스트 작업",
                    "status": "running",
                    "start_time": now,
                    "end_time": None,
                },
                "task-2.1": {
                    "task_id": "task-2.1",
                    "team_id": "dev2-team",
                    "description": "완료된 작업",
                    "status": "completed",
                    "start_time": now,
                    "end_time": now,
                },
            },
        )
        result = whisper_compile.load_task_timers(base_dir=tmp_path)
        running = [t for t in result.values() if t["status"] == "running"]
        assert len(running) == 1
        assert running[0]["task_id"] == "task-1.1"

    def test_load_recent_completed_within_24h(self, tmp_path: Path):
        """24시간 이내 완료 작업은 포함"""
        one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-3.1": {
                    "task_id": "task-3.1",
                    "team_id": "dev1-team",
                    "description": "최근 완료",
                    "status": "completed",
                    "start_time": one_hour_ago,
                    "end_time": one_hour_ago,
                }
            },
        )
        result = whisper_compile.load_task_timers(base_dir=tmp_path)
        assert "task-3.1" in result

    def test_exclude_old_completed(self, tmp_path: Path):
        """25시간 이전 완료 작업은 제외"""
        old_time = (datetime.now(timezone.utc) - timedelta(hours=25)).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-4.1": {
                    "task_id": "task-4.1",
                    "team_id": "dev1-team",
                    "description": "오래된 완료",
                    "status": "completed",
                    "start_time": old_time,
                    "end_time": old_time,
                }
            },
        )
        result = whisper_compile.load_task_timers(base_dir=tmp_path)
        assert "task-4.1" not in result

    def test_load_task_timers_missing(self, tmp_path: Path):
        result = whisper_compile.load_task_timers(base_dir=tmp_path)
        assert result == {}


# ---------------------------------------------------------------------------
# 4. .done 파일 테스트
# ---------------------------------------------------------------------------


class TestDoneFiles:
    """events/*.done 파일 스캔 (.done.acked, .done.escalated 등 확장자 파일 자동 제외)"""

    def test_scan_done_files_basic(self, tmp_path: Path):
        make_done_file(
            tmp_path,
            "task-10.1.done",
            {"task_id": "task-10.1", "status": "done"},
        )
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert len(result) == 1
        assert result[0]["task_id"] == "task-10.1"

    def test_scan_done_files_excludes_non_done_extensions(self, tmp_path: Path):
        """*.done.acked 파일은 제외해야 함 (새 프로토콜 반영)"""
        make_done_file(
            tmp_path,
            "task-11.1.done.acked",
            {"task_id": "task-11.1", "status": "done"},
        )
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert len(result) == 0

    def test_scan_done_files_excludes_done_acked(self, tmp_path: Path):
        """*.done.acked 파일은 스캔에서 제외되어야 함"""
        make_done_file(tmp_path, "task-15.1.done", {"task_id": "task-15.1"})
        make_done_file(tmp_path, "task-15.1.done.acked", {"task_id": "task-15.1"})
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        task_ids = [r.get("task_id") for r in result]
        assert "task-15.1" in task_ids
        assert len(result) == 1

    def test_scan_done_files_excludes_done_escalated(self, tmp_path: Path):
        """*.done.escalated 파일은 스캔에서 제외되어야 함"""
        make_done_file(
            tmp_path,
            "task-16.1.done.escalated",
            {"task_id": "task-16.1", "status": "done"},
        )
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert len(result) == 0

    def test_scan_done_files_excludes_done_notified(self, tmp_path: Path):
        """*.done.notified 파일은 스캔에서 제외되어야 함 (레거시 호환)"""
        make_done_file(
            tmp_path,
            "task-17.1.done.notified",
            {"task_id": "task-17.1", "status": "done"},
        )
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert len(result) == 0

    def test_scan_done_files_multiple(self, tmp_path: Path):
        make_done_file(tmp_path, "task-12.1.done", {"task_id": "task-12.1"})
        make_done_file(tmp_path, "task-13.1.done", {"task_id": "task-13.1"})
        make_done_file(tmp_path, "task-14.1.done.acked", {"task_id": "task-14.1"})
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        task_ids = [r.get("task_id") for r in result]
        assert "task-12.1" in task_ids
        assert "task-13.1" in task_ids
        assert "task-14.1" not in task_ids

    def test_scan_done_files_corrupted_json(self, tmp_path: Path):
        """손상된 .done 파일은 스킵"""
        events_dir = tmp_path / "events"
        events_dir.mkdir(parents=True, exist_ok=True)
        f = events_dir / "task-bad.1.done"
        f.write_text("not json", encoding="utf-8")
        result = whisper_compile.scan_done_files(base_dir=tmp_path)
        assert result == []

    def test_done_pending_count_excludes_acked(self, tmp_path: Path):
        """compile_briefing의 status_dict에서 done_pending이 .done.acked를 제외하고 카운트해야 함"""
        make_done_file(tmp_path, "task-20.1.done", {"task_id": "task-20.1"})
        make_done_file(tmp_path, "task-21.1.done", {"task_id": "task-21.1"})
        make_done_file(tmp_path, "task-22.1.done.acked", {"task_id": "task-22.1"})
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["done_pending"] == 2


# ---------------------------------------------------------------------------
# 5. 보고서 SCQA 추출 테스트
# ---------------------------------------------------------------------------


class TestReportSCQA:
    """extract_report_scqa() - 보고서 SCQA 추출 정확성"""

    SAMPLE_REPORT = """\
# task-564.1 완료 보고서

**S**: done-watcher 개선 사항이 있었다.
**C**: cokacdir가 추가되어 상황이 바뀌었다.
**Q**: 개선 효과를 측정할 수 있는가?
**A**: 테스트 PASS로 확인됨.
"""

    def test_extract_scqa_basic(self, tmp_path: Path):
        make_report(tmp_path, "task-564.1.md", self.SAMPLE_REPORT)
        result = whisper_compile.extract_report_scqa("task-564.1", base_dir=tmp_path)
        assert result is not None
        assert "done-watcher" in result["S"]
        assert "cokacdir" in result["C"]
        assert "개선 효과" in result["Q"]
        assert "테스트" in result["A"]

    def test_extract_scqa_missing_report(self, tmp_path: Path):
        """보고서 없으면 None 반환"""
        result = whisper_compile.extract_report_scqa("task-nonexistent.1", base_dir=tmp_path)
        assert result is None

    def test_extract_scqa_partial(self, tmp_path: Path):
        """S, C만 있는 경우도 graceful 처리"""
        partial = "**S**: 상황\n**C**: 변화\n"
        make_report(tmp_path, "task-partial.1.md", partial)
        result = whisper_compile.extract_report_scqa("task-partial.1", base_dir=tmp_path)
        assert result is not None
        assert result.get("S") == "상황"
        assert result.get("C") == "변화"

    def test_extract_scqa_format_in_briefing(self, tmp_path: Path):
        """[완료] 섹션에 SCQA 포맷이 올바르게 표시되는지"""
        make_report(tmp_path, "task-100.1.md", self.SAMPLE_REPORT)
        make_done_file(tmp_path, "task-100.1.done", {"task_id": "task-100.1"})
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "task-100.1" in output
        assert "S:" in output

    def test_extract_scqa_no_done_files(self, tmp_path: Path):
        """done 파일 없으면 [완료] 섹션은 '없음'"""
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[완료]" in output
        assert "없음" in output


# ---------------------------------------------------------------------------
# 6. session-guidance.json 테스트
# ---------------------------------------------------------------------------


class TestGuidance:
    """session-guidance.json 로드"""

    def test_load_guidance_normal(self, tmp_path: Path):
        make_guidance(
            tmp_path,
            {
                "last_session": "2026-03-15T00:00:00Z",
                "guidance": "이전 세션 키워드",
                "pending_dispatches": [],
                "idle_teams": [],
            },
        )
        result = whisper_compile.load_guidance(base_dir=tmp_path)
        assert result["guidance"] == "이전 세션 키워드"

    def test_load_guidance_missing(self, tmp_path: Path):
        result = whisper_compile.load_guidance(base_dir=tmp_path)
        assert result == {}

    def test_guidance_in_briefing(self, tmp_path: Path):
        make_guidance(tmp_path, {"guidance": "테스트 가이던스"})
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[가이던스]" in output
        assert "테스트 가이던스" in output

    def test_guidance_fallback_in_briefing(self, tmp_path: Path):
        """guidance 없으면 '이전 세션 정보 없음'"""
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "이전 세션 정보 없음" in output


# ---------------------------------------------------------------------------
# 7. 질문 파일 테스트
# ---------------------------------------------------------------------------


class TestQuestions:
    """events/questions/*.json 로드 (.answered 제외)"""

    def test_load_questions_basic(self, tmp_path: Path):
        make_question(
            tmp_path,
            "q-task-565.json",
            {
                "from": "hermes",
                "question": "설계 확인 필요",
                "task_id": "task-565",
            },
        )
        result = whisper_compile.load_questions(base_dir=tmp_path)
        assert len(result) == 1
        assert result[0]["from"] == "hermes"

    def test_load_questions_excludes_answered(self, tmp_path: Path):
        """.answered 파일은 제외"""
        q_dir = tmp_path / "events" / "questions"
        q_dir.mkdir(parents=True, exist_ok=True)
        f = q_dir / "q-answered.json.answered"
        f.write_text(json.dumps({"question": "이미 답변됨"}), encoding="utf-8")
        result = whisper_compile.load_questions(base_dir=tmp_path)
        assert len(result) == 0

    def test_questions_in_briefing_present(self, tmp_path: Path):
        make_question(
            tmp_path,
            "q-test.json",
            {"from": "dev1", "question": "테스트 질문"},
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[질문]" in output
        assert "테스트 질문" in output

    def test_questions_in_briefing_none(self, tmp_path: Path):
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[질문]" in output
        assert "없음" in output

    def test_load_questions_no_dir(self, tmp_path: Path):
        result = whisper_compile.load_questions(base_dir=tmp_path)
        assert result == []


# ---------------------------------------------------------------------------
# 8. 유휴 팀 경고 테스트
# ---------------------------------------------------------------------------


class TestIdleTeams:
    """detect_idle_teams() - 3시간 이상 유휴 팀 탐지"""

    def test_detect_idle_over_3h(self, tmp_path: Path):
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        bots = {"dev2": {"status": "idle", "since": four_hours_ago}}
        result = whisper_compile.detect_idle_teams(bots)
        assert len(result) == 1
        assert result[0]["team_id"] == "dev2"

    def test_no_idle_under_3h(self, tmp_path: Path):
        two_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
        bots = {"dev1": {"status": "idle", "since": two_hours_ago}}
        result = whisper_compile.detect_idle_teams(bots)
        assert len(result) == 0

    def test_processing_team_not_idle(self, tmp_path: Path):
        """processing 상태 팀은 유휴 경고 안 함"""
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        bots = {"dev3": {"status": "processing", "since": four_hours_ago}}
        result = whisper_compile.detect_idle_teams(bots)
        assert len(result) == 0

    def test_idle_warning_in_briefing(self, tmp_path: Path):
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        data = {"bots": {"dev2": {"status": "idle", "since": four_hours_ago}}}
        make_bot_activity(tmp_path, data)
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[유휴경고]" in output
        assert "2팀" in output

    def test_no_idle_warning_when_active(self, tmp_path: Path):
        """유휴 팀 없으면 [유휴경고] 줄 자체가 없어야 함"""
        two_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%SZ")
        data = {"bots": {"dev1": {"status": "idle", "since": two_hours_ago}}}
        make_bot_activity(tmp_path, data)
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[유휴경고]" not in output

    def test_exactly_3h_idle_is_warned(self, tmp_path: Path):
        """정확히 3시간(180분) 유휴 → 경고 포함"""
        three_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=3, minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
        bots = {"dev1": {"status": "idle", "since": three_hours_ago}}
        result = whisper_compile.detect_idle_teams(bots)
        assert len(result) == 1


# ---------------------------------------------------------------------------
# 9. 프로젝트 context 테스트
# ---------------------------------------------------------------------------


class TestProjectContext:
    """프로젝트 context.md 로드"""

    def test_load_project_context_by_cwd(self, tmp_path: Path):
        make_project_context(tmp_path, "insuwiki", "# InsuWiki\n- Phase: 미시작\n")
        result = whisper_compile.load_project_context(cwd="/home/jay/projects/insuwiki/src", base_dir=tmp_path)
        assert "insuwiki" in result

    def test_load_project_context_threadauto(self, tmp_path: Path):
        make_project_context(tmp_path, "threadauto", "# ThreadAuto\n- Remotion 대기\n")
        result = whisper_compile.load_project_context(cwd="/home/jay/projects/threadauto/", base_dir=tmp_path)
        assert "threadauto" in result

    def test_load_all_projects_when_no_cwd(self, tmp_path: Path):
        make_project_context(tmp_path, "insuwiki", "# InsuWiki 요약\n")
        make_project_context(tmp_path, "threadauto", "# ThreadAuto 요약\n")
        result = whisper_compile.load_project_context(base_dir=tmp_path)
        assert "insuwiki" in result
        assert "threadauto" in result

    def test_project_context_in_briefing(self, tmp_path: Path):
        make_project_context(tmp_path, "insuwiki", "# InsuWiki\n- 상태: PENDING\n")
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[프로젝트]" in output


# ---------------------------------------------------------------------------
# 10. 정상 통합 테스트
# ---------------------------------------------------------------------------


class TestFullBriefing:
    """전체 브리핑 통합 테스트"""

    def test_xml_structure(self, tmp_path: Path):
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert output.strip().startswith("<whisper-briefing>")
        assert output.strip().endswith("</whisper-briefing>")

    def test_all_sections_present(self, tmp_path: Path):
        """정상 데이터 → 모든 섹션 포함"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ")

        # bot-activity
        make_bot_activity(
            tmp_path,
            {"bots": {"dev1": {"status": "processing", "since": now_iso}}},
        )
        # task timers
        make_task_timers(
            tmp_path,
            {
                "task-200.1": {
                    "task_id": "task-200.1",
                    "team_id": "dev1-team",
                    "description": "통합 테스트 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                }
            },
        )
        # done file + report
        make_done_file(tmp_path, "task-100.1.done", {"task_id": "task-100.1"})
        make_report(
            tmp_path,
            "task-100.1.md",
            "**S**: 상황\n**C**: 변화\n**Q**: 질문\n**A**: 답변\n",
        )
        # guidance
        make_guidance(tmp_path, {"guidance": "테스트 가이던스 키워드"})
        # question
        make_question(tmp_path, "q-200.json", {"from": "dev2", "question": "확인 요청"})
        # project context
        make_project_context(tmp_path, "insuwiki", "# InsuWiki\n")

        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]

        assert "[팀]" in output
        assert "[완료]" in output
        assert "[프로젝트]" in output
        assert "[가이던스]" in output
        assert "[질문]" in output

    def test_partial_files_only(self, tmp_path: Path):
        """guidance만 있을 때 나머지 섹션은 빈/없음으로 처리"""
        make_guidance(tmp_path, {"guidance": "부분 테스트"})
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[가이던스]" in output
        assert "부분 테스트" in output
        assert "[팀]" in output

    def test_no_exception_on_all_missing(self, tmp_path: Path):
        """파일 전혀 없어도 예외 없이 XML 반환"""
        try:
            output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
            assert "<whisper-briefing>" in output
        except SystemExit as e:
            pytest.fail(f"SystemExit 발생: {e}")
        except Exception as e:
            pytest.fail(f"예외 발생: {e}")

    def test_team_line_format(self, tmp_path: Path):
        """[팀] 줄이 '|' 구분자 포맷"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev1": {"status": "idle", "since": now_iso},
                    "dev2": {"status": "idle", "since": now_iso},
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = [l for l in output.splitlines() if l.startswith("[팀]")]
        assert len(team_line) == 1
        assert "|" in team_line[0]


# ---------------------------------------------------------------------------
# 11. 아누(anu) 브리핑 제외 테스트
# ---------------------------------------------------------------------------


class TestAnuExclusion:
    """아누는 대화 주체이므로 [팀] 섹션 및 유휴경고에서 제외되어야 함"""

    def test_anu_not_in_team_section(self, tmp_path: Path):
        """아누가 bot-activity에 있어도 [팀] 섹션에 표시되지 않아야 함"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev1": {"status": "processing", "since": now_iso},
                    "anu": {"status": "processing", "since": now_iso},
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "아누" not in team_line, f"아누가 [팀] 섹션에 포함됨: {team_line}"

    def test_anu_only_team_shows_no_info(self, tmp_path: Path):
        """아누만 있는 bot-activity → [팀] 정보없음"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"anu": {"status": "processing", "since": now_iso}}},
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[팀] 정보없음" in output, f"아누만 있을 때 정보없음 기대: {output}"

    def test_dev1_and_anu_shows_only_dev1(self, tmp_path: Path):
        """dev1 + anu → anu 제외되고 dev1만 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev1": {"status": "idle", "since": now_iso},
                    "anu": {"status": "processing", "since": now_iso},
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "1팀" in team_line, f"dev1(1팀)이 [팀] 섹션에 없음: {team_line}"
        assert "아누" not in team_line, f"아누가 [팀] 섹션에 포함됨: {team_line}"

    def test_anu_idle_not_in_idle_warning(self, tmp_path: Path):
        """아누가 장기 유휴 상태여도 [유휴경고]에 포함되지 않아야 함"""
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        bots = {"anu": {"status": "idle", "since": four_hours_ago}}
        result = whisper_compile.detect_idle_teams(bots)
        assert len(result) == 0, f"아누는 유휴경고 대상 아님: {result}"

    def test_anu_idle_not_in_briefing_idle_warning(self, tmp_path: Path):
        """아누가 장기 유휴여도 [유휴경고] 줄이 출력되지 않아야 함"""
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"anu": {"status": "idle", "since": four_hours_ago}}},
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[유휴경고]" not in output, f"아누 유휴 시 [유휴경고] 출력됨: {output}"

    def test_team_name_mapping_anu_still_exists(self, tmp_path: Path):
        """TEAM_NAME_MAP에서 anu→아누 매핑은 여전히 존재해야 함 (다른 용도 가능)"""
        mapping = whisper_compile.TEAM_NAME_MAP
        assert mapping.get("anu") == "아누", "TEAM_NAME_MAP에서 anu 매핑이 제거되면 안 됨"


# ---------------------------------------------------------------------------
# 12. save_status() 및 status_dict 테스트
# ---------------------------------------------------------------------------


class TestSaveStatus:
    """save_status() - status.json 기록 및 status_dict 구조 검증"""

    def test_save_status_creates_file(self, tmp_path: Path):
        """save_status() 호출 시 status.json 파일 생성"""
        status_dict = {
            "last_run": "2026-03-15T04:00:00+00:00",
            "status": "ok",
            "briefing_summary": "1팀:작업중",
            "teams_active": 1,
            "teams_idle": 0,
            "done_pending": 0,
            "questions_pending": 0,
            "guidance_last_saved": None,
            "error": None,
        }
        whisper_compile.save_status(status_dict, base_dir=tmp_path)
        status_path = tmp_path / "whisper" / "status.json"
        assert status_path.exists(), "status.json 파일이 생성되지 않음"

    def test_save_status_content_correct(self, tmp_path: Path):
        """저장된 status.json 내용이 입력과 일치"""
        status_dict = {
            "last_run": "2026-03-15T04:00:00+00:00",
            "status": "ok",
            "briefing_summary": "1팀:작업중 | 2팀:유휴",
            "teams_active": 1,
            "teams_idle": 1,
            "done_pending": 2,
            "questions_pending": 1,
            "guidance_last_saved": "2026-03-15T03:50:00+00:00",
            "error": None,
        }
        whisper_compile.save_status(status_dict, base_dir=tmp_path)
        status_path = tmp_path / "whisper" / "status.json"
        saved = json.loads(status_path.read_text(encoding="utf-8"))
        assert saved["status"] == "ok"
        assert saved["teams_active"] == 1
        assert saved["teams_idle"] == 1
        assert saved["done_pending"] == 2
        assert saved["questions_pending"] == 1
        assert saved["guidance_last_saved"] == "2026-03-15T03:50:00+00:00"
        assert saved["error"] is None

    def test_save_status_error_state(self, tmp_path: Path):
        """에러 상태 기록 검증"""
        status_dict = {
            "last_run": "2026-03-15T04:00:00+00:00",
            "status": "error",
            "briefing_summary": "",
            "teams_active": 0,
            "teams_idle": 0,
            "done_pending": 0,
            "questions_pending": 0,
            "guidance_last_saved": None,
            "error": "FileNotFoundError: bot-activity.json not found",
        }
        whisper_compile.save_status(status_dict, base_dir=tmp_path)
        status_path = tmp_path / "whisper" / "status.json"
        saved = json.loads(status_path.read_text(encoding="utf-8"))
        assert saved["status"] == "error"
        assert "FileNotFoundError" in saved["error"]

    def test_compile_briefing_returns_status_dict(self, tmp_path: Path):
        """compile_briefing()이 (str, dict) 튜플을 반환하는지 확인"""
        result = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert isinstance(result, tuple), "compile_briefing()은 튜플을 반환해야 함"
        assert len(result) == 2
        output, status_dict = result
        assert isinstance(output, str)
        assert isinstance(status_dict, dict)

    def test_status_dict_required_keys(self, tmp_path: Path):
        """status_dict에 필수 키가 모두 포함되어야 함"""
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        required_keys = [
            "last_run",
            "status",
            "briefing_summary",
            "teams_active",
            "teams_idle",
            "done_pending",
            "questions_pending",
            "guidance_last_saved",
            "error",
        ]
        for key in required_keys:
            assert key in status_dict, f"status_dict에 '{key}' 키 없음"

    def test_status_dict_ok_on_success(self, tmp_path: Path):
        """정상 실행 시 status='ok'"""
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["status"] == "ok"
        assert status_dict["error"] is None

    def test_status_dict_teams_count(self, tmp_path: Path):
        """teams_active / teams_idle 카운트 정확성"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev1": {"status": "processing", "since": now_iso},
                    "dev2": {"status": "idle", "since": now_iso},
                    "dev3": {"status": "idle", "since": now_iso},
                }
            },
        )
        make_task_timers(
            tmp_path,
            {
                "task-active.1": {
                    "task_id": "task-active.1",
                    "team_id": "dev1-team",
                    "description": "활성 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                }
            },
        )
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["teams_active"] == 1
        assert status_dict["teams_idle"] == 2

    def test_status_dict_done_pending_count(self, tmp_path: Path):
        """done_pending: events/*.done 파일 수와 일치"""
        make_done_file(tmp_path, "task-50.1.done", {"task_id": "task-50.1"})
        make_done_file(tmp_path, "task-51.1.done", {"task_id": "task-51.1"})
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["done_pending"] == 2

    def test_status_dict_questions_pending_count(self, tmp_path: Path):
        """questions_pending: 미응답 질문 수와 일치"""
        make_question(tmp_path, "q-a.json", {"from": "dev1", "question": "질문1"})
        make_question(tmp_path, "q-b.json", {"from": "dev2", "question": "질문2"})
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["questions_pending"] == 2

    def test_status_dict_guidance_last_saved(self, tmp_path: Path):
        """guidance_last_saved: session-guidance.json의 saved_at 필드와 일치"""
        saved_at = "2026-03-15T03:50:00Z"
        make_guidance(tmp_path, {"guidance": "테스트", "saved_at": saved_at})
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["guidance_last_saved"] == saved_at

    def test_save_status_creates_parent_dir(self, tmp_path: Path):
        """whisper 디렉토리가 없어도 자동 생성"""
        assert not (tmp_path / "whisper").exists()
        status_dict = {
            "last_run": "2026-03-15T04:00:00+00:00",
            "status": "ok",
            "briefing_summary": "",
            "teams_active": 0,
            "teams_idle": 0,
            "done_pending": 0,
            "questions_pending": 0,
            "guidance_last_saved": None,
            "error": None,
        }
        whisper_compile.save_status(status_dict, base_dir=tmp_path)
        assert (tmp_path / "whisper" / "status.json").exists()


# ---------------------------------------------------------------------------
# 13. processing 상태이지만 running task 없음 → 유휴 처리 테스트 (task-683.1)
# ---------------------------------------------------------------------------


class TestProcessingButNoRunningTasks:
    """bot-activity가 processing이지만 실제 running task가 없으면 유휴로 표시"""

    def test_processing_no_running_shows_idle_in_briefing(self, tmp_path: Path):
        """processing 상태 + running task 없음 → 브리핑에서 '유휴'로 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev2": {"status": "processing", "since": now_iso}}},
        )
        # task-timers에 running task가 없음
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "유휴" in team_line, f"processing+no running → 유휴 기대: {team_line}"
        assert "processing" not in team_line, f"processing이 직접 표시됨: {team_line}"

    def test_processing_with_running_shows_working(self, tmp_path: Path):
        """processing 상태 + running task 있음 → '작업중'으로 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev1": {"status": "processing", "since": now_iso}}},
        )
        make_task_timers(
            tmp_path,
            {
                "task-500.1": {
                    "task_id": "task-500.1",
                    "team_id": "dev1-team",
                    "description": "활성 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "작업중" in team_line, f"processing+running → 작업중 기대: {team_line}"

    def test_processing_no_running_status_dict_idle_count(self, tmp_path: Path):
        """processing + no running → status_dict에서 teams_idle 카운트에 포함"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev2": {"status": "processing", "since": now_iso},
                    "dev3": {"status": "processing", "since": now_iso},
                }
            },
        )
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["teams_idle"] == 2, f"teams_idle=2 기대: {status_dict['teams_idle']}"
        assert status_dict["teams_active"] == 0

    def test_unknown_status_shows_unknown(self, tmp_path: Path):
        """알 수 없는 상태 → '상태불명' 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev1": {"status": "error", "since": now_iso}}},
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "상태불명" in team_line, f"unknown status → 상태불명 기대: {team_line}"

    def test_mixed_teams_status(self, tmp_path: Path):
        """혼합 상태: dev1(running), dev2(processing+no running), dev3(idle) → 각각 올바르게 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {
                "bots": {
                    "dev1": {"status": "processing", "since": now_iso},
                    "dev2": {"status": "processing", "since": now_iso},
                    "dev3": {"status": "idle", "since": now_iso},
                }
            },
        )
        make_task_timers(
            tmp_path,
            {
                "task-600.1": {
                    "task_id": "task-600.1",
                    "team_id": "dev1-team",
                    "description": "활성",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                }
            },
        )
        output, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        assert status_dict["teams_active"] == 1  # dev1
        assert status_dict["teams_idle"] == 2  # dev2 + dev3


# ---------------------------------------------------------------------------
# 14. 고스트 태스크 감지 테스트 (task-716.1)
# ---------------------------------------------------------------------------


class TestGhostTasks:
    """detect_ghost_tasks() - 4시간 이상 running 태스크 고스트 감지"""

    def test_detect_ghost_over_4h(self, tmp_path: Path):
        """4시간 이상 running → 고스트 감지"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        task_timers = {
            "task-700.1": {
                "task_id": "task-700.1",
                "team_id": "dev2-team",
                "description": "고스트 태스크",
                "status": "running",
                "start_time": five_hours_ago,
            }
        }
        result = whisper_compile.detect_ghost_tasks(task_timers)
        assert len(result) == 1
        assert result[0]["task_id"] == "task-700.1"
        assert result[0]["team_name"] == "2팀"
        assert result[0]["hours"] >= 5.0

    def test_no_ghost_under_4h(self, tmp_path: Path):
        """4시간 미만 running → 고스트 아님"""
        two_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
        task_timers = {
            "task-701.1": {
                "task_id": "task-701.1",
                "team_id": "dev1-team",
                "description": "정상 태스크",
                "status": "running",
                "start_time": two_hours_ago,
            }
        }
        result = whisper_compile.detect_ghost_tasks(task_timers)
        assert len(result) == 0

    def test_completed_task_not_ghost(self, tmp_path: Path):
        """completed 상태는 고스트 대상 아님"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        task_timers = {
            "task-702.1": {
                "task_id": "task-702.1",
                "team_id": "dev1-team",
                "description": "완료된 작업",
                "status": "completed",
                "start_time": five_hours_ago,
            }
        }
        result = whisper_compile.detect_ghost_tasks(task_timers)
        assert len(result) == 0

    def test_ghost_warning_in_briefing(self, tmp_path: Path):
        """고스트 태스크 → [고스트경고] 섹션 출력"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-703.1": {
                    "task_id": "task-703.1",
                    "team_id": "dev2-team",
                    "description": "고스트 의심 작업",
                    "status": "running",
                    "start_time": five_hours_ago,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[고스트경고]" in output
        assert "task-703.1" in output
        assert "고스트" in output

    def test_no_ghost_warning_when_recent(self, tmp_path: Path):
        """최근 태스크만 있으면 [고스트경고] 출력 안 함"""
        one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-704.1": {
                    "task_id": "task-704.1",
                    "team_id": "dev1-team",
                    "description": "최근 작업",
                    "status": "running",
                    "start_time": one_hour_ago,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[고스트경고]" not in output

    def test_ghost_task_team_display_ghost_marker(self, tmp_path: Path):
        """고스트 태스크는 [팀] 섹션에서 '⚠️고스트?' 마커 표시"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev2": {"status": "processing", "since": now_iso}}},
        )
        make_task_timers(
            tmp_path,
            {
                "task-705.1": {
                    "task_id": "task-705.1",
                    "team_id": "dev2-team",
                    "description": "고스트",
                    "status": "running",
                    "start_time": five_hours_ago,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "⚠️고스트?" in team_line
        assert "실질유휴" in team_line

    def test_ghost_only_shows_practical_idle(self, tmp_path: Path):
        """고스트만 있는 팀은 '(실질유휴)' 표시"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev1": {"status": "processing", "since": now_iso}}},
        )
        make_task_timers(
            tmp_path,
            {
                "task-706.1": {
                    "task_id": "task-706.1",
                    "team_id": "dev1-team",
                    "description": "고스트",
                    "status": "running",
                    "start_time": five_hours_ago,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "(실질유휴)" in team_line
        assert "작업중" not in team_line

    def test_mixed_ghost_and_normal_shows_working(self, tmp_path: Path):
        """고스트+정상 태스크 혼합 → '작업중' 유지"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        one_hour_ago = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {"dev1": {"status": "processing", "since": now_iso}}},
        )
        make_task_timers(
            tmp_path,
            {
                "task-707.1": {
                    "task_id": "task-707.1",
                    "team_id": "dev1-team",
                    "description": "고스트",
                    "status": "running",
                    "start_time": five_hours_ago,
                    "end_time": None,
                },
                "task-708.1": {
                    "task_id": "task-708.1",
                    "team_id": "dev1-team",
                    "description": "정상 작업",
                    "status": "running",
                    "start_time": one_hour_ago,
                    "end_time": None,
                },
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "작업중" in team_line

    def test_ghost_warning_before_closing_tag(self, tmp_path: Path):
        """[고스트경고]가 </whisper-briefing> 앞에 위치"""
        five_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat()
        make_task_timers(
            tmp_path,
            {
                "task-709.1": {
                    "task_id": "task-709.1",
                    "team_id": "dev1-team",
                    "description": "고스트",
                    "status": "running",
                    "start_time": five_hours_ago,
                    "end_time": None,
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        output_lines = output.strip().splitlines()
        ghost_idx = None
        close_idx = None
        for i, line in enumerate(output_lines):
            if "[고스트경고]" in line:
                ghost_idx = i
            if "</whisper-briefing>" in line:
                close_idx = i
        assert ghost_idx is not None, "[고스트경고] 줄이 없음"
        assert close_idx is not None
        assert ghost_idx < close_idx, "[고스트경고]가 </whisper-briefing> 뒤에 위치"


# ---------------------------------------------------------------------------
# 15. 논리적 팀 봇 점유 표시 테스트 (task-1450.1)
# ---------------------------------------------------------------------------


class TestBotOccupation:
    """논리적 팀이 물리 봇을 점유할 때 dev팀 표시 변경 테스트"""

    def test_occupied_bot_shows_occupation(self, tmp_path: Path):
        """design이 bot-b 점유 → dev1이 '봇점유(design:task-1446.1)'로 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": now_iso},
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "디자인 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "봇점유" in team_line, f"봇점유 표시 기대: {team_line}"
        assert "design" in team_line
        assert "task-1446.1" in team_line

    def test_multiple_bots_occupied(self, tmp_path: Path):
        """design이 bot-b, bot-c, bot-d 점유 → dev1/2/3 모두 봇점유 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": now_iso},
                "dev2": {"status": "idle", "since": now_iso},
                "dev3": {"status": "idle", "since": now_iso},
                "dev4": {"status": "idle", "since": now_iso},
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "디자인 작업 1",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                },
                "task-1447.1": {
                    "task_id": "task-1447.1",
                    "team_id": "design",
                    "description": "디자인 작업 2",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-c",
                },
                "task-1448.1": {
                    "task_id": "task-1448.1",
                    "team_id": "design",
                    "description": "디자인 작업 3",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-d",
                },
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        # dev1, dev2, dev3 모두 봇점유 표시
        assert team_line.count("봇점유") >= 3, f"3개 봇점유 기대: {team_line}"
        # dev4는 유휴
        assert "유휴" in team_line

    def test_dev_team_with_own_task_not_affected(self, tmp_path: Path):
        """dev팀이 자체 작업 running 중이면 봇 점유와 무관하게 '작업중' 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-100.1": {
                    "task_id": "task-100.1",
                    "team_id": "dev1-team",
                    "description": "dev1 자체 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "작업중" in team_line
        assert "봇점유" not in team_line

    def test_logical_team_shows_bot_id(self, tmp_path: Path):
        """논리적 팀 표시에 봇 ID 포함: task-1446.1(bot-b)"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "디자인 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "(bot-b)" in team_line, f"봇 ID 표시 기대: {team_line}"

    def test_no_bot_field_graceful(self, tmp_path: Path):
        """bot 필드 없는 작업은 봇점유 탐지에서 무시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": now_iso},
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-old.1": {
                    "task_id": "task-old.1",
                    "team_id": "design",
                    "description": "봇 필드 없는 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    # bot 필드 없음
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        # dev1은 봇점유가 아니라 유휴
        assert "유휴" in team_line
        assert "봇점유" not in team_line

    def test_occupation_cleared_after_task_complete(self, tmp_path: Path):
        """논리적 팀 작업 완료 후 dev팀이 다시 '유휴'로 표시"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "완료된 디자인 작업",
                    "status": "completed",
                    "start_time": now_iso,
                    "end_time": now_iso,
                    "bot": "bot-b",
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        team_line = next((l for l in output.splitlines() if l.startswith("[팀]")), "")
        assert "유휴" in team_line
        assert "봇점유" not in team_line

    def test_bot_occupied_not_in_idle_warning(self, tmp_path: Path):
        """봇 점유 중인 dev팀은 [유휴경고]에서 제외"""
        four_hours_ago = (datetime.now(timezone.utc) - timedelta(hours=4)).strftime("%Y-%m-%dT%H:%M:%SZ")
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": four_hours_ago},
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "디자인 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                }
            },
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        # dev1이 4시간 유휴이지만 봇 점유 중이므로 유휴경고 없어야 함
        assert "[유휴경고]" not in output, f"봇점유 팀은 유휴경고 제외: {output}"

    def test_status_dict_bot_occupied_counts_as_active(self, tmp_path: Path):
        """봇 점유 dev팀은 status_dict에서 teams_active로 카운트"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        make_bot_activity(
            tmp_path,
            {"bots": {
                "dev1": {"status": "idle", "since": now_iso},
                "dev2": {"status": "idle", "since": now_iso},
                "design": {"status": "processing", "since": now_iso},
            }},
        )
        make_task_timers(
            tmp_path,
            {
                "task-1446.1": {
                    "task_id": "task-1446.1",
                    "team_id": "design",
                    "description": "디자인 작업",
                    "status": "running",
                    "start_time": now_iso,
                    "end_time": None,
                    "bot": "bot-b",
                }
            },
        )
        _, status_dict = whisper_compile.compile_briefing(base_dir=tmp_path)
        # design은 작업중(active), dev1은 봇점유(active), dev2는 유휴
        assert status_dict["teams_active"] >= 2  # design + dev1
        assert "봇점유" in status_dict["briefing_summary"]

    def test_build_bot_occupation_function(self, tmp_path: Path):
        """_build_bot_occupation 함수 직접 테스트"""
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
        task_timers = {
            "task-1446.1": {
                "task_id": "task-1446.1",
                "team_id": "design",
                "description": "디자인",
                "status": "running",
                "start_time": now_iso,
                "bot": "bot-b",
            },
            "task-100.1": {
                "task_id": "task-100.1",
                "team_id": "dev2-team",
                "description": "dev2 자체 작업",
                "status": "running",
                "start_time": now_iso,
                "bot": "bot-c",
            },
        }
        bot_to_dev = {"bot-b": "dev1", "bot-c": "dev2", "bot-d": "dev3"}
        result = whisper_compile._build_bot_occupation(task_timers, bot_to_dev)
        assert "dev1" in result  # design이 bot-b 점유
        assert result["dev1"]["team"] == "design"
        assert result["dev1"]["task_id"] == "task-1446.1"
        assert "dev2" not in result  # dev2 자체 작업은 점유로 안 잡힘


# ---------------------------------------------------------------------------
# Helper: 새 섹션용 헬퍼 함수
# ---------------------------------------------------------------------------


def make_active_projects(tmp_path: Path, data: dict) -> Path:
    f = tmp_path / "active-projects.json"
    f.write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


def make_feedback_file(dir_path: Path, filename: str, name: str, mtime_offset_days: int = 0) -> Path:
    """feedback_*.md 파일 생성. mtime_offset_days: 0=오늘, 1=하루 전, ..."""
    dir_path.mkdir(parents=True, exist_ok=True)
    content = f"---\nname: {name}\ndescription: test\ntype: feedback\n---\n\n내용"
    f = dir_path / filename
    f.write_text(content, encoding="utf-8")
    if mtime_offset_days > 0:
        import time
        new_mtime = time.time() - (mtime_offset_days * 86400)
        import os
        os.utime(f, (new_mtime, new_mtime))
    return f


def make_learning_file(tmp_path: Path, filename: str, status: str = "pending") -> Path:
    learnings_dir = tmp_path / "learnings"
    learnings_dir.mkdir(parents=True, exist_ok=True)
    content = f"---\nstatus: {status}\n---\n\n학습 내용"
    f = learnings_dir / filename
    f.write_text(content, encoding="utf-8")
    return tmp_path


# ---------------------------------------------------------------------------
# 16. 프로젝트 진행률 테스트
# ---------------------------------------------------------------------------


class TestProjectProgress:
    """load_project_progress() - active-projects.json에서 진행률 로드"""

    def test_load_project_progress_normal(self, tmp_path: Path):
        """정상 데이터 → name, progress 추출"""
        make_active_projects(
            tmp_path,
            {"active": [{"name": "ProjectA", "progress": 90}, {"name": "ProjectB", "progress": 40}]},
        )
        result = whisper_compile.load_project_progress(base_dir=tmp_path)
        assert len(result) == 2
        names = [p["name"] for p in result]
        assert "ProjectA" in names
        assert "ProjectB" in names
        progress_map = {p["name"]: p["progress"] for p in result}
        assert progress_map["ProjectA"] == 90
        assert progress_map["ProjectB"] == 40

    def test_load_project_progress_missing_file(self, tmp_path: Path):
        """파일 없음 → 빈 리스트"""
        result = whisper_compile.load_project_progress(base_dir=tmp_path)
        assert result == []

    def test_load_project_progress_empty_active(self, tmp_path: Path):
        """active 배열 비어있음 → 빈 리스트"""
        make_active_projects(tmp_path, {"active": []})
        result = whisper_compile.load_project_progress(base_dir=tmp_path)
        assert result == []

    def test_load_project_progress_corrupted(self, tmp_path: Path):
        """손상 JSON → 빈 리스트"""
        f = tmp_path / "active-projects.json"
        f.write_text("{ not valid json !!!", encoding="utf-8")
        result = whisper_compile.load_project_progress(base_dir=tmp_path)
        assert result == []

    def test_progress_in_briefing(self, tmp_path: Path):
        """compile_briefing에서 [진행률] 섹션 출력 확인"""
        make_active_projects(
            tmp_path,
            {"active": [{"name": "ProjectA", "progress": 75}]},
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[진행률]" in output
        assert "ProjectA" in output

    def test_no_progress_in_briefing_when_empty(self, tmp_path: Path):
        """active-projects.json 없을 때 [진행률] 없음"""
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[진행률]" not in output


# ---------------------------------------------------------------------------
# 17. 미착수(stale) 태스크 테스트
# ---------------------------------------------------------------------------


class TestStaleTasks:
    """load_stale_tasks() - pending 상태 + 3일 이상 경과한 태스크"""

    def test_load_stale_tasks_pending_over_3_days(self, tmp_path: Path):
        """pending 상태 + 4일 전 → 반환됨"""
        four_days_ago = (datetime.now(timezone.utc) - timedelta(days=4)).isoformat()
        f = tmp_path / "task-timers.json"
        f.write_text(
            json.dumps({
                "tasks": {
                    "task-X": {
                        "status": "pending",
                        "start_time": four_days_ago,
                        "description": "오래된 대기 작업",
                    }
                }
            }),
            encoding="utf-8",
        )
        result = whisper_compile.load_stale_tasks(base_dir=tmp_path)
        assert len(result) == 1
        assert result[0]["task_id"] == "task-X"

    def test_load_stale_tasks_pending_under_3_days(self, tmp_path: Path):
        """pending + 1일 전 → 빈 리스트"""
        one_day_ago = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
        f = tmp_path / "task-timers.json"
        f.write_text(
            json.dumps({
                "tasks": {
                    "task-Y": {
                        "status": "pending",
                        "start_time": one_day_ago,
                        "description": "최근 대기 작업",
                    }
                }
            }),
            encoding="utf-8",
        )
        result = whisper_compile.load_stale_tasks(base_dir=tmp_path)
        assert result == []

    def test_load_stale_tasks_no_pending(self, tmp_path: Path):
        """running/completed만 → 빈 리스트"""
        four_days_ago = (datetime.now(timezone.utc) - timedelta(days=4)).isoformat()
        f = tmp_path / "task-timers.json"
        f.write_text(
            json.dumps({
                "tasks": {
                    "task-R": {
                        "status": "running",
                        "start_time": four_days_ago,
                        "description": "실행 중 작업",
                    },
                    "task-C": {
                        "status": "completed",
                        "start_time": four_days_ago,
                        "description": "완료된 작업",
                    },
                }
            }),
            encoding="utf-8",
        )
        result = whisper_compile.load_stale_tasks(base_dir=tmp_path)
        assert result == []

    def test_load_stale_tasks_missing_file(self, tmp_path: Path):
        """파일 없음 → 빈 리스트"""
        result = whisper_compile.load_stale_tasks(base_dir=tmp_path)
        assert result == []

    def test_stale_tasks_in_briefing(self, tmp_path: Path):
        """compile_briefing에서 [미착수] 섹션 출력 확인"""
        four_days_ago = (datetime.now(timezone.utc) - timedelta(days=4)).isoformat()
        f = tmp_path / "task-timers.json"
        f.write_text(
            json.dumps({
                "tasks": {
                    "task-stale-1": {
                        "status": "pending",
                        "start_time": four_days_ago,
                        "description": "미착수 태스크",
                    }
                }
            }),
            encoding="utf-8",
        )
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[미착수]" in output
        assert "task-stale-1" in output


# ---------------------------------------------------------------------------
# 18. 최근 실수 테스트
# ---------------------------------------------------------------------------


class TestRecentMistakes:
    """load_recent_mistakes() - feedback_*.md 파일에서 최신 3개 추출"""

    def test_load_recent_mistakes_normal(self, tmp_path: Path):
        """5개 파일 중 최신 3개만 반환"""
        feedback_dir = tmp_path / "feedback"
        make_feedback_file(feedback_dir, "feedback_01.md", "실수1", mtime_offset_days=4)
        make_feedback_file(feedback_dir, "feedback_02.md", "실수2", mtime_offset_days=3)
        make_feedback_file(feedback_dir, "feedback_03.md", "실수3", mtime_offset_days=2)
        make_feedback_file(feedback_dir, "feedback_04.md", "실수4", mtime_offset_days=1)
        make_feedback_file(feedback_dir, "feedback_05.md", "실수5", mtime_offset_days=0)
        result = whisper_compile.load_recent_mistakes(feedback_dir=feedback_dir)
        assert len(result) == 3
        # 최신 3개 → 실수5, 실수4, 실수3
        names = [r["name"] for r in result]
        assert "실수5" in names
        assert "실수4" in names
        assert "실수3" in names
        assert "실수1" not in names
        assert "실수2" not in names

    def test_load_recent_mistakes_missing_dir(self, tmp_path: Path):
        """디렉토리 없음 → 빈 리스트"""
        missing_dir = tmp_path / "nonexistent_feedback"
        result = whisper_compile.load_recent_mistakes(feedback_dir=missing_dir)
        assert result == []

    def test_load_recent_mistakes_name_extraction(self, tmp_path: Path):
        """frontmatter name 정확 추출 확인"""
        feedback_dir = tmp_path / "feedback"
        make_feedback_file(feedback_dir, "feedback_test.md", "정확한이름")
        result = whisper_compile.load_recent_mistakes(feedback_dir=feedback_dir)
        assert len(result) == 1
        assert result[0]["name"] == "정확한이름"
        assert "date" in result[0]

    def test_load_recent_mistakes_no_feedback_files(self, tmp_path: Path):
        """디렉토리 존재하지만 파일 없음 → 빈 리스트"""
        feedback_dir = tmp_path / "feedback"
        feedback_dir.mkdir(parents=True, exist_ok=True)
        result = whisper_compile.load_recent_mistakes(feedback_dir=feedback_dir)
        assert result == []

    def test_mistakes_in_briefing(self, tmp_path: Path):
        """compile_briefing에서 [최근실수] 출력 확인"""
        feedback_dir = tmp_path / "feedback"
        make_feedback_file(feedback_dir, "feedback_recent.md", "최근실수항목")
        output = whisper_compile.compile_briefing(base_dir=tmp_path, feedback_dir=feedback_dir)[0]
        assert "[최근실수]" in output
        assert "최근실수항목" in output


# ---------------------------------------------------------------------------
# 19. 미처리 학습 테스트
# ---------------------------------------------------------------------------


class TestPendingLearnings:
    """load_pending_learnings() - learnings/*.md에서 status=pending 카운트"""

    def test_load_pending_learnings_normal(self, tmp_path: Path):
        """3개 pending, 2개 applied → 3 반환"""
        make_learning_file(tmp_path, "learn_01.md", status="pending")
        make_learning_file(tmp_path, "learn_02.md", status="pending")
        make_learning_file(tmp_path, "learn_03.md", status="pending")
        make_learning_file(tmp_path, "learn_04.md", status="applied")
        make_learning_file(tmp_path, "learn_05.md", status="applied")
        result = whisper_compile.load_pending_learnings(base_dir=tmp_path)
        assert result == 3

    def test_load_pending_learnings_no_dir(self, tmp_path: Path):
        """폴더 없음 → 0 반환"""
        result = whisper_compile.load_pending_learnings(base_dir=tmp_path)
        assert result == 0

    def test_load_pending_learnings_empty_dir(self, tmp_path: Path):
        """폴더 있지만 파일 없음 → 0 반환"""
        learnings_dir = tmp_path / "learnings"
        learnings_dir.mkdir(parents=True, exist_ok=True)
        result = whisper_compile.load_pending_learnings(base_dir=tmp_path)
        assert result == 0

    def test_learnings_in_briefing(self, tmp_path: Path):
        """compile_briefing에서 [미처리학습] 섹션 항상 출력 확인"""
        make_learning_file(tmp_path, "learn_a.md", status="pending")
        make_learning_file(tmp_path, "learn_b.md", status="pending")
        output = whisper_compile.compile_briefing(base_dir=tmp_path)[0]
        assert "[미처리학습]" in output
