"""Tests for file-cleanup.py - TDD approach (RED phase first).

파일 자동 정리 스크립트의 4가지 정리 카테고리, 실행 모드, 안전장치를
테스트합니다. 모든 테스트는 tmp_path fixture를 사용하여 실제 시스템
파일을 건드리지 않습니다.
"""

import os
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import patch

import pytest

# Add the scripts directory to path so we can import the module
sys.path.insert(0, str(Path(__file__).parent.parent))

import file_cleanup as fc

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_old_file(path: Path, days: int, content: str = "data") -> Path:
    """파일을 생성하고 mtime을 days일 전으로 설정한다."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content)
    old_time = time.time() - (days * 24 * 3600)
    os.utime(path, (old_time, old_time))
    return path


def _make_recent_file(path: Path, content: str = "data") -> Path:
    """최근 파일을 생성한다 (mtime = now)."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content)
    return path


# ---------------------------------------------------------------------------
# 1. CokacDir Upload Cleanup (30-day threshold)
# ---------------------------------------------------------------------------


class TestCokacDirCleanup:
    """cokacdir 업로드 파일 정리 테스트 (30일 초과 미디어 파일)."""

    def test_old_pdf_is_candidate(self, tmp_path: Path) -> None:
        """31일 된 PDF 파일은 삭제 후보로 감지되어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        old_pdf = _make_old_file(cokac_ws / "report.pdf", days=31)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert str(old_pdf) in paths

    def test_old_png_is_candidate(self, tmp_path: Path) -> None:
        """31일 된 PNG 파일은 삭제 후보로 감지되어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        old_png = _make_old_file(cokac_ws / "image.png", days=35)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert str(old_png) in paths

    def test_old_jpg_and_jpeg_are_candidates(self, tmp_path: Path) -> None:
        """31일 된 JPG/JPEG 파일은 삭제 후보로 감지되어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        old_jpg = _make_old_file(cokac_ws / "photo.jpg", days=40)
        old_jpeg = _make_old_file(cokac_ws / "scan.jpeg", days=40)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert str(old_jpg) in paths
        assert str(old_jpeg) in paths

    def test_recent_media_is_not_candidate(self, tmp_path: Path) -> None:
        """30일 이내 미디어 파일은 삭제 후보가 아니어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        recent_pdf = _make_recent_file(cokac_ws / "recent.pdf")

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert str(recent_pdf) not in paths

    def test_exactly_30_days_not_candidate(self, tmp_path: Path) -> None:
        """정확히 30일 된 파일은 삭제 후보가 아니다 (30일 초과만 대상)."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        border_pdf = _make_old_file(cokac_ws / "border.pdf", days=30)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert str(border_pdf) not in paths

    def test_claude_md_is_protected(self, tmp_path: Path) -> None:
        """CLAUDE.md는 오래되어도 삭제 후보가 아니어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_old_file(cokac_ws / "CLAUDE.md", days=60)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert not any("CLAUDE.md" in p for p in paths)

    def test_py_scripts_are_protected(self, tmp_path: Path) -> None:
        """*.py 파일은 오래되어도 삭제 후보가 아니어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_old_file(cokac_ws / "helper.py", days=60)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert not any(".py" in p for p in paths)

    def test_sh_scripts_are_protected(self, tmp_path: Path) -> None:
        """*.sh 파일은 오래되어도 삭제 후보가 아니어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_old_file(cokac_ws / "setup.sh", days=60)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert not any(".sh" in p for p in paths)

    def test_non_media_files_not_included(self, tmp_path: Path) -> None:
        """PDF/PNG/JPG/JPEG가 아닌 파일은 포함되지 않아야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_old_file(cokac_ws / "data.csv", days=60)
        _make_old_file(cokac_ws / "notes.txt", days=60)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        paths = [c["path"] for c in candidates]
        assert not any(".csv" in p for p in paths)
        assert not any(".txt" in p for p in paths)

    def test_nonexistent_workspace_returns_empty(self, tmp_path: Path) -> None:
        """존재하지 않는 workspace는 빈 목록을 반환해야 한다."""
        missing = tmp_path / ".cokacdir" / "workspace"

        candidates = fc.find_cokacdir_cleanup_candidates(missing)

        assert candidates == []

    def test_candidate_has_required_fields(self, tmp_path: Path) -> None:
        """각 후보 항목은 path, size_bytes, days_old, category 필드를 가져야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_old_file(cokac_ws / "doc.pdf", days=45)

        candidates = fc.find_cokacdir_cleanup_candidates(tmp_path / ".cokacdir" / "workspace")

        assert len(candidates) == 1
        c = candidates[0]
        assert "path" in c
        assert "size_bytes" in c
        assert "days_old" in c
        assert "category" in c
        assert c["days_old"] >= 45


