#!/usr/bin/env python3
"""
Tests for ast_dependency_map.py — AST 기반 Python 의존성 맵 생성기

테스트 커버리지:
- AST 파싱 (유효/무효 파일)
- 임포트 모듈 추출
- 의존성 그래프 구축
- 직접 임포터 조회
- analyze() 출력 구조
- 함수 레벨 호출자 분석
- 테스트 파일 감지
"""

import ast
import sys
import tempfile
from pathlib import Path

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

from ast_dependency_map import (  # type: ignore[import-not-found]  # noqa: E402
    DependencyGraph,
    _extract_imported_modules,
    _parse_file,
    analyze,
)

# 통합 테스트에 사용할 실제 대시보드 디렉토리
DASHBOARD_ROOT = Path("/home/jay/workspace/dashboard")


# ---------------------------------------------------------------------------
# 1. test_parse_file_valid
# ---------------------------------------------------------------------------


class TestParseFileValid:
    """유효한 Python 파일 파싱 테스트"""

    def test_parse_valid_python_file(self):
        """유효한 Python 파일을 파싱하면 None이 아닌 AST를 반환한다."""
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            f.write("x = 1\nprint(x)\n")
            tmp_path = Path(f.name)

        try:
            result = _parse_file(tmp_path)
            assert result is not None
            assert isinstance(result, ast.Module)
        finally:
            tmp_path.unlink(missing_ok=True)

    def test_parse_valid_file_with_imports(self):
        """임포트가 포함된 유효한 파일을 파싱하면 AST를 반환한다."""
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            f.write(
                "import os\nfrom pathlib import Path\n\ndef hello():\n    pass\n"
            )
            tmp_path = Path(f.name)

        try:
            result = _parse_file(tmp_path)
            assert result is not None
        finally:
            tmp_path.unlink(missing_ok=True)

    def test_parse_real_data_loader(self):
        """실제 data_loader.py 파일을 파싱한다. 100KB 초과 시 대형 파일 보호로 None을 반환한다."""
        data_loader_path = DASHBOARD_ROOT / "data_loader.py"
        assert data_loader_path.exists(), "data_loader.py 파일이 없습니다"

        result = _parse_file(data_loader_path)
        file_size = data_loader_path.stat().st_size
        if file_size > 100_000:
            # 대형 파일 보호: 100KB 초과 시 None 반환이 올바른 동작
            assert result is None
        else:
            assert result is not None
            assert isinstance(result, ast.Module)


# ---------------------------------------------------------------------------
# 2. test_parse_file_invalid
# ---------------------------------------------------------------------------


class TestParseFileInvalid:
    """구문 오류가 있는 파일 파싱 테스트"""

    def test_parse_syntax_error_returns_none(self):
        """구문 오류가 있는 파일을 파싱하면 None을 반환한다."""
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            f.write("def broken(\n    # 괄호가 닫히지 않음\nx = 1\n")
            tmp_path = Path(f.name)

        try:
            result = _parse_file(tmp_path)
            assert result is None
        finally:
            tmp_path.unlink(missing_ok=True)

    def test_parse_nonexistent_file_returns_none(self):
        """존재하지 않는 파일을 파싱하면 None을 반환한다."""
        result = _parse_file(Path("/nonexistent/path/does_not_exist.py"))
        assert result is None

    def test_parse_incomplete_expression_returns_none(self):
        """불완전한 표현식 파일을 파싱하면 None을 반환한다."""
        with tempfile.NamedTemporaryFile(
            mode="w", suffix=".py", delete=False
        ) as f:
            f.write("class Foo:\n  def bar(self\n")
            tmp_path = Path(f.name)

        try:
            result = _parse_file(tmp_path)
            assert result is None
        finally:
            tmp_path.unlink(missing_ok=True)


# ---------------------------------------------------------------------------
# 3. test_extract_imported_modules
# ---------------------------------------------------------------------------


