"""
test_project_map_incremental.py - project-map.py IncrementalUpdater 기능 테스트

테스터: 헤임달 (dev2 team)
대상: /home/jay/workspace/scripts/project-map.py
설계 Spec: /home/jay/workspace/memory/specs/project-map-incremental-spec.md

테스트 항목:
1. test_validate_path_normal       - 정상 경로 통과
2. test_validate_path_traversal    - ../../ 경로 차단 (ValueError)
3. test_compute_hash               - 파일 hash 일관성
4. test_classify_file              - 파일 분류 정확성
5. test_full_scan_creates_cache    - full scan 후 캐시 파일 생성 확인
6. test_incremental_add_file       - 파일 추가 후 incremental → 구조맵 반영
7. test_incremental_delete_file    - 파일 삭제 후 incremental → 구조맵에서 제거
8. test_incremental_modify_file    - 파일 수정 후 incremental → 구조맵 업데이트
9. test_incremental_vs_full_scan   - incremental 결과 == full scan 결과 (shadow comparison)
10. test_rollback                  - 롤백 후 이전 상태 복원
11. test_render_markdown           - JSON → Markdown 렌더링 정확성
12. test_atomic_write              - 캐시 저장이 atomic인지 확인
13. test_sensitive_file_exclusion  - .env 파일이 구조맵에 포함되지 않음
"""

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

import pytest

# project-map.py 임포트 (하이픈 때문에 importlib 사용)
sys.path.insert(0, "/home/jay/workspace/scripts/")

PROJECT_MAP_PATH = "/home/jay/workspace/scripts/project-map.py"

# project_map 모듈 로드 시도
_spec = importlib.util.spec_from_file_location("project_map", PROJECT_MAP_PATH)
try:
    project_map = importlib.util.module_from_spec(_spec)
    _spec.loader.exec_module(project_map)
    _PROJECT_MAP_LOADED = True
except Exception as _e:
    _PROJECT_MAP_LOADED = False
    _LOAD_ERROR = str(_e)
    project_map = None

# IncrementalUpdater 클래스 존재 여부 확인
_INCREMENTAL_AVAILABLE = (
    _PROJECT_MAP_LOADED
    and hasattr(project_map, "IncrementalUpdater")
)

# 스킵 데코레이터
skip_if_no_incremental = pytest.mark.skipif(
    not _INCREMENTAL_AVAILABLE,
    reason=(
        "IncrementalUpdater 클래스가 project-map.py에 아직 구현되지 않았습니다. "
        f"(project_map loaded: {_PROJECT_MAP_LOADED})"
    ),
)

skip_if_no_project_map = pytest.mark.skipif(
    not _PROJECT_MAP_LOADED,
    reason="project-map.py 로드 실패",
)


# ────────────────────────────────────────────────────────────────
# Fixtures
# ────────────────────────────────────────────────────────────────

def make_sample_project(base_dir: str) -> Path:
    """테스트용 프로젝트 디렉토리 구조를 생성한다."""
    root = Path(base_dir)

    # src/types/
    types_dir = root / "src" / "types"
    types_dir.mkdir(parents=True, exist_ok=True)
    (types_dir / "firestore.ts").write_text(
        "export interface UserRole { id: string; }\nexport type User = { name: string; };",
        encoding="utf-8",
    )

    # src/app/api/health/
    api_dir = root / "src" / "app" / "api" / "health"
    api_dir.mkdir(parents=True, exist_ok=True)
    (api_dir / "route.ts").write_text(
        "export async function GET(req: Request) { return new Response('ok'); }",
        encoding="utf-8",
    )

    # src/components/
    comp_dir = root / "src" / "components"
    comp_dir.mkdir(parents=True, exist_ok=True)
    (comp_dir / "SearchModal.tsx").write_text(
        "export default function SearchModal() { return null; }",
        encoding="utf-8",
    )

    # package.json
    (root / "package.json").write_text(
        json.dumps({
            "name": "test-project",
            "version": "1.0.0",
            "dependencies": {"react": "^18.0.0"},
            "devDependencies": {"typescript": "^5.0.0"},
            "scripts": {"build": "tsc", "dev": "next dev"},
        }),
        encoding="utf-8",
    )

    # .env (민감 파일 - 제외 대상)
    (root / ".env").write_text("SECRET_KEY=super_secret", encoding="utf-8")

    return root