# ---------------------------------------------------------------------------
# 2. .done.clear Cleanup (30-day threshold)
# ---------------------------------------------------------------------------


class TestDoneClearCleanup:
    """memory/events/*.done.clear 파일 정리 테스트."""

    def test_old_done_clear_is_candidate(self, tmp_path: Path) -> None:
        """31일 된 .done.clear 파일은 삭제 후보로 감지되어야 한다."""
        events_dir = tmp_path / "memory" / "events"
        old_file = _make_old_file(events_dir / "task-2025.done.clear", days=31)

        candidates = fc.find_done_clear_candidates(events_dir)

        paths = [c["path"] for c in candidates]
        assert str(old_file) in paths

    def test_recent_done_clear_not_candidate(self, tmp_path: Path) -> None:
        """30일 이내 .done.clear 파일은 삭제 후보가 아니어야 한다."""
        events_dir = tmp_path / "memory" / "events"
        recent = _make_recent_file(events_dir / "recent.done.clear")

        candidates = fc.find_done_clear_candidates(events_dir)

        paths = [c["path"] for c in candidates]
        assert str(recent) not in paths

    def test_non_done_clear_files_not_included(self, tmp_path: Path) -> None:
        """*.done.clear가 아닌 파일은 포함되지 않아야 한다."""
        events_dir = tmp_path / "memory" / "events"
        _make_old_file(events_dir / "notes.md", days=60)

        candidates = fc.find_done_clear_candidates(events_dir)

        assert candidates == []

    def test_nonexistent_events_dir_returns_empty(self, tmp_path: Path) -> None:
        """존재하지 않는 events 디렉토리는 빈 목록을 반환해야 한다."""
        missing = tmp_path / "memory" / "events"

        candidates = fc.find_done_clear_candidates(missing)

        assert candidates == []

    def test_multiple_old_files_all_included(self, tmp_path: Path) -> None:
        """여러 개의 오래된 .done.clear 파일이 모두 포함되어야 한다."""
        events_dir = tmp_path / "memory" / "events"
        f1 = _make_old_file(events_dir / "a.done.clear", days=35)
        f2 = _make_old_file(events_dir / "b.done.clear", days=40)
        f3 = _make_old_file(events_dir / "c.done.clear", days=45)

        candidates = fc.find_done_clear_candidates(events_dir)

        paths = [c["path"] for c in candidates]
        assert str(f1) in paths
        assert str(f2) in paths
        assert str(f3) in paths


# ---------------------------------------------------------------------------
# 3. Dispatch Task Cleanup (90-day threshold)
# ---------------------------------------------------------------------------


class TestDispatchCleanup:
    """memory/tasks/dispatch-*.md 파일 정리 테스트 (90일 초과)."""

    def test_old_dispatch_is_candidate(self, tmp_path: Path) -> None:
        """91일 된 dispatch-*.md 파일은 삭제 후보로 감지되어야 한다."""
        tasks_dir = tmp_path / "memory" / "tasks"
        old_dispatch = _make_old_file(tasks_dir / "dispatch-2024-001.md", days=91)

        candidates = fc.find_dispatch_candidates(tasks_dir)

        paths = [c["path"] for c in candidates]
        assert str(old_dispatch) in paths

    def test_recent_dispatch_not_candidate(self, tmp_path: Path) -> None:
        """90일 이내 dispatch-*.md 파일은 삭제 후보가 아니어야 한다."""
        tasks_dir = tmp_path / "memory" / "tasks"
        recent = _make_recent_file(tasks_dir / "dispatch-recent.md")

        candidates = fc.find_dispatch_candidates(tasks_dir)

        paths = [c["path"] for c in candidates]
        assert str(recent) not in paths

    def test_exactly_90_days_not_candidate(self, tmp_path: Path) -> None:
        """정확히 90일 된 파일은 삭제 후보가 아니다 (90일 초과만 대상)."""
        tasks_dir = tmp_path / "memory" / "tasks"
        border = _make_old_file(tasks_dir / "dispatch-border.md", days=90)

        candidates = fc.find_dispatch_candidates(tasks_dir)

        paths = [c["path"] for c in candidates]
        assert str(border) not in paths

    def test_non_dispatch_md_not_included(self, tmp_path: Path) -> None:
        """dispatch-로 시작하지 않는 파일은 포함되지 않아야 한다."""
        tasks_dir = tmp_path / "memory" / "tasks"
        _make_old_file(tasks_dir / "task-notes.md", days=100)
        _make_old_file(tasks_dir / "other-dispatch.md", days=100)

        candidates = fc.find_dispatch_candidates(tasks_dir)

        assert candidates == []

    def test_nonexistent_tasks_dir_returns_empty(self, tmp_path: Path) -> None:
        """존재하지 않는 tasks 디렉토리는 빈 목록을 반환해야 한다."""
        missing = tmp_path / "memory" / "tasks"

        candidates = fc.find_dispatch_candidates(missing)

        assert candidates == []