class TestExtractImportedModules:
    """임포트 모듈 추출 테스트"""

    def _parse_code(self, code: str) -> ast.Module:
        return ast.parse(code)

    def test_import_x(self):
        """import X 패턴을 처리한다."""
        tree = self._parse_code("import os\nimport sys\n")
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "os" in modules
        assert "sys" in modules

    def test_from_x_import_y(self):
        """from X import Y 패턴을 처리한다."""
        tree = self._parse_code("from pathlib import Path\nfrom typing import List\n")
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "pathlib" in modules
        assert "typing" in modules

    def test_from_dashboard_x_import_y(self):
        """from dashboard.X import Y 패턴에서 X를 추출한다."""
        tree = self._parse_code(
            "from dashboard.data_loader import DataLoader\n"
            "from dashboard.server_utils import helper\n"
        )
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "data_loader" in modules
        assert "server_utils" in modules

    def test_relative_import_from_dot_x(self):
        """from .X import Y 패턴에서 X를 추출한다."""
        tree = self._parse_code("from .data_loader import DataLoader\n")
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "data_loader" in modules

    def test_import_dashboard_x(self):
        """import dashboard.X 패턴에서 X를 추출한다."""
        tree = self._parse_code("import dashboard.data_loader\n")
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "data_loader" in modules

    def test_no_imports_returns_empty_set(self):
        """임포트가 없는 파일은 빈 집합을 반환한다."""
        tree = self._parse_code("x = 1\ny = 2\n")
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert modules == set()

    def test_try_except_import_pattern(self):
        """try/except 블록 내의 임포트 패턴을 처리한다."""
        code = (
            "try:\n"
            "    from dashboard.data_loader import DataLoader\n"
            "except ImportError:\n"
            "    from data_loader import DataLoader\n"
        )
        tree = self._parse_code(code)
        modules = _extract_imported_modules(tree, pkg_name="dashboard")
        assert "data_loader" in modules


# ---------------------------------------------------------------------------
# 4. test_dependency_graph_build
# ---------------------------------------------------------------------------


class TestDependencyGraphBuild:
    """의존성 그래프 구축 테스트"""

    def test_data_loader_in_module_to_file(self):
        """DependencyGraph 빌드 후 data_loader.py가 module_to_file에 포함된다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        assert "data_loader" in graph.module_to_file

    def test_server_in_module_to_file(self):
        """DependencyGraph 빌드 후 server.py가 module_to_file에 포함된다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        assert "server" in graph.module_to_file

    def test_module_to_file_maps_to_path(self):
        """module_to_file의 값이 실제 존재하는 Path 객체이다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        data_loader_path = graph.module_to_file["data_loader"]
        assert isinstance(data_loader_path, Path)
        assert data_loader_path.exists()

    def test_graph_has_file_imports(self):
        """file_imports 딕셔너리가 비어 있지 않다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        assert len(graph.file_imports) > 0

    def test_graph_pkg_name(self):
        """그래프의 pkg_name이 루트 디렉토리 이름과 일치한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        assert graph.pkg_name == "dashboard"

    def test_get_file_path_with_extension(self):
        """get_file_path()가 .py 확장자가 있는 파일명도 처리한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        path = graph.get_file_path("data_loader.py")
        assert path is not None
        assert path.exists()

    def test_get_file_path_without_extension(self):
        """get_file_path()가 확장자 없는 모듈명도 처리한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        path = graph.get_file_path("data_loader")
        assert path is not None
        assert path.exists()


# ---------------------------------------------------------------------------
# 5. test_get_direct_importers
# ---------------------------------------------------------------------------


class TestGetDirectImporters:
    """직접 임포터 조회 테스트"""

    def test_data_loader_has_server_as_importer(self):
        """data_loader를 직접 임포트하는 파일 중 server.py가 포함된다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        importers = graph.get_direct_importers("data_loader")

        importer_names = {p.name for p in importers}
        assert "server.py" in importer_names, (
            f"server.py가 data_loader의 직접 임포터 목록에 없습니다. "
            f"현재 임포터: {importer_names}"
        )

    def test_data_loader_importers_are_paths(self):
        """get_direct_importers()가 Path 객체의 집합을 반환한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        importers = graph.get_direct_importers("data_loader")
        assert isinstance(importers, set)
        for imp in importers:
            assert isinstance(imp, Path)

    def test_nonexistent_module_returns_empty_set(self):
        """존재하지 않는 모듈에 대해 빈 집합을 반환한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        importers = graph.get_direct_importers("nonexistent_module_xyz")
        assert importers == set()

    def test_get_transitive_dependents_returns_set(self):
        """get_transitive_dependents()가 집합을 반환한다."""
        graph = DependencyGraph(DASHBOARD_ROOT)
        dependents = graph.get_transitive_dependents("data_loader", hops=2)
        assert isinstance(dependents, set)