@pytest.fixture
def tmp_project():
    """임시 프로젝트 디렉토리 + 출력 경로를 제공하는 fixture."""
    base = tempfile.mkdtemp(prefix="pm_test_project_")
    out_dir = tempfile.mkdtemp(prefix="pm_test_output_")
    root = make_sample_project(base)
    output_path = Path(out_dir) / "project-map.md"
    yield root, output_path
    shutil.rmtree(base, ignore_errors=True)
    shutil.rmtree(out_dir, ignore_errors=True)


@pytest.fixture
def updater(tmp_project):
    """IncrementalUpdater 인스턴스를 반환하는 fixture (skip if unavailable)."""
    if not _INCREMENTAL_AVAILABLE:
        pytest.skip("IncrementalUpdater 미구현")
    root, output_path = tmp_project
    return project_map.IncrementalUpdater(
        project_root=root,
        output_path=output_path,
        depth=3,
        include_tests=False,
    ), root, output_path


# ────────────────────────────────────────────────────────────────
# 1. test_validate_path_normal
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_validate_path_normal(updater):
    """정상적인 상대 경로는 ValueError 없이 통과해야 한다."""
    upd, root, _ = updater
    # 정상 경로
    valid_paths = [
        "src/types/firestore.ts",
        "src/components/SearchModal.tsx",
        "package.json",
        "src/app/api/health/route.ts",
    ]
    for rel_path in valid_paths:
        # 실제 파일을 생성해두어야 validate_path가 통과할 수 있음
        abs_path = root / rel_path
        abs_path.parent.mkdir(parents=True, exist_ok=True)
        if not abs_path.exists():
            abs_path.write_text("// dummy", encoding="utf-8")
        # 예외 없이 반환되어야 함
        result = upd._validate_path(rel_path)
        assert result is not None, f"validate_path가 None을 반환: {rel_path}"


# ────────────────────────────────────────────────────────────────
# 2. test_validate_path_traversal
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_validate_path_traversal(updater):
    """../../ 경로는 ValueError를 발생시켜야 한다."""
    upd, _, _ = updater
    traversal_paths = [
        "../../etc/passwd",
        "../secret.env",
        "src/../../outside.ts",
        "src/../../../root.ts",
    ]
    for bad_path in traversal_paths:
        with pytest.raises((ValueError, PermissionError), match=r"(?i)(traversal|invalid|outside|not allowed|범위|경로)"):
            upd._validate_path(bad_path)


# ────────────────────────────────────────────────────────────────
# 3. test_compute_hash
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_compute_hash(updater):
    """동일 파일의 hash는 두 번 호출해도 동일해야 하며,
    내용이 달라지면 hash도 달라져야 한다."""
    upd, root, _ = updater
    test_file = root / "hash_test.ts"
    test_file.write_text("export type HashTest = string;", encoding="utf-8")

    hash1 = upd._compute_hash(test_file)
    hash2 = upd._compute_hash(test_file)
    assert hash1 == hash2, "동일 파일의 hash가 일관되지 않음"
    assert len(hash1) == 64, "SHA-256 hex digest는 64자여야 함"

    # 내용 변경 후 hash 달라져야 함
    test_file.write_text("export type HashTest = number;", encoding="utf-8")
    hash3 = upd._compute_hash(test_file)
    assert hash1 != hash3, "파일 내용 변경 후 hash가 변경되지 않음"


# ────────────────────────────────────────────────────────────────
# 4. test_classify_file
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_classify_file(updater):
    """파일 경로에 따라 올바른 섹션으로 분류되어야 한다."""
    upd, _, _ = updater

    cases = [
        # (rel_path,                                 expected_section)
        ("src/types/firestore.ts",                   "types"),
        ("src/app/api/health/route.ts",              "routes"),
        ("src/components/SearchModal.tsx",           "components"),
        ("package.json",                             "config"),
        ("src/utils/helper.ts",                      "types"),   # .ts → types (route.ts 아님)
        ("README.md",                                "other"),
        ("src/lib/utils.js",                         "other"),
    ]

    for rel_path, expected in cases:
        result = upd._classify_file(rel_path)
        assert result == expected, (
            f"_classify_file('{rel_path}') = '{result}', expected '{expected}'"
        )