# ---------------------------------------------------------------------------
# 4. System Log Cleanup (60-day threshold)
# ---------------------------------------------------------------------------


class TestLogCleanup:
    """workspace/logs/*.log 파일 정리 테스트 (60일 초과)."""

    def test_old_log_is_candidate(self, tmp_path: Path) -> None:
        """61일 된 .log 파일은 삭제 후보로 감지되어야 한다."""
        logs_dir = tmp_path / "logs"
        old_log = _make_old_file(logs_dir / "app.log", days=61)

        candidates = fc.find_log_candidates(logs_dir)

        paths = [c["path"] for c in candidates]
        assert str(old_log) in paths

    def test_recent_log_not_candidate(self, tmp_path: Path) -> None:
        """60일 이내 .log 파일은 삭제 후보가 아니어야 한다."""
        logs_dir = tmp_path / "logs"
        recent = _make_recent_file(logs_dir / "current.log")

        candidates = fc.find_log_candidates(logs_dir)

        paths = [c["path"] for c in candidates]
        assert str(recent) not in paths

    def test_today_modified_log_skipped(self, tmp_path: Path) -> None:
        """오늘 수정된 로그 파일은 스킵되어야 한다 (활성 로그 보존)."""
        logs_dir = tmp_path / "logs"
        # Make it look old but modify today
        active_log = _make_old_file(logs_dir / "active.log", days=70)
        # Reset mtime to today
        now = time.time()
        os.utime(active_log, (now, now))

        candidates = fc.find_log_candidates(logs_dir)

        paths = [c["path"] for c in candidates]
        assert str(active_log) not in paths

    def test_non_log_files_not_included(self, tmp_path: Path) -> None:
        """*.log가 아닌 파일은 포함되지 않아야 한다."""
        logs_dir = tmp_path / "logs"
        _make_old_file(logs_dir / "debug.txt", days=100)

        candidates = fc.find_log_candidates(logs_dir)

        assert candidates == []

    def test_exactly_60_days_not_candidate(self, tmp_path: Path) -> None:
        """정확히 60일 된 파일은 삭제 후보가 아니다 (60일 초과만 대상)."""
        logs_dir = tmp_path / "logs"
        border = _make_old_file(logs_dir / "border.log", days=60)

        candidates = fc.find_log_candidates(logs_dir)

        paths = [c["path"] for c in candidates]
        assert str(border) not in paths

    def test_nonexistent_logs_dir_returns_empty(self, tmp_path: Path) -> None:
        """존재하지 않는 logs 디렉토리는 빈 목록을 반환해야 한다."""
        missing = tmp_path / "logs"

        candidates = fc.find_log_candidates(missing)

        assert candidates == []


# ---------------------------------------------------------------------------
# 5. Safety Guards
# ---------------------------------------------------------------------------


