"""test_absorption_health_check.py

absorption-health-check 시스템 단위/통합 테스트 (Heimdal, dev2-team)

커버리지:
  1. YAML 파싱 테스트
  2. 헬스체크 로직 단위 테스트 (file_exists, file_recent_activity,
     grep_pattern, audit_trail_recent, process_running)
  3. 출력 포맷 테스트
  4. CLI 필터 테스트 (--source, --status, --summary)
  5. 통합 테스트 (실제 스크립트 실행)
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import textwrap
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import yaml

# ---------------------------------------------------------------------------
# 상수
# ---------------------------------------------------------------------------

SCRIPT_PATH = Path("/home/jay/workspace/scripts/absorption-health-check.py")
REGISTRY_PATH = Path("/home/jay/workspace/config/absorption-registry.yaml")

# ---------------------------------------------------------------------------
# 샘플 YAML 픽스처 데이터
# ---------------------------------------------------------------------------

SAMPLE_REGISTRY_YAML = textwrap.dedent("""\
    version: "1.0"
    sources:
      memory:
        description: "Memory & Learning 시스템"
        items:
          - id: "mem-001"
            name: "Memory Indexer"
            priority: high
            source_section: "memory"
            status: active
            health_check:
              type: file_exists
              path: ~/workspace/scripts/memory-indexer.py
          - id: "mem-002"
            name: "Memory Janitor"
            priority: medium
            source_section: "memory"
            status: implemented
            health_check:
              type: file_recent_activity
              path: ~/workspace/logs/memory-janitor.log
              max_age_hours: 48
      security:
        description: "보안 시스템"
        items:
          - id: "sec-001"
            name: "Secret Rotation Check"
            priority: high
            source_section: "security"
            status: recommended
            health_check:
              type: grep_pattern
              pattern: "ROTATION_POLICY"
              target: ~/workspace/scripts/secret-rotation-check.py
          - id: "sec-002"
            name: "Audit Trail"
            priority: medium
            source_section: "security"
            status: degraded
            health_check:
              type: audit_trail_recent
              path: ~/workspace/logs/audit.jsonl
              max_age_hours: 24
      infra:
        description: "인프라 시스템"
        items:
          - id: "inf-001"
            name: "Activity Watcher"
            priority: high
            source_section: "infra"
            status: active
            health_check:
              type: process_running
              process_name: activity-watcher
          - id: "inf-002"
            name: "Duplicate Checker"
            priority: low
            source_section: "infra"
            status: duplicate
            health_check:
              type: file_exists
              path: ~/workspace/scripts/activity-watcher.py