# ────────────────────────────────────────────────────────────────
# 5. test_full_scan_creates_cache
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_full_scan_creates_cache(updater):
    """full_scan_to_cache() 실행 후 캐시 JSON 파일이 생성되어야 한다."""
    upd, root, output_path = updater

    # 출력 디렉토리 보장
    output_path.parent.mkdir(parents=True, exist_ok=True)

    cache = upd.full_scan_to_cache()

    # 반환값 검증
    assert isinstance(cache, dict), "full_scan_to_cache()는 dict를 반환해야 함"
    assert "files" in cache, "캐시에 'files' 키가 없음"
    assert "version" in cache, "캐시에 'version' 키가 없음"

    # 캐시 파일 생성 확인
    cache_path = upd.cache_path
    assert cache_path.exists(), f"캐시 파일이 생성되지 않음: {cache_path}"

    # JSON 파싱 가능 여부
    loaded = json.loads(cache_path.read_text(encoding="utf-8"))
    assert "files" in loaded


# ────────────────────────────────────────────────────────────────
# 6. test_incremental_add_file
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_incremental_add_file(updater):
    """새 파일 추가 후 incremental update 시 구조맵(캐시)에 반영되어야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # 먼저 full scan으로 캐시 초기화
    upd.full_scan_to_cache()

    # 새 파일 추가
    new_file = root / "src" / "types" / "newtype.ts"
    new_file.write_text(
        "export interface NewType { id: number; label: string; }",
        encoding="utf-8",
    )

    # incremental update
    upd.update(changed_files=["src/types/newtype.ts"], deleted_files=[])

    # 캐시 재로드 후 확인
    cache = upd.load_cache()
    assert cache is not None, "캐시 로드 실패"

    file_keys = list(cache.get("files", {}).keys())
    normalized_keys = [k.replace("\\", "/") for k in file_keys]
    assert "src/types/newtype.ts" in normalized_keys, (
        f"새 파일이 캐시에 없음. 현재 파일 목록: {normalized_keys}"
    )


# ────────────────────────────────────────────────────────────────
# 7. test_incremental_delete_file
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_incremental_delete_file(updater):
    """파일 삭제 후 incremental update 시 구조맵에서 제거되어야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # full scan으로 캐시 초기화
    upd.full_scan_to_cache()
    cache_before = upd.load_cache()
    assert cache_before is not None

    # 삭제 대상 파일 확인
    target_rel = "src/types/firestore.ts"
    target_abs = root / target_rel
    assert target_abs.exists(), "테스트 전제 파일이 없음"

    # 파일 삭제
    target_abs.unlink()

    # incremental update
    upd.update(changed_files=[], deleted_files=[target_rel])

    # 캐시 재로드
    cache_after = upd.load_cache()
    assert cache_after is not None

    file_keys = [k.replace("\\", "/") for k in cache_after.get("files", {}).keys()]
    assert target_rel not in file_keys, (
        f"삭제된 파일이 캐시에 여전히 존재함: {target_rel}"
    )


# ────────────────────────────────────────────────────────────────
# 8. test_incremental_modify_file
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_incremental_modify_file(updater):
    """파일 수정 후 incremental update 시 캐시의 hash가 갱신되어야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    upd.full_scan_to_cache()
    cache_before = upd.load_cache()
    assert cache_before is not None

    target_rel = "src/types/firestore.ts"
    target_abs = root / target_rel

    # 수정 전 hash 기록
    files_before = cache_before.get("files", {})
    # 키 정규화
    norm_before = {k.replace("\\", "/"): v for k, v in files_before.items()}
    old_entry = norm_before.get(target_rel, {})
    old_hash = old_entry.get("hash", "")

    # 파일 내용 변경 (새 타입 추가)
    target_abs.write_text(
        "export interface UserRole { id: string; role: string; }\n"
        "export type User = { name: string; email: string; };",
        encoding="utf-8",
    )

    # incremental update
    upd.update(changed_files=[target_rel], deleted_files=[])

    cache_after = upd.load_cache()
    assert cache_after is not None

    files_after = cache_after.get("files", {})
    norm_after = {k.replace("\\", "/"): v for k, v in files_after.items()}
    new_entry = norm_after.get(target_rel, {})
    new_hash = new_entry.get("hash", "")

    assert new_hash != "", "수정 후 캐시에 hash가 없음"
    assert old_hash != new_hash, (
        "파일 수정 후 캐시 hash가 갱신되지 않음"
    )


# ────────────────────────────────────────────────────────────────
# 9. test_incremental_vs_full_scan
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_incremental_vs_full_scan(updater):
    """incremental update 결과가 full scan 결과와 동일해야 한다 (shadow comparison)."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # 1단계: 초기 full scan
    upd.full_scan_to_cache()

    # 2단계: 파일 하나 추가
    new_ts = root / "src" / "types" / "extra.ts"
    new_ts.write_text("export interface Extra { x: number; }", encoding="utf-8")

    # incremental 방식으로 업데이트
    upd.update(changed_files=["src/types/extra.ts"], deleted_files=[])
    cache_incremental = upd.load_cache()

    # 3단계: 동일 상태에서 full scan 재실행 (비교용)
    cache_full = upd.full_scan_to_cache()

    # 파일 집합 비교
    keys_inc = set(k.replace("\\", "/") for k in cache_incremental.get("files", {}).keys())
    keys_full = set(k.replace("\\", "/") for k in cache_full.get("files", {}).keys())

    # incremental이 놓친 파일이 없어야 함
    missing_in_inc = keys_full - keys_inc
    assert not missing_in_inc, (
        f"incremental에서 누락된 파일: {missing_in_inc}"
    )

    # incremental이 추가로 가진 파일 (삭제됐어야 할 파일)
    extra_in_inc = keys_inc - keys_full
    assert not extra_in_inc, (
        f"incremental에 불필요하게 남은 파일: {extra_in_inc}"
    )