# ---------------------------------------------------------------------------
# 6. test_analyze_basic
# ---------------------------------------------------------------------------


class TestAnalyzeBasic:
    """analyze() 기본 출력 구조 테스트"""

    def test_analyze_returns_list(self):
        """analyze()는 리스트를 반환한다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert isinstance(results, list)

    def test_analyze_single_file_returns_one_result(self):
        """단일 파일 분석은 결과 항목 하나를 반환한다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert len(results) == 1

    def test_analyze_result_has_changed_file(self):
        """결과에 changed_file 키가 있다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert "changed_file" in results[0]
        assert results[0]["changed_file"] == "data_loader.py"

    def test_analyze_result_has_blast_radius(self):
        """결과에 blast_radius 키가 있다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert "blast_radius" in results[0]

    def test_blast_radius_has_required_keys(self):
        """blast_radius에 필수 키들이 모두 있다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        blast = results[0]["blast_radius"]
        required_keys = {
            "direct_importers",
            "callers",
            "transitive_dependents",
            "test_files",
            "total_affected",
        }
        for key in required_keys:
            assert key in blast, f"blast_radius에 '{key}' 키가 없습니다"

    def test_blast_radius_direct_importers_is_list(self):
        """direct_importers가 리스트 타입이다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert isinstance(results[0]["blast_radius"]["direct_importers"], list)

    def test_blast_radius_total_affected_is_int(self):
        """total_affected가 정수 타입이다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert isinstance(results[0]["blast_radius"]["total_affected"], int)

    def test_blast_radius_server_in_direct_importers(self):
        """data_loader.py의 direct_importers에 server.py가 포함된다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        direct = results[0]["blast_radius"]["direct_importers"]
        assert any("server" in imp for imp in direct), (
            f"server.py가 direct_importers에 없습니다. 현재 값: {direct}"
        )

    def test_analyze_result_has_analysis_time_ms(self):
        """결과에 analysis_time_ms 키가 있다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert "analysis_time_ms" in results[0]
        assert isinstance(results[0]["analysis_time_ms"], (int, float))

    def test_analyze_multiple_files(self):
        """여러 파일 분석은 각 파일에 대한 결과를 반환한다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py", "server.py"])
        assert len(results) == 2
        changed_files = [r["changed_file"] for r in results]
        assert "data_loader.py" in changed_files
        assert "server.py" in changed_files


# ---------------------------------------------------------------------------
# 7. test_analyze_with_function
# ---------------------------------------------------------------------------