class TestSafetyGuards:
    """삭제 금지 대상 보호 및 안전장치 테스트."""

    def test_protected_dirs_never_targeted(self, tmp_path: Path) -> None:
        """보호된 디렉토리 내 파일은 어떤 카테고리에서도 대상이 되면 안 된다."""
        protected_dirs = [
            "memory/reports",
            "memory/research",
            "memory/specs",
            "memory/meetings",
            "memory/plans",
        ]
        for d in protected_dirs:
            p = tmp_path / d
            p.mkdir(parents=True, exist_ok=True)
            _make_old_file(p / "important.md", days=100)

        checker = fc.SafetyChecker(base_dir=tmp_path)
        for d in protected_dirs:
            test_path = tmp_path / d / "important.md"
            assert checker.is_protected(test_path), f"{d} should be protected"

    def test_protected_filenames(self, tmp_path: Path) -> None:
        """CLAUDE.md, MEMORY.md, .env, .env.keys는 절대 삭제 대상이 아니다."""
        protected_names = ["CLAUDE.md", "MEMORY.md", ".env", ".env.keys"]
        checker = fc.SafetyChecker(base_dir=tmp_path)
        for name in protected_names:
            test_path = tmp_path / name
            _make_recent_file(test_path)
            assert checker.is_protected(test_path), f"{name} should be protected"

    def test_git_files_protected(self, tmp_path: Path) -> None:
        """.git, .worktrees 관련 파일은 보호되어야 한다."""
        checker = fc.SafetyChecker(base_dir=tmp_path)
        git_path = tmp_path / ".git" / "config"
        worktree_path = tmp_path / ".worktrees" / "branch" / "file.py"
        assert checker.is_protected(git_path)
        assert checker.is_protected(worktree_path)

    def test_projects_dir_protected(self, tmp_path: Path) -> None:
        """/home/jay/projects/ 내 파일은 절대 건드리지 않아야 한다."""
        checker = fc.SafetyChecker(base_dir=tmp_path)
        projects_path = tmp_path / "projects" / "myapp" / "src" / "main.py"
        assert checker.is_protected(projects_path)

    def test_dry_run_does_not_delete(self, tmp_path: Path) -> None:
        """dry-run 모드에서는 파일이 실제로 삭제되지 않아야 한다."""
        logs_dir = tmp_path / "logs"
        old_log = _make_old_file(logs_dir / "old.log", days=61)

        candidates = fc.find_log_candidates(logs_dir)
        result = fc.execute_cleanup(candidates, dry_run=True)

        # 파일이 여전히 존재해야 함
        assert old_log.exists()
        assert result["deleted_count"] == 0
        assert result["dry_run"] is True

    def test_execute_mode_deletes_files(self, tmp_path: Path) -> None:
        """--execute 모드에서는 파일이 실제로 삭제되어야 한다."""
        logs_dir = tmp_path / "logs"
        old_log1 = _make_old_file(logs_dir / "old1.log", days=61)
        old_log2 = _make_old_file(logs_dir / "old2.log", days=65)

        candidates = fc.find_log_candidates(logs_dir)
        result = fc.execute_cleanup(candidates, dry_run=False)

        assert not old_log1.exists()
        assert not old_log2.exists()
        assert result["deleted_count"] == 2
        assert result["dry_run"] is False

    def test_minimum_keep_one_per_category(self, tmp_path: Path) -> None:
        """각 카테고리에서 최소 1개 파일은 보존되어야 한다 (전체 삭제 방지)."""
        logs_dir = tmp_path / "logs"
        # 모두 오래된 파일 3개 생성
        _make_old_file(logs_dir / "a.log", days=61, content="aaa")
        _make_old_file(logs_dir / "b.log", days=65, content="bbb")
        _make_old_file(logs_dir / "c.log", days=70, content="ccc")

        candidates = fc.find_log_candidates(logs_dir)
        safe_candidates = fc.apply_minimum_keep(candidates, keep=1)

        # 최소 1개를 남기므로 최대 2개만 삭제 대상이어야 함
        assert len(safe_candidates) <= 2

    def test_minimum_keep_empty_candidates(self, tmp_path: Path) -> None:
        """후보가 없는 경우 최소 보존 규칙은 빈 목록을 반환해야 한다."""
        result = fc.apply_minimum_keep([], keep=1)
        assert result == []


# ---------------------------------------------------------------------------
# 6. Cleanup Log Recording
# ---------------------------------------------------------------------------