# ────────────────────────────────────────────────────────────────
# 10. test_rollback
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_rollback(updater):
    """롤백 후 .bak 파일로부터 이전 Markdown 상태가 복원되어야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # 초기 full scan + Markdown 생성
    cache = upd.full_scan_to_cache()
    md_content = upd.render_markdown(cache)
    upd._write_markdown_atomic(md_content)

    # 초기 Markdown 내용 저장
    assert output_path.exists(), "full scan 후 Markdown 파일이 없음"
    original_content = output_path.read_text(encoding="utf-8")

    # 파일 하나 추가 후 incremental update
    extra_file = root / "src" / "types" / "rollback_test.ts"
    extra_file.write_text("export interface RollbackType { v: number; }", encoding="utf-8")
    upd.update(changed_files=["src/types/rollback_test.ts"], deleted_files=[])

    updated_content = output_path.read_text(encoding="utf-8")

    # rollback 수행 (bak 파일에서 복원)
    if hasattr(upd, "rollback"):
        upd.rollback()
    else:
        # 수동 rollback: .bak 파일 복원
        bak_path = Path(str(output_path) + ".bak")
        if bak_path.exists():
            shutil.copy2(str(bak_path), str(output_path))
        else:
            pytest.skip(".bak 파일이 없어 rollback 테스트 불가")

    restored_content = output_path.read_text(encoding="utf-8")

    assert restored_content == original_content, (
        "롤백 후 내용이 원본과 다름\n"
        f"원본 길이: {len(original_content)}, 복원 길이: {len(restored_content)}"
    )


# ────────────────────────────────────────────────────────────────
# 11. test_render_markdown
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_render_markdown(updater):
    """JSON 캐시로부터 Markdown 렌더링 시 필수 섹션이 포함되어야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    cache = upd.full_scan_to_cache()
    markdown = upd.render_markdown(cache)

    assert isinstance(markdown, str), "render_markdown()은 str을 반환해야 함"
    assert len(markdown) > 0, "Markdown이 비어 있음"

    # 필수 섹션 헤더 확인
    required_sections = [
        "# Project Map",
        "## Directory Tree",
        "## Types",
        "## API Routes",
        "## Components",
    ]
    for section in required_sections:
        assert section in markdown, f"필수 섹션 누락: '{section}'"

    # .env 내용이 Markdown에 노출되지 않아야 함
    assert "super_secret" not in markdown, ".env 내용이 Markdown에 노출됨"
    assert "SECRET_KEY" not in markdown, ".env 키가 Markdown에 노출됨"