""")

VALID_STATUSES = {"recommended", "implemented", "active", "degraded", "duplicate"}

# ---------------------------------------------------------------------------
# 헬퍼: 모듈 로드
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# 1. YAML 파싱 테스트
# ---------------------------------------------------------------------------


class TestYamlParsing:
    """YAML 파싱 및 구조 검증"""

    @pytest.fixture()
    def registry_data(self) -> dict:
        """샘플 YAML 픽스처를 파싱한 딕셔너리"""
        return yaml.safe_load(SAMPLE_REGISTRY_YAML)

    @pytest.fixture()
    def registry_file(self, tmp_path) -> Path:
        """tmp_path에 샘플 YAML 파일 생성"""
        p = tmp_path / "absorption-registry.yaml"
        p.write_text(SAMPLE_REGISTRY_YAML, encoding="utf-8")
        return p

    # ---- 로드 가능성 ----

    def test_yaml_loads_without_error(self, registry_file: Path) -> None:
        """YAML 파일이 오류 없이 로드되어야 한다."""
        data = yaml.safe_load(registry_file.read_text())
        assert data is not None

    def test_real_registry_loads_if_exists(self) -> None:
        """실제 레지스트리가 존재하면 로드할 수 있어야 한다."""
        if not REGISTRY_PATH.exists():
            pytest.skip("실제 registry 파일 없음 — 스킵")
        data = yaml.safe_load(REGISTRY_PATH.read_text())
        assert data is not None

    # ---- 최상위 구조 ----

    def test_has_version_field(self, registry_data: dict) -> None:
        """version 필드가 존재해야 한다."""
        assert "version" in registry_data

    def test_has_sources_dict(self, registry_data: dict) -> None:
        """sources 필드가 딕셔너리여야 한다."""
        assert "sources" in registry_data
        assert isinstance(registry_data["sources"], dict)

    def test_sources_not_empty(self, registry_data: dict) -> None:
        """sources 에 최소 1개 이상의 소스가 있어야 한다."""
        assert len(registry_data["sources"]) > 0

    # ---- 소스별 items 리스트 ----

    def test_each_source_has_items_list(self, registry_data: dict) -> None:
        """각 source 는 items 리스트를 가져야 한다."""
        for src_name, src in registry_data["sources"].items():
            assert "items" in src, f"'{src_name}' 에 items 없음"
            assert isinstance(src["items"], list), f"'{src_name}'.items 가 list 가 아님"

    # ---- 필수 필드 검증 ----

    REQUIRED_ITEM_FIELDS = {"id", "name", "priority", "source_section", "status", "health_check"}

    def _all_items(self, registry_data: dict):
        for src in registry_data["sources"].values():
            yield from src.get("items", [])

    def test_items_have_required_fields(self, registry_data: dict) -> None:
        """모든 item 에 필수 필드가 존재해야 한다."""
        for item in self._all_items(registry_data):
            missing = self.REQUIRED_ITEM_FIELDS - set(item.keys())
            assert not missing, f"item '{item.get('id')}' 에 필드 누락: {missing}"

    def test_status_values_are_valid(self, registry_data: dict) -> None:
        """status 값이 허용된 값 중 하나여야 한다."""
        for item in self._all_items(registry_data):
            assert item["status"] in VALID_STATUSES, (
                f"item '{item.get('id')}' 의 status '{item['status']}' 가 유효하지 않음"
            )

    def test_health_check_has_type(self, registry_data: dict) -> None:
        """health_check 딕셔너리에 type 필드가 있어야 한다."""
        for item in self._all_items(registry_data):
            hc = item.get("health_check", {})
            assert "type" in hc, f"item '{item.get('id')}' 의 health_check 에 type 없음"

    def test_id_values_are_unique(self, registry_data: dict) -> None:
        """모든 item 의 id 가 고유해야 한다."""
        ids = [item["id"] for item in self._all_items(registry_data)]
        assert len(ids) == len(set(ids)), "중복 id 발견"

    def test_source_section_matches_source_key(self, registry_data: dict) -> None:
        """source_section 값이 해당 sources 키와 일치해야 한다."""
        for src_name, src in registry_data["sources"].items():
            for item in src.get("items", []):
                assert item["source_section"] == src_name, (
                    f"item '{item['id']}' 의 source_section '{item['source_section']}' 이 "
                    f"source key '{src_name}' 와 불일치"
                )


# ---------------------------------------------------------------------------
# 2. 헬스체크 로직 단위 테스트
# ---------------------------------------------------------------------------


class TestHealthCheckFileExists:
    """health_check type: file_exists"""

    def _run_check(self, path: str) -> bool:
        """file_exists 체크 로직을 직접 구현하여 테스트 (모듈 독립)."""
        expanded = os.path.expanduser(path)
        return os.path.exists(expanded)

    def test_existing_file_passes(self, tmp_path: Path) -> None:
        """존재하는 파일 → pass (True)"""
        f = tmp_path / "target.txt"
        f.write_text("data")
        assert self._run_check(str(f)) is True

    def test_nonexistent_file_fails(self, tmp_path: Path) -> None:
        """존재하지 않는 파일 → fail (False)"""
        missing = tmp_path / "no_such_file.txt"
        assert self._run_check(str(missing)) is False

    def test_tilde_path_expansion(self, tmp_path: Path, monkeypatch) -> None:
        """~ 경로가 올바르게 확장되어야 한다."""
        monkeypatch.setenv("HOME", str(tmp_path))
        target = tmp_path / "sentinel.txt"
        target.write_text("ok")
        # ~ 경로 사용
        assert self._run_check("~/sentinel.txt") is True

    def test_tilde_path_nonexistent(self, tmp_path: Path, monkeypatch) -> None:
        """~ 경로 확장 후 파일 없으면 fail"""
        monkeypatch.setenv("HOME", str(tmp_path))
        assert self._run_check("~/nonexistent_file.txt") is False

    def test_existing_directory_passes(self, tmp_path: Path) -> None:
        """디렉토리 경로도 존재하면 pass"""
        assert self._run_check(str(tmp_path)) is True


class TestHealthCheckFileRecentActivity:
    """health_check type: file_recent_activity"""

    def _run_check(self, path: str, max_age_hours: int = 24) -> bool:
        """file_recent_activity 로직."""
        expanded = os.path.expanduser(path)
        if not os.path.exists(expanded):
            return False
        mtime = os.path.getmtime(expanded)
        age_hours = (time.time() - mtime) / 3600
        return age_hours <= max_age_hours

    def test_recently_modified_file_passes(self, tmp_path: Path) -> None:
        """방금 수정된 파일 → pass"""
        f = tmp_path / "recent.log"
        f.write_text("log entry")
        assert self._run_check(str(f), max_age_hours=1) is True

    def test_old_file_fails(self, tmp_path: Path) -> None:
        """오래된 파일 → fail"""
        f = tmp_path / "old.log"
        f.write_text("old entry")
        # mtime 을 72시간 전으로 조정
        old_time = time.time() - 72 * 3600
        os.utime(str(f), (old_time, old_time))
        assert self._run_check(str(f), max_age_hours=24) is False

    def test_nonexistent_file_fails(self, tmp_path: Path) -> None:
        """파일 없음 → fail"""
        assert self._run_check(str(tmp_path / "missing.log")) is False

    def test_custom_max_age_hours(self, tmp_path: Path) -> None:
        """max_age_hours 파라미터가 적용되어야 한다."""
        f = tmp_path / "medium.log"
        f.write_text("data")
        # 30시간 전으로 설정
        past = time.time() - 30 * 3600
        os.utime(str(f), (past, past))
        # 48시간 기준이면 pass, 24시간 기준이면 fail
        assert self._run_check(str(f), max_age_hours=48) is True
        assert self._run_check(str(f), max_age_hours=24) is False

    def test_edge_age_at_boundary(self, tmp_path: Path) -> None:
        """정확히 경계(max_age_hours)에서 pass 처리"""
        f = tmp_path / "boundary.log"
        f.write_text("data")
        # 정확히 1시간 전 (약간의 여유 포함)
        past = time.time() - 3600 + 10  # 10초 여유
        os.utime(str(f), (past, past))
        assert self._run_check(str(f), max_age_hours=1) is True


class TestHealthCheckGrepPattern:
    """health_check type: grep_pattern"""

    def _run_check(self, pattern: str, target: str) -> bool:
        """grep_pattern 로직."""
        import re
        expanded = os.path.expanduser(target)
        if os.path.isdir(expanded):
            # 디렉토리 내 모든 파일 검색
            for root, _, files in os.walk(expanded):
                for fname in files:
                    fpath = os.path.join(root, fname)
                    try:
                        content = Path(fpath).read_text(errors="ignore")
                        if re.search(pattern, content):
                            return True
                    except (IOError, OSError):
                        continue
            return False
        else:
            if not os.path.exists(expanded):
                return False
            try:
                content = Path(expanded).read_text(errors="ignore")
                return bool(re.search(pattern, content))
            except (IOError, OSError):
                return False

    def test_matching_pattern_in_file_passes(self, tmp_path: Path) -> None:
        """파일에 패턴이 있으면 pass"""
        f = tmp_path / "config.py"
        f.write_text("ROTATION_POLICY = 90\n")
        assert self._run_check("ROTATION_POLICY", str(f)) is True

    def test_nonmatching_pattern_fails(self, tmp_path: Path) -> None:
        """패턴이 없으면 fail"""
        f = tmp_path / "config.py"
        f.write_text("SOME_OTHER_SETTING = 10\n")
        assert self._run_check("ROTATION_POLICY", str(f)) is False

    def test_pattern_in_directory_passes(self, tmp_path: Path) -> None:
        """디렉토리 내 파일에 패턴 있으면 pass"""
        (tmp_path / "a.py").write_text("TARGET_KEY = True\n")
        (tmp_path / "b.py").write_text("OTHER = False\n")
        assert self._run_check("TARGET_KEY", str(tmp_path)) is True

    def test_pattern_not_in_directory_fails(self, tmp_path: Path) -> None:
        """디렉토리 내 어떤 파일에도 패턴 없으면 fail"""
        (tmp_path / "x.py").write_text("FOO = 1\n")
        (tmp_path / "y.py").write_text("BAR = 2\n")
        assert self._run_check("MISSING_PATTERN_XYZ", str(tmp_path)) is False

    def test_regex_pattern_works(self, tmp_path: Path) -> None:
        """정규식 패턴이 동작해야 한다."""
        f = tmp_path / "data.txt"
        f.write_text("error_code=404\n")
        assert self._run_check(r"error_code=\d+", str(f)) is True

    def test_nonexistent_file_fails(self, tmp_path: Path) -> None:
        """파일 없으면 fail"""
        assert self._run_check("pattern", str(tmp_path / "no_file.txt")) is False


class TestHealthCheckAuditTrailRecent:
    """health_check type: audit_trail_recent"""

    def _run_check(self, path: str, max_age_hours: int = 24) -> bool:
        """audit_trail_recent 로직: JSONL 파일에서 최근 항목 확인."""
        expanded = os.path.expanduser(path)
        if not os.path.exists(expanded):
            return False
        lines = Path(expanded).read_text(errors="ignore").strip().splitlines()
        if not lines:
            return False
        cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=max_age_hours)
        for line in reversed(lines):
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
                ts_str = entry.get("timestamp") or entry.get("ts") or entry.get("time")
                if ts_str:
                    ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00").rstrip("+00:00"))
                    if ts >= cutoff:
                        return True
            except (json.JSONDecodeError, ValueError):
                continue
        return False

    def _make_jsonl(self, path: Path, entries: list[dict]) -> None:
        path.write_text("\n".join(json.dumps(e) for e in entries), encoding="utf-8")

    def test_recent_entry_passes(self, tmp_path: Path) -> None:
        """최근 항목이 있으면 pass"""
        now_str = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
        f = tmp_path / "audit.jsonl"
        self._make_jsonl(f, [{"timestamp": now_str, "event": "login"}])
        assert self._run_check(str(f), max_age_hours=24) is True

    def test_old_entries_fail(self, tmp_path: Path) -> None:
        """오래된 항목만 있으면 fail"""
        old_str = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=72)).isoformat()
        f = tmp_path / "audit.jsonl"
        self._make_jsonl(f, [{"timestamp": old_str, "event": "login"}])
        assert self._run_check(str(f), max_age_hours=24) is False

    def test_empty_file_fails(self, tmp_path: Path) -> None:
        """빈 파일 → fail"""
        f = tmp_path / "empty.jsonl"
        f.write_text("")
        assert self._run_check(str(f)) is False

    def test_nonexistent_file_fails(self, tmp_path: Path) -> None:
        """파일 없음 → fail"""
        assert self._run_check(str(tmp_path / "missing.jsonl")) is False

    def test_mixed_entries_passes_if_recent_exists(self, tmp_path: Path) -> None:
        """오래된 항목 + 최신 항목 혼재 → pass"""
        old_str = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=72)).isoformat()
        now_str = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
        f = tmp_path / "mixed.jsonl"
        self._make_jsonl(
            f,
            [
                {"timestamp": old_str, "event": "old"},
                {"timestamp": now_str, "event": "new"},
            ],
        )
        assert self._run_check(str(f), max_age_hours=24) is True

    def test_custom_max_age_hours(self, tmp_path: Path) -> None:
        """max_age_hours 파라미터 적용 확인"""
        entry_time = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=36)).isoformat()
        f = tmp_path / "audit.jsonl"
        self._make_jsonl(f, [{"timestamp": entry_time, "event": "evt"}])
        assert self._run_check(str(f), max_age_hours=48) is True
        assert self._run_check(str(f), max_age_hours=24) is False


class TestHealthCheckProcessRunning:
    """health_check type: process_running"""

    def _run_check(self, process_name: str) -> bool:
        """process_running 로직: ps aux 로 프로세스 확인."""
        try:
            result = subprocess.run(
                ["pgrep", "-f", process_name],
                capture_output=True,
                text=True,
            )
            return result.returncode == 0
        except FileNotFoundError:
            # pgrep 없는 환경 fallback
            result = subprocess.run(
                ["ps", "aux"],
                capture_output=True,
                text=True,
            )
            return process_name in result.stdout

    def test_running_process_passes(self) -> None:
        """현재 실행 중인 프로세스 → pass"""
        # python 프로세스는 항상 실행 중
        assert self._run_check("python") is True or self._run_check("python3") is True

    def test_nonrunning_process_fails(self) -> None:
        """존재하지 않는 프로세스 → fail"""
        assert self._run_check("nonexistent_process_xyz_12345_abc") is False

    def test_process_name_substring_match(self) -> None:
        """프로세스명 서브스트링으로도 매칭되어야 한다."""
        # 현재 스크립트(pytest) 확인
        assert self._run_check("pytest") is True


# ---------------------------------------------------------------------------
# 3. 출력 포맷 테스트
# ---------------------------------------------------------------------------
# Actual script output structure:
#   timestamp: ISO 8601
#   summary:   {total, active, implemented, recommended, degraded, duplicate}
#   by_source: {src_name: {total, active, implemented, ...}}  ← count dict
#   duplicates: [{items: [...], description: "..."}]
#   items:     [{id, name, source, priority, declared_status, status,
#                health_check_result ("pass"|"fail"|"skip"), health_check_detail}]


class TestOutputFormat:
    """출력 JSON 구조 검증 (실제 스크립트 포맷 기준)"""

    @pytest.fixture()
    def sample_output(self) -> dict:
        """실제 스크립트가 생성하는 출력 포맷과 일치하는 샘플"""
        return {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "summary": {
                "total": 6,
                "active": 2,
                "implemented": 1,
                "recommended": 1,
                "degraded": 1,
                "duplicate": 1,
            },
            "by_source": {
                "memory": {
                    "total": 2,
                    "active": 1,
                    "implemented": 1,
                    "recommended": 0,
                    "degraded": 0,
                    "duplicate": 0,
                },
                "security": {
                    "total": 2,
                    "active": 0,
                    "implemented": 0,
                    "recommended": 1,
                    "degraded": 1,
                    "duplicate": 0,
                },
                "infra": {
                    "total": 2,
                    "active": 1,
                    "implemented": 0,
                    "recommended": 0,
                    "degraded": 0,
                    "duplicate": 1,
                },
            },
            "duplicates": [
                {"items": ["inf-002", "inf-003"], "description": "중복 활동 감지기"}
            ],
            "items": [
                {
                    "id": "mem-001",
                    "name": "Memory Indexer",
                    "source": "memory",
                    "priority": "high",
                    "declared_status": "active",
                    "status": "active",
                    "health_check_result": "pass",
                    "health_check_detail": "exists: /home/jay/workspace/scripts/memory-indexer.py",
                },
                {
                    "id": "mem-002",
                    "name": "Memory Janitor",
                    "source": "memory",
                    "priority": "medium",
                    "declared_status": "implemented",
                    "status": "implemented",
                    "health_check_result": "pass",
                    "health_check_detail": "last modified 2.0h ago (limit 48h)",
                },
                {
                    "id": "sec-001",
                    "name": "Secret Rotation Check",
                    "source": "security",
                    "priority": "high",
                    "declared_status": "recommended",
                    "status": "recommended",
                    "health_check_result": "pass",
                    "health_check_detail": "pattern found in: secret-rotation-check.py",
                },
                {
                    "id": "sec-002",
                    "name": "Audit Trail",
                    "source": "security",
                    "priority": "medium",
                    "declared_status": "active",
                    "status": "degraded",
                    "health_check_result": "fail",
                    "health_check_detail": "no entries within last 24h",
                },
                {
                    "id": "inf-001",
                    "name": "Activity Watcher",
                    "source": "infra",
                    "priority": "high",
                    "declared_status": "active",
                    "status": "active",
                    "health_check_result": "pass",
                    "health_check_detail": "process 'activity-watcher' found (1 match(es))",
                },
                {
                    "id": "inf-002",
                    "name": "Duplicate Checker",
                    "source": "infra",
                    "priority": "low",
                    "declared_status": "duplicate",
                    "status": "duplicate",
                    "health_check_result": "pass",
                    "health_check_detail": "exists: /home/jay/workspace/scripts/activity-watcher.py",
                },
            ],
        }

    def test_output_has_required_top_keys(self, sample_output: dict) -> None:
        """출력에 필수 최상위 키가 있어야 한다."""
        required = {"timestamp", "summary", "by_source", "duplicates", "items"}
        assert required <= set(sample_output.keys())

    def test_timestamp_is_valid_iso(self, sample_output: dict) -> None:
        """timestamp 가 유효한 ISO 8601 형식이어야 한다."""
        ts = sample_output["timestamp"]
        parsed = datetime.fromisoformat(ts)
        assert isinstance(parsed, datetime)

    def test_summary_has_total_field(self, sample_output: dict) -> None:
        """summary 에 total 필드가 있어야 한다."""
        assert "total" in sample_output["summary"]

    def test_summary_has_all_status_fields(self, sample_output: dict) -> None:
        """summary 에 모든 status 카운트 키가 있어야 한다."""
        s = sample_output["summary"]
        for status in ("active", "implemented", "recommended", "degraded", "duplicate"):
            assert status in s, f"summary 에 '{status}' 키 없음"

    def test_summary_status_counts_sum_to_total(self, sample_output: dict) -> None:
        """summary 내 status 카운트 합이 total 과 같아야 한다."""
        s = sample_output["summary"]
        status_sum = sum(s[k] for k in ("active", "implemented", "recommended", "degraded", "duplicate"))
        assert status_sum == s["total"], (
            f"status 합계({status_sum}) != total({s['total']})"
        )

    def test_summary_total_matches_items_list(self, sample_output: dict) -> None:
        """summary.total 이 items 리스트 길이와 같아야 한다."""
        assert sample_output["summary"]["total"] == len(sample_output["items"])

    def test_by_source_grouping_is_correct(self, sample_output: dict) -> None:
        """by_source 키가 YAML 소스 키와 일치해야 한다."""
        registry = yaml.safe_load(SAMPLE_REGISTRY_YAML)
        source_keys = set(registry["sources"].keys())
        for src_key in sample_output["by_source"]:
            assert src_key in source_keys, f"알 수 없는 source: {src_key}"

    def test_by_source_each_entry_is_count_dict(self, sample_output: dict) -> None:
        """by_source 내 각 값은 카운트 딕셔너리여야 한다."""
        for src, counts in sample_output["by_source"].items():
            assert isinstance(counts, dict), f"'{src}' 의 값이 dict 가 아님"
            assert "total" in counts, f"'{src}' 카운트에 total 없음"

    def test_by_source_totals_sum_to_summary_total(self, sample_output: dict) -> None:
        """by_source 의 total 합이 summary.total 과 같아야 한다."""
        source_total = sum(v["total"] for v in sample_output["by_source"].values())
        assert source_total == sample_output["summary"]["total"]

    def test_each_item_has_health_check_result(self, sample_output: dict) -> None:
        """items 리스트의 모든 항목에 health_check_result 필드가 있어야 한다."""
        for item in sample_output["items"]:
            assert "health_check_result" in item, (
                f"item '{item.get('id')}' 에 health_check_result 없음"
            )

    def test_health_check_result_values_are_valid(self, sample_output: dict) -> None:
        """health_check_result 값이 pass/fail/skip 중 하나여야 한다."""
        valid = {"pass", "fail", "skip"}
        for item in sample_output["items"]:
            assert item["health_check_result"] in valid, (
                f"item '{item.get('id')}' 의 result '{item['health_check_result']}' 가 유효하지 않음"
            )

    def test_items_have_required_fields(self, sample_output: dict) -> None:
        """items 의 각 항목에 필수 필드가 있어야 한다."""
        required = {"id", "name", "source", "priority", "declared_status", "status",
                    "health_check_result", "health_check_detail"}
        for item in sample_output["items"]:
            missing = required - set(item.keys())
            assert not missing, f"item '{item.get('id')}' 에 필드 누락: {missing}"

    def test_duplicate_detection(self, sample_output: dict) -> None:
        """duplicates 는 리스트이고 각 항목에 items 필드가 있어야 한다."""
        assert isinstance(sample_output["duplicates"], list)
        for dup in sample_output["duplicates"]:
            assert "items" in dup, "duplicate 항목에 'items' 필드 없음"

    def test_active_fail_becomes_degraded(self, sample_output: dict) -> None:
        """active 항목이 health check fail 이면 status 가 degraded 로 변경되어야 한다."""
        degraded_items = [
            i for i in sample_output["items"]
            if i["declared_status"] == "active" and i["health_check_result"] == "fail"
        ]
        for item in degraded_items:
            assert item["status"] == "degraded", (
                f"item '{item['id']}': active+fail 이지만 status='{item['status']}'"
            )

    def test_output_is_json_serializable(self, sample_output: dict) -> None:
        """출력 전체가 JSON 직렬화 가능해야 한다."""
        serialized = json.dumps(sample_output)
        parsed_back = json.loads(serialized)
        assert parsed_back["summary"]["total"] == sample_output["summary"]["total"]


# ---------------------------------------------------------------------------
# 4. CLI 필터 테스트
# ---------------------------------------------------------------------------


class TestCliFilters:
    """CLI 필터 옵션 테스트 (--source, --status, --summary)"""

    @pytest.fixture()
    def full_items(self) -> list[dict]:
        """전체 items 픽스처 (실제 스크립트 items 포맷)"""
        return [
            {"id": "mem-001", "name": "M1", "source": "memory",
             "priority": "high", "declared_status": "active", "status": "active",
             "health_check_result": "pass", "health_check_detail": "ok"},
            {"id": "mem-002", "name": "M2", "source": "memory",
             "priority": "medium", "declared_status": "implemented", "status": "implemented",
             "health_check_result": "pass", "health_check_detail": "ok"},
            {"id": "sec-001", "name": "S1", "source": "security",
             "priority": "high", "declared_status": "active", "status": "active",
             "health_check_result": "pass", "health_check_detail": "ok"},
            {"id": "sec-002", "name": "S2", "source": "security",
             "priority": "medium", "declared_status": "recommended", "status": "recommended",
             "health_check_result": "fail", "health_check_detail": "not found"},
        ]

    def _filter_by_source(self, items: list[dict], source: str) -> list[dict]:
        """--source 필터 로직 시뮬레이션"""
        return [i for i in items if i["source"] == source]

    def _filter_by_status(self, items: list[dict], status: str) -> list[dict]:
        """--status 필터 로직 시뮬레이션"""
        return [i for i in items if i["status"] == status]

    def _build_by_source(self, items: list[dict]) -> dict:
        """items 리스트로 by_source 카운트 딕셔너리 구성"""
        STATUS_KEYS = ("active", "implemented", "recommended", "degraded", "duplicate")
        result: dict[str, dict] = {}
        for item in items:
            src = item["source"]
            if src not in result:
                result[src] = {k: 0 for k in STATUS_KEYS}
                result[src]["total"] = 0
            result[src]["total"] += 1
            st = item["status"]
            if st in result[src]:
                result[src][st] += 1
        return result

    # ---- --source 필터 ----

    def test_source_filter_returns_only_matching(self, full_items: list[dict]) -> None:
        """--source memory → memory 항목만 반환"""
        result = self._filter_by_source(full_items, "memory")
        for item in result:
            assert item["source"] == "memory"

    def test_source_filter_excludes_other_sources(self, full_items: list[dict]) -> None:
        """--source memory 필터 후 security 항목 없어야 한다."""
        result = self._filter_by_source(full_items, "memory")
        assert all(i["source"] != "security" for i in result)

    def test_source_filter_correct_count(self, full_items: list[dict]) -> None:
        """--source 필터 후 count 가 정확해야 한다."""
        result = self._filter_by_source(full_items, "security")
        expected = sum(1 for i in full_items if i["source"] == "security")
        assert len(result) == expected

    def test_source_filter_nonexistent_returns_empty(self, full_items: list[dict]) -> None:
        """존재하지 않는 source 필터 → 빈 리스트"""
        result = self._filter_by_source(full_items, "nonexistent_source")
        assert result == []

    def test_by_source_build_from_filtered(self, full_items: list[dict]) -> None:
        """필터된 items 로 by_source 구성이 올바른지 확인"""
        filtered = self._filter_by_source(full_items, "memory")
        by_src = self._build_by_source(filtered)
        assert "memory" in by_src
        assert "security" not in by_src
        assert by_src["memory"]["total"] == len(filtered)

    # ---- --status 필터 ----

    def test_status_filter_returns_only_matching(self, full_items: list[dict]) -> None:
        """--status active → active 항목만 반환"""
        result = self._filter_by_status(full_items, "active")
        for item in result:
            assert item["status"] == "active"

    def test_status_filter_correct_count(self, full_items: list[dict]) -> None:
        """--status 필터 후 카운트 검증"""
        result = self._filter_by_status(full_items, "active")
        expected = sum(1 for i in full_items if i["status"] == "active")
        assert len(result) == expected

    def test_status_filter_recommended(self, full_items: list[dict]) -> None:
        """--status recommended → recommended 항목만"""
        result = self._filter_by_status(full_items, "recommended")
        for item in result:
            assert item["status"] == "recommended"

    def test_status_filter_nonexistent_returns_empty(self, full_items: list[dict]) -> None:
        """존재하지 않는 status 필터 → 빈 결과"""
        result = self._filter_by_status(full_items, "nonexistent_status")
        assert result == []

    # ---- --summary 옵션 ----

    def test_summary_output_excludes_items_list(self, full_items: list[dict]) -> None:
        """--summary 출력에 items 상세 목록이 없어야 한다."""
        # 스크립트의 --summary 모드: items 키를 제외하고 출력
        summary_only = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "summary": {"total": len(full_items)},
            "by_source": self._build_by_source(full_items),
            "duplicates": [],
        }
        assert "items" not in summary_only

    def test_summary_has_totals(self, full_items: list[dict]) -> None:
        """summary 에 total 포함"""
        summary = {"total": len(full_items)}
        assert "total" in summary
        assert summary["total"] == len(full_items)


# ---------------------------------------------------------------------------
# 5. 통합 테스트
# ---------------------------------------------------------------------------
# 스크립트는 registry 경로가 하드코딩되어 있음.
# 실제 레지스트리가 없으면 대부분의 통합 테스트를 스킵한다.


class TestIntegration:
    """실제 스크립트 실행 통합 테스트"""

    @pytest.fixture(autouse=True)
    def skip_if_no_script(self) -> None:
        """스크립트가 없으면 통합 테스트 전체 스킵"""
        if not SCRIPT_PATH.exists():
            pytest.skip(f"스크립트 없음: {SCRIPT_PATH} — 통합 테스트 스킵")

    def _run_script(self, *extra_args: str) -> subprocess.CompletedProcess:
        """스크립트를 실행한다. registry 경로는 스크립트에 하드코딩되어 있음."""
        cmd = [sys.executable, str(SCRIPT_PATH)] + list(extra_args)
        return subprocess.run(cmd, capture_output=True, text=True, timeout=30)

    @pytest.fixture(autouse=True)
    def skip_if_no_registry(self) -> None:
        """레지스트리 파일이 없으면 통합 테스트 전체 스킵"""
        if not REGISTRY_PATH.exists():
            pytest.skip(f"레지스트리 없음: {REGISTRY_PATH} — 통합 테스트 스킵")

    def test_script_runs_successfully(self) -> None:
        """스크립트가 오류 없이 실행되어야 한다."""
        result = self._run_script()
        assert result.returncode == 0, (
            f"스크립트 실패\nstdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}"
        )

    def test_output_is_valid_json(self) -> None:
        """스크립트 stdout 이 유효한 JSON 이어야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        try:
            data = json.loads(result.stdout)
        except json.JSONDecodeError as exc:
            pytest.fail(f"stdout 이 유효한 JSON 이 아님: {exc}\n출력: {result.stdout[:500]}")
        assert isinstance(data, dict)

    def test_output_has_required_keys(self) -> None:
        """출력 JSON 에 필수 키가 있어야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        required = {"timestamp", "summary", "by_source", "duplicates", "items"}
        missing = required - set(data.keys())
        assert not missing, f"출력에 필수 키 누락: {missing}"

    def test_all_items_have_health_check_result(self) -> None:
        """출력 items 리스트의 모든 항목에 health_check_result 가 있어야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        for item in data.get("items", []):
            assert "health_check_result" in item, (
                f"item '{item.get('id')}' 에 health_check_result 없음"
            )

    def test_health_check_result_values_valid(self) -> None:
        """health_check_result 값이 pass/fail/skip 중 하나여야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        valid = {"pass", "fail", "skip"}
        for item in data.get("items", []):
            assert item["health_check_result"] in valid, (
                f"item '{item.get('id')}' 의 result '{item['health_check_result']}' 가 유효하지 않음"
            )

    def test_summary_total_matches_items_count(self) -> None:
        """summary.total 이 items 리스트 개수와 같아야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        assert data["summary"]["total"] == len(data["items"]), (
            f"summary.total({data['summary']['total']}) != len(items)({len(data['items'])})"
        )

    def test_summary_status_counts_sum_to_total(self) -> None:
        """summary 의 status 카운트 합이 total 과 같아야 한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        s = data["summary"]
        status_sum = sum(
            v for k, v in s.items()
            if k != "total" and isinstance(v, (int, float))
        )
        assert status_sum == s["total"], (
            f"status 합계({status_sum}) != total({s['total']})"
        )

    def test_source_filter_cli(self) -> None:
        """--source 옵션이 동작해야 한다."""
        registry_data = yaml.safe_load(REGISTRY_PATH.read_text())
        first_source = next(iter(registry_data["sources"]))
        result = self._run_script("--source", first_source)
        assert result.returncode == 0, f"--source 실패: {result.stderr}"
        data = json.loads(result.stdout)
        # items 리스트의 모든 항목이 해당 source 여야 함
        for item in data.get("items", []):
            assert item["source"] == first_source, (
                f"필터 후 다른 source '{item['source']}' 가 포함됨"
            )
        # by_source 에도 해당 source 만 있어야 함
        for src in data.get("by_source", {}):
            assert src == first_source, f"by_source 에 다른 source '{src}' 가 포함됨"

    def test_status_filter_cli(self) -> None:
        """--status 옵션이 동작해야 한다."""
        result = self._run_script("--status", "active")
        assert result.returncode == 0, f"--status 실패: {result.stderr}"
        data = json.loads(result.stdout)
        for item in data.get("items", []):
            assert item["status"] == "active", (
                f"필터 후 status='{item['status']}' 항목이 포함됨"
            )

    def test_summary_flag_excludes_items(self) -> None:
        """--summary 옵션 실행 시 items 키가 없어야 한다."""
        result = self._run_script("--summary")
        assert result.returncode == 0, f"--summary 실패: {result.stderr}"
        data = json.loads(result.stdout)
        assert "summary" in data
        # --summary 모드에서는 items 상세 없음
        assert "items" not in data, "items 가 --summary 출력에 포함되면 안 됨"

    def test_real_registry_output_structure(self) -> None:
        """실제 레지스트리로 실행한 출력의 전체 구조를 검증한다."""
        result = self._run_script()
        assert result.returncode == 0
        data = json.loads(result.stdout)
        # 필수 키
        assert "summary" in data
        assert "by_source" in data
        assert "items" in data
        assert "duplicates" in data
        assert "timestamp" in data
        # 타임스탬프 파싱 가능
        datetime.fromisoformat(data["timestamp"])