class TestCleanupLog:
    """삭제 로그 기록 테스트."""

    def test_cleanup_log_written_on_execute(self, tmp_path: Path) -> None:
        """--execute 모드에서 삭제 후 로그 파일에 기록이 남아야 한다."""
        logs_dir = tmp_path / "logs"
        cleanup_log = logs_dir / "file-cleanup.log"
        _make_old_file(logs_dir / "old.log", days=61)

        candidates = fc.find_log_candidates(logs_dir)
        fc.execute_cleanup(candidates, dry_run=False, cleanup_log_path=cleanup_log)

        assert cleanup_log.exists()
        content = cleanup_log.read_text()
        assert "old.log" in content

    def test_cleanup_log_not_written_on_dry_run(self, tmp_path: Path) -> None:
        """dry-run 모드에서는 로그 파일이 생성되지 않아야 한다."""
        logs_dir = tmp_path / "logs"
        cleanup_log = tmp_path / "file-cleanup.log"
        _make_old_file(logs_dir / "old.log", days=61)

        candidates = fc.find_log_candidates(logs_dir)
        fc.execute_cleanup(candidates, dry_run=True, cleanup_log_path=cleanup_log)

        assert not cleanup_log.exists()


# ---------------------------------------------------------------------------
# 7. Report Mode
# ---------------------------------------------------------------------------


class TestReportMode:
    """--report 모드 테스트."""

    def test_report_returns_dict_with_required_keys(self, tmp_path: Path) -> None:
        """report 함수는 필수 키를 갖는 dict를 반환해야 한다."""
        result = fc.generate_report(base_dir=tmp_path)

        assert "disk_usage" in result
        assert "cleanup_candidates" in result
        assert "total_reclaimable_mb" in result

    def test_report_disk_usage_has_total_and_free(self, tmp_path: Path) -> None:
        """disk_usage 섹션에는 total_mb와 free_mb가 있어야 한다."""
        result = fc.generate_report(base_dir=tmp_path)

        du = result["disk_usage"]
        assert "total_mb" in du
        assert "free_mb" in du
        assert "used_mb" in du
        assert isinstance(du["total_mb"], (int, float))

    def test_report_reclaimable_is_sum_of_candidates(self, tmp_path: Path) -> None:
        """total_reclaimable_mb는 삭제 가능 파일들의 크기 합계여야 한다."""
        logs_dir = tmp_path / "logs"
        _make_old_file(logs_dir / "a.log", days=61, content="A" * 1024)
        _make_old_file(logs_dir / "b.log", days=65, content="B" * 1024)

        result = fc.generate_report(base_dir=tmp_path, logs_dir=logs_dir)

        assert result["total_reclaimable_mb"] >= 0

    def test_report_cleanup_candidates_by_category(self, tmp_path: Path) -> None:
        """cleanup_candidates는 카테고리별로 분류되어야 한다."""
        result = fc.generate_report(base_dir=tmp_path)

        candidates = result["cleanup_candidates"]
        assert isinstance(candidates, dict)


# ---------------------------------------------------------------------------
# 8. Organize Mode
# ---------------------------------------------------------------------------