# ────────────────────────────────────────────────────────────────
# 12. test_atomic_write
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_atomic_write(updater):
    """save_cache()가 atomic write(temp → rename) 방식으로 동작하는지 확인한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # full scan으로 초기 캐시 생성
    cache = upd.full_scan_to_cache()
    cache_path = upd.cache_path
    assert cache_path.exists(), "캐시 파일이 없음"

    # 캐시 파일 mtime 기록
    mtime_before = cache_path.stat().st_mtime

    # 약간의 시간 대기 (mtime 해상도 고려)
    time.sleep(0.05)

    # save_cache 재호출
    upd.save_cache(cache)

    # 저장 후 파일이 존재하고 유효한 JSON이어야 함
    assert cache_path.exists(), "save_cache 후 캐시 파일이 사라짐"
    try:
        loaded = json.loads(cache_path.read_text(encoding="utf-8"))
        assert "files" in loaded
    except json.JSONDecodeError as e:
        pytest.fail(f"save_cache 후 캐시가 유효한 JSON이 아님: {e}")

    # 임시 파일이 남아 있지 않아야 함
    cache_dir = cache_path.parent
    tmp_files = list(cache_dir.glob("*.tmp")) + list(cache_dir.glob("tmp_*"))
    assert not tmp_files, f"임시 파일이 남아 있음: {tmp_files}"


# ────────────────────────────────────────────────────────────────
# 13. test_sensitive_file_exclusion
# ────────────────────────────────────────────────────────────────

@skip_if_no_incremental
def test_sensitive_file_exclusion(updater):
    """.env 파일이 캐시(구조맵)에 포함되지 않아야 한다."""
    upd, root, output_path = updater
    output_path.parent.mkdir(parents=True, exist_ok=True)

    # 여러 민감 파일 생성
    sensitive_files = [
        root / ".env",
        root / ".env.local",
        root / "secrets.pem",
        root / "credentials.json",
        root / "api.key",
    ]
    for f in sensitive_files:
        if not f.exists():
            f.write_text("SENSITIVE_DATA=should_not_appear", encoding="utf-8")

    # full scan
    cache = upd.full_scan_to_cache()

    file_keys = [k.replace("\\", "/") for k in cache.get("files", {}).keys()]

    # .env 및 민감 파일이 캐시에 없어야 함
    for f in sensitive_files:
        rel = str(f.relative_to(root)).replace("\\", "/")
        assert rel not in file_keys, (
            f"민감 파일이 캐시에 포함됨: {rel}"
        )

    # incremental update 시도 시에도 추가되지 않아야 함
    env_rel = ".env"
    # .env가 이미 없는지 확인 후 incremental 시도
    upd.update(changed_files=[env_rel], deleted_files=[])
    cache_after = upd.load_cache()
    assert cache_after is not None

    file_keys_after = [k.replace("\\", "/") for k in cache_after.get("files", {}).keys()]
    assert ".env" not in file_keys_after, ".env가 incremental 후 캐시에 추가됨"


# ────────────────────────────────────────────────────────────────
# Fallback: project-map.py 로드는 되지만 IncrementalUpdater 미구현인 경우
# ────────────────────────────────────────────────────────────────

@skip_if_no_project_map
def test_project_map_module_loads():
    """project-map.py 자체는 정상 로드되어야 한다."""
    assert project_map is not None
    # 기존 함수들 존재 확인
    for func_name in [
        "build_tree",
        "extract_types_interfaces",
        "extract_api_routes",
        "extract_components",
        "summarize_package_json",
        "generate_markdown",
    ]:
        assert hasattr(project_map, func_name), (
            f"기존 함수 누락: {func_name}"
        )


def test_incremental_updater_class_placeholder():
    """IncrementalUpdater 클래스 존재 여부를 확인하는 플레이스홀더 테스트.

    이 테스트는 구현 전에도 실행 가능하며,
    IncrementalUpdater가 없으면 경고 메시지를 출력한다.
    """
    if not _PROJECT_MAP_LOADED:
        pytest.skip(f"project-map.py 로드 실패: {_LOAD_ERROR if not _PROJECT_MAP_LOADED else ''}")

    if not _INCREMENTAL_AVAILABLE:
        # 실패가 아닌 xfail로 처리
        pytest.xfail(
            "IncrementalUpdater 클래스가 아직 구현되지 않았습니다. "
            "개발자(dev1/dev3)가 project-map.py에 구현하면 자동으로 활성화됩니다."
        )

    assert hasattr(project_map.IncrementalUpdater, "update")
    assert hasattr(project_map.IncrementalUpdater, "full_scan_to_cache")
    assert hasattr(project_map.IncrementalUpdater, "load_cache")
    assert hasattr(project_map.IncrementalUpdater, "save_cache")
    assert hasattr(project_map.IncrementalUpdater, "render_markdown")
    assert hasattr(project_map.IncrementalUpdater, "_classify_file")
    assert hasattr(project_map.IncrementalUpdater, "_compute_hash")
    assert hasattr(project_map.IncrementalUpdater, "_validate_path")