class TestAnalyzeWithFunction:
    """analyze() 함수 레벨 분석 테스트"""

    def test_analyze_with_function_has_changed_function_key(self):
        """함수 지정 시 결과에 changed_function 키가 있다."""
        results = analyze(
            DASHBOARD_ROOT,
            ["data_loader.py"],
            function_name="get_member_status",
        )
        assert "changed_function" in results[0]
        assert results[0]["changed_function"] == "get_member_status"

    def test_analyze_without_function_no_changed_function_key(self):
        """함수 미지정 시 결과에 changed_function 키가 없다."""
        results = analyze(DASHBOARD_ROOT, ["data_loader.py"])
        assert "changed_function" not in results[0]

    def test_analyze_with_function_callers_is_list(self):
        """함수 지정 시 callers가 리스트 타입이다."""
        results = analyze(
            DASHBOARD_ROOT,
            ["data_loader.py"],
            function_name="get_member_status",
        )
        assert isinstance(results[0]["blast_radius"]["callers"], list)

    def test_analyze_with_function_callers_found(self):
        """get_member_status 함수 분석 시 호출자가 하나 이상 발견된다."""
        results = analyze(
            DASHBOARD_ROOT,
            ["data_loader.py"],
            function_name="get_member_status",
        )
        callers = results[0]["blast_radius"]["callers"]
        assert len(callers) > 0, (
            "get_member_status 함수의 호출자를 찾지 못했습니다. "
            f"blast_radius: {results[0]['blast_radius']}"
        )

    def test_analyze_with_function_callers_format(self):
        """callers 목록의 각 항목이 'path:lineno' 형식이다."""
        results = analyze(
            DASHBOARD_ROOT,
            ["data_loader.py"],
            function_name="get_member_status",
        )
        callers = results[0]["blast_radius"]["callers"]
        for caller in callers:
            assert ":" in caller, f"호출자 형식이 잘못되었습니다: {caller}"
            parts = caller.rsplit(":", 1)
            assert len(parts) == 2
            assert parts[1].isdigit(), f"줄번호가 숫자가 아닙니다: {parts[1]}"

    def test_analyze_with_nonexistent_function_callers_empty(self):
        """존재하지 않는 함수 분석 시 callers가 빈 리스트이다."""
        results = analyze(
            DASHBOARD_ROOT,
            ["data_loader.py"],
            function_name="this_function_does_not_exist_xyz_abc",
        )
        callers = results[0]["blast_radius"]["callers"]
        assert callers == []


# ---------------------------------------------------------------------------
# 8. test_is_test_file
# ---------------------------------------------------------------------------


class TestIsTestFile:
    """테스트 파일 감지 테스트"""

    def setup_method(self):
        self.graph = DependencyGraph(DASHBOARD_ROOT)

    def test_test_prefix_file_is_test(self):
        """test_로 시작하는 .py 파일은 테스트 파일로 감지된다."""
        test_path = DASHBOARD_ROOT / "test_server.py"
        assert self.graph.is_test_file(test_path) is True

    def test_regular_file_is_not_test(self):
        """일반 .py 파일은 테스트 파일이 아니다."""
        regular_path = DASHBOARD_ROOT / "server.py"
        assert self.graph.is_test_file(regular_path) is False

    def test_data_loader_is_not_test(self):
        """data_loader.py는 테스트 파일이 아니다."""
        data_loader_path = DASHBOARD_ROOT / "data_loader.py"
        assert self.graph.is_test_file(data_loader_path) is False

    def test_file_in_tests_dir_is_test(self):
        """tests/ 디렉토리 내의 파일은 테스트 파일로 감지된다."""
        tests_path = DASHBOARD_ROOT / "tests" / "some_test.py"
        assert self.graph.is_test_file(tests_path) is True

    def test_test_blog_image_classify_is_test(self):
        """test_blog_image_classify.py가 테스트 파일로 감지된다."""
        test_path = DASHBOARD_ROOT / "test_blog_image_classify.py"
        assert self.graph.is_test_file(test_path) is True

    def test_arbitrary_test_prefix_file(self):
        """임의의 test_ 접두사 파일이 테스트 파일로 감지된다."""
        arbitrary_test = Path("/some/dir/test_anything.py")
        # root 기준 상대경로가 없어도 파일명으로 판단
        assert self.graph.is_test_file(arbitrary_test) is True

    def test_non_py_test_file_not_detected(self):
        """test_로 시작하지만 .py가 아닌 파일은 테스트 파일이 아니다."""
        non_py = DASHBOARD_ROOT / "test_session_watchdog.sh"
        # .sh 파일은 is_test_file 조건에 맞지 않음
        assert self.graph.is_test_file(non_py) is False