class TestOrganizeMode:
    """--organize 모드 테스트."""

    def test_organize_find_files_in_cokacdir(self, tmp_path: Path) -> None:
        """cokacdir workspace에서 이동 대상 파일을 찾아야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        pdf_file = _make_recent_file(cokac_ws / "document.pdf")

        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )

        sources = [m["source"] for m in moves]
        assert str(pdf_file) in sources

    def test_organize_moves_to_uploads_by_default(self, tmp_path: Path) -> None:
        """프로젝트명이 없는 파일은 uploads/YYYY-MM/ 으로 이동해야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_recent_file(cokac_ws / "random-doc.pdf")

        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )

        assert len(moves) >= 1
        m = moves[0]
        assert "uploads" in m["destination"]
        # YYYY-MM 패턴 확인
        dest = Path(m["destination"])
        assert dest.parent.name == datetime.now().strftime("%Y-%m")

    def test_organize_dry_run_does_not_move(self, tmp_path: Path) -> None:
        """dry-run 모드에서는 파일이 실제로 이동되지 않아야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        pdf_file = _make_recent_file(cokac_ws / "doc.pdf")

        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )
        fc.execute_organize(moves, dry_run=True)

        # 파일이 원래 위치에 있어야 함
        assert pdf_file.exists()

    def test_organize_execute_moves_files(self, tmp_path: Path) -> None:
        """execute 모드에서는 파일이 실제로 이동되어야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        pdf_file = _make_recent_file(cokac_ws / "doc.pdf")

        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )
        fc.execute_organize(moves, dry_run=False)

        # 파일이 원래 위치에 없어야 함
        assert not pdf_file.exists()

    def test_organize_nonexistent_workspace_returns_empty(self, tmp_path: Path) -> None:
        """존재하지 않는 workspace는 빈 이동 목록을 반환해야 한다."""
        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )
        assert moves == []

    def test_organize_candidate_has_required_fields(self, tmp_path: Path) -> None:
        """각 이동 후보는 source, destination, reason 필드를 가져야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "proj1"
        _make_recent_file(cokac_ws / "doc.pdf")

        moves = fc.find_organize_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            uploads_base=tmp_path / "uploads",
            projects_base=tmp_path / "projects",
        )

        assert len(moves) >= 1
        m = moves[0]
        assert "source" in m
        assert "destination" in m
        assert "reason" in m


# ---------------------------------------------------------------------------
# 9. All Candidates Collection
# ---------------------------------------------------------------------------


class TestAllCandidates:
    """전체 정리 후보 수집 테스트."""

    def test_collect_all_candidates_returns_dict(self, tmp_path: Path) -> None:
        """collect_all_candidates는 카테고리별 딕셔너리를 반환해야 한다."""
        result = fc.collect_all_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            events_dir=tmp_path / "memory" / "events",
            tasks_dir=tmp_path / "memory" / "tasks",
            logs_dir=tmp_path / "logs",
        )

        assert "cokacdir" in result
        assert "done_clear" in result
        assert "dispatch" in result
        assert "logs" in result

    def test_collect_all_candidates_empty_dirs(self, tmp_path: Path) -> None:
        """모든 디렉토리가 비어 있으면 각 카테고리가 빈 목록이어야 한다."""
        result = fc.collect_all_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            events_dir=tmp_path / "memory" / "events",
            tasks_dir=tmp_path / "memory" / "tasks",
            logs_dir=tmp_path / "logs",
        )

        assert result["cokacdir"] == []
        assert result["done_clear"] == []
        assert result["dispatch"] == []
        assert result["logs"] == []

    def test_collect_all_candidates_multiple_categories(self, tmp_path: Path) -> None:
        """여러 카테고리에 후보가 있을 때 모두 수집해야 한다."""
        cokac_ws = tmp_path / ".cokacdir" / "workspace" / "p1"
        events_dir = tmp_path / "memory" / "events"
        tasks_dir = tmp_path / "memory" / "tasks"
        logs_dir = tmp_path / "logs"

        _make_old_file(cokac_ws / "old.pdf", days=31)
        _make_old_file(events_dir / "event.done.clear", days=31)
        _make_old_file(tasks_dir / "dispatch-001.md", days=91)
        _make_old_file(logs_dir / "system.log", days=61)

        result = fc.collect_all_candidates(
            cokacdir_workspace=tmp_path / ".cokacdir" / "workspace",
            events_dir=events_dir,
            tasks_dir=tasks_dir,
            logs_dir=logs_dir,
        )

        assert len(result["cokacdir"]) >= 1
        assert len(result["done_clear"]) >= 1
        assert len(result["dispatch"]) >= 1
        assert len(result["logs"]) >= 1


# ---------------------------------------------------------------------------
# 10. SafetyChecker Integration with Candidates
# ---------------------------------------------------------------------------


class TestSafetyCheckerIntegration:
    """SafetyChecker가 실제 후보 수집과 통합되어 동작하는지 테스트."""

    def test_protected_path_excluded_from_log_candidates(self, tmp_path: Path) -> None:
        """file-cleanup.log 자체는 로그 후보에서 제외되어야 한다."""
        logs_dir = tmp_path / "logs"
        # 정리 로그 자체 생성 (오래된 mtime)
        cleanup_log = _make_old_file(logs_dir / "file-cleanup.log", days=70)
        old_app_log = _make_old_file(logs_dir / "app.log", days=61)

        candidates = fc.find_log_candidates(logs_dir)

        paths = [c["path"] for c in candidates]
        # file-cleanup.log 자체는 포함되면 안 됨
        assert str(cleanup_log) not in paths
        # 다른 오래된 로그는 포함되어야 함
        assert str(old_app_log) in paths

    def test_safety_checker_is_not_protected(self, tmp_path: Path) -> None:
        """일반 파일은 is_protected가 False를 반환해야 한다."""
        checker = fc.SafetyChecker(base_dir=tmp_path)
        normal_file = tmp_path / "logs" / "old.log"
        _make_recent_file(normal_file)

        assert checker.is_protected(normal_file) is False
