"""Tests for static analysis utilities."""

from __future__ import annotations

import ast
import textwrap
from typing import TYPE_CHECKING

import attrs
import pytest

from codeflash_python._model import FunctionParent
from codeflash_python.analysis._static_analysis import (
    FunctionKind,
    ImportedInternalModuleAnalysis,
    analyze_imported_modules,
    function_kind,
    get_first_top_level_function_or_method_ast,
    get_first_top_level_object_def_ast,
    get_module_file_path,
    get_module_full_name,
    has_typed_parameters,
    is_internal_module,
    parse_imports,
    resolve_relative_name,
)

if TYPE_CHECKING:
    from pathlib import Path


class TestImportedInternalModuleAnalysis:
    """ImportedInternalModuleAnalysis attrs class."""

    def test_valid_construction(self, tmp_path: Path) -> None:
        """Accepts a valid identifier, dotted name, and existing path."""
        f = tmp_path / "mod.py"
        f.write_text("")
        obj = ImportedInternalModuleAnalysis(
            name="mod",
            full_name="pkg.mod",
            file_path=f,
        )
        assert "mod" == obj.name
        assert "pkg.mod" == obj.full_name
        assert f == obj.file_path

    def test_name_rejects_non_identifier(self, tmp_path: Path) -> None:
        """Raises when name is not a valid Python identifier."""
        f = tmp_path / "mod.py"
        f.write_text("")
        with pytest.raises((ValueError, TypeError)):
            ImportedInternalModuleAnalysis(
                name="not-an-identifier",
                full_name="pkg.mod",
                file_path=f,
            )

    def test_full_name_rejects_invalid_dotted_name(
        self,
        tmp_path: Path,
    ) -> None:
        """Raises when full_name contains non-identifier segments."""
        f = tmp_path / "mod.py"
        f.write_text("")
        with pytest.raises((ValueError, TypeError)):
            ImportedInternalModuleAnalysis(
                name="mod",
                full_name="pkg..mod",
                file_path=f,
            )

    def test_full_name_rejects_empty_string(self, tmp_path: Path) -> None:
        """Raises when full_name is empty."""
        f = tmp_path / "mod.py"
        f.write_text("")
        with pytest.raises((ValueError, TypeError)):
            ImportedInternalModuleAnalysis(
                name="mod",
                full_name="",
                file_path=f,
            )

    def test_file_path_rejects_nonexistent(self, tmp_path: Path) -> None:
        """Raises when file_path does not exist on disk."""
        missing = tmp_path / "nonexistent.py"
        with pytest.raises((ValueError, TypeError)):
            ImportedInternalModuleAnalysis(
                name="mod",
                full_name="pkg.mod",
                file_path=missing,
            )

    def test_frozen(self, tmp_path: Path) -> None:
        """Instances are immutable."""
        f = tmp_path / "mod.py"
        f.write_text("")
        obj = ImportedInternalModuleAnalysis(
            name="mod",
            full_name="pkg.mod",
            file_path=f,
        )
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            obj.name = "other"  # type: ignore[misc]


class TestFunctionKindEnum:
    """FunctionKind enum."""

    def test_enum_values(self) -> None:
        """Enum members have expected integer values."""
        assert 0 == FunctionKind.FUNCTION.value
        assert 1 == FunctionKind.STATIC_METHOD.value
        assert 2 == FunctionKind.CLASS_METHOD.value
        assert 3 == FunctionKind.INSTANCE_METHOD.value

    def test_all_members(self) -> None:
        """Enum contains exactly four members."""
        assert 4 == len(FunctionKind)


class TestParseImports:
    """parse_imports function."""

    def test_import_statement(self) -> None:
        """Parses a bare import statement."""
        result = parse_imports("import os")
        assert 1 == len(result)
        assert isinstance(result[0], ast.Import)

    def test_from_import_statement(self) -> None:
        """Parses a from-import statement."""
        result = parse_imports("from pathlib import Path")
        assert 1 == len(result)
        assert isinstance(result[0], ast.ImportFrom)

    def test_no_imports(self) -> None:
        """Returns empty list when code has no imports."""
        result = parse_imports("x = 1\ny = 2\n")
        assert [] == result

    def test_multiple_imports(self) -> None:
        """Parses multiple import statements."""
        code = textwrap.dedent("""\
            import os
            import sys
            from pathlib import Path
        """)
        result = parse_imports(code)
        assert 3 == len(result)

    def test_import_inside_function(self) -> None:
        """Finds imports nested inside function bodies."""
        code = textwrap.dedent("""\
            def f():
                import json
        """)
        result = parse_imports(code)
        assert 1 == len(result)
        assert isinstance(result[0], ast.Import)


class TestResolveRelativeName:
    """resolve_relative_name function."""

    def test_level_zero_returns_module(self) -> None:
        """Level 0 (absolute) returns the module name unchanged."""
        assert "os.path" == resolve_relative_name("os.path", 0, "pkg.sub")

    def test_level_one_relative(self) -> None:
        """Level 1 resolves relative to the parent package."""
        result = resolve_relative_name("sibling", 1, "pkg.sub.mod")
        assert "pkg.sub.sibling" == result

    def test_level_two_relative(self) -> None:
        """Level 2 goes up two levels."""
        result = resolve_relative_name("other", 2, "pkg.sub.mod")
        assert "pkg.other" == result

    def test_level_exceeding_depth_returns_none(self) -> None:
        """Returns None when level exceeds module depth."""
        result = resolve_relative_name("x", 5, "pkg.mod")
        assert result is None

    def test_module_none_package_import(self) -> None:
        """Handles package-level relative import (module=None)."""
        result = resolve_relative_name(None, 1, "pkg.sub.mod")
        assert "pkg.sub" == result

    def test_level_zero_with_none_module(self) -> None:
        """Level 0 with module=None returns None."""
        assert resolve_relative_name(None, 0, "pkg.mod") is None


class TestGetModuleFullName:
    """get_module_full_name function."""

    def test_with_import_node(self) -> None:
        """Returns module names from an ast.Import node."""
        tree = ast.parse("import os, sys")
        node = tree.body[0]
        result = get_module_full_name(node, "pkg.mod")
        assert ["os", "sys"] == result

    def test_with_import_from_absolute(self) -> None:
        """Returns base module from an absolute ImportFrom."""
        tree = ast.parse("from pathlib import Path")
        node = tree.body[0]
        result = get_module_full_name(node, "pkg.mod")
        assert ["pathlib"] == result

    def test_with_import_from_relative(self) -> None:
        """Resolves relative ImportFrom using current_module."""
        tree = ast.parse("from . import helper")
        node = tree.body[0]
        result = get_module_full_name(node, "pkg.sub.mod")
        assert ["pkg.sub.helper"] == result

    def test_with_import_from_relative_module(self) -> None:
        """Resolves relative ImportFrom with a module specified."""
        tree = ast.parse("from .utils import func")
        node = tree.body[0]
        result = get_module_full_name(node, "pkg.sub.mod")
        assert ["pkg.sub.utils"] == result


class TestIsInternalModule:
    """is_internal_module function."""

    def test_existing_module_file(self, tmp_path: Path) -> None:
        """Returns True when a .py file exists for the module."""
        (tmp_path / "mymod.py").write_text("")
        assert is_internal_module("mymod", tmp_path) is True

    def test_existing_package(self, tmp_path: Path) -> None:
        """Returns True when module is a package with __init__.py."""
        pkg = tmp_path / "mypkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text("")
        assert is_internal_module("mypkg", tmp_path) is True

    def test_nonexistent_module(self, tmp_path: Path) -> None:
        """Returns False for a module that does not exist."""
        assert is_internal_module("nonexistent", tmp_path) is False

    def test_dotted_module(self, tmp_path: Path) -> None:
        """Handles dotted module names by converting to path."""
        sub = tmp_path / "pkg" / "sub"
        sub.mkdir(parents=True)
        (sub / "mod.py").write_text("")
        assert is_internal_module("pkg.sub.mod", tmp_path) is True


class TestGetModuleFilePath:
    """get_module_file_path function."""

    def test_returns_path_for_existing_file(self, tmp_path: Path) -> None:
        """Returns the resolved path for an existing module file."""
        f = tmp_path / "mymod.py"
        f.write_text("")
        result = get_module_file_path("mymod", tmp_path)
        assert f.resolve() == result

    def test_returns_path_for_package_init(self, tmp_path: Path) -> None:
        """Returns the __init__.py path for a package."""
        pkg = tmp_path / "mypkg"
        pkg.mkdir()
        init = pkg / "__init__.py"
        init.write_text("")
        result = get_module_file_path("mypkg", tmp_path)
        assert init.resolve() == result

    def test_returns_none_for_missing(self, tmp_path: Path) -> None:
        """Returns None when no file matches the module name."""
        result = get_module_file_path("nonexistent", tmp_path)
        assert result is None

    def test_prefers_py_file_over_package(self, tmp_path: Path) -> None:
        """When both mod.py and mod/__init__.py exist, returns mod.py."""
        f = tmp_path / "mymod.py"
        f.write_text("")
        pkg = tmp_path / "mymod"
        pkg.mkdir()
        (pkg / "__init__.py").write_text("")
        result = get_module_file_path("mymod", tmp_path)
        assert f.resolve() == result


class TestAnalyzeImportedModules:
    """analyze_imported_modules function."""

    def test_finds_internal_imports(self, tmp_path: Path) -> None:
        """Discovers internal modules from import statements."""
        pkg = tmp_path / "mypkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text("")
        helper = pkg / "helper.py"
        helper.write_text("")
        main = pkg / "main.py"
        main.write_text(
            textwrap.dedent("""\
                from mypkg import helper
            """),
        )

        result = analyze_imported_modules(
            main.read_text(),
            main,
            tmp_path,
        )
        assert 1 == len(result)
        assert "mypkg" == result[0].name
        assert "mypkg" == result[0].full_name

    def test_ignores_external_imports(self, tmp_path: Path) -> None:
        """Returns empty list when all imports are external."""
        pkg = tmp_path / "mypkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text("")
        main = pkg / "main.py"
        main.write_text(
            textwrap.dedent("""\
                import os
                import sys
                from pathlib import Path
            """),
        )

        result = analyze_imported_modules(
            main.read_text(),
            main,
            tmp_path,
        )
        assert [] == result

    def test_relative_import_resolution(self, tmp_path: Path) -> None:
        """Resolves relative imports to internal modules."""
        pkg = tmp_path / "mypkg"
        pkg.mkdir()
        (pkg / "__init__.py").write_text("")
        utils = pkg / "utils.py"
        utils.write_text("")
        main = pkg / "main.py"
        main.write_text(
            textwrap.dedent("""\
                from . import utils
            """),
        )

        result = analyze_imported_modules(
            main.read_text(),
            main,
            tmp_path,
        )
        names = {r.name for r in result}
        assert "utils" in names


class TestGetFirstTopLevelObjectDefAst:
    """get_first_top_level_object_def_ast function."""

    def test_finds_function(self) -> None:
        """Finds a top-level function definition by name."""
        code = textwrap.dedent("""\
            def target():
                return 1

            def other():
                return 2
        """)
        tree = ast.parse(code)
        result = get_first_top_level_object_def_ast(
            "target",
            ast.FunctionDef,
            tree,
        )
        assert result is not None
        assert "target" == result.name

    def test_finds_class(self) -> None:
        """Finds a top-level class definition by name."""
        code = textwrap.dedent("""\
            class MyClass:
                pass
        """)
        tree = ast.parse(code)
        result = get_first_top_level_object_def_ast(
            "MyClass",
            ast.ClassDef,
            tree,
        )
        assert result is not None
        assert "MyClass" == result.name

    def test_returns_none_for_missing(self) -> None:
        """Returns None when the named object is not found."""
        tree = ast.parse("x = 1\n")
        result = get_first_top_level_object_def_ast(
            "missing",
            ast.FunctionDef,
            tree,
        )
        assert result is None

    def test_does_not_descend_into_functions(self) -> None:
        """Does not find functions nested inside other functions."""
        code = textwrap.dedent("""\
            def outer():
                def inner():
                    pass
        """)
        tree = ast.parse(code)
        result = get_first_top_level_object_def_ast(
            "inner",
            ast.FunctionDef,
            tree,
        )
        assert result is None

    def test_finds_async_function(self) -> None:
        """Finds an async function definition."""
        code = textwrap.dedent("""\
            async def atarget():
                return 1
        """)
        tree = ast.parse(code)
        result = get_first_top_level_object_def_ast(
            "atarget",
            ast.AsyncFunctionDef,
            tree,
        )
        assert result is not None
        assert "atarget" == result.name


class TestGetFirstTopLevelFunctionOrMethodAst:
    """get_first_top_level_function_or_method_ast function."""

    def test_finds_top_level_function(self) -> None:
        """Finds a function at module level with no parents."""
        code = textwrap.dedent("""\
            def target():
                return 1
        """)
        tree = ast.parse(code)
        result = get_first_top_level_function_or_method_ast(
            "target",
            [],
            tree,
        )
        assert result is not None
        assert "target" == result.name

    def test_finds_class_method(self) -> None:
        """Finds a method inside a class via parent list."""
        code = textwrap.dedent("""\
            class MyClass:
                def method(self):
                    return 1
        """)
        tree = ast.parse(code)
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = get_first_top_level_function_or_method_ast(
            "method",
            parents,
            tree,
        )
        assert result is not None
        assert "method" == result.name

    def test_returns_none_for_missing_method(self) -> None:
        """Returns None when the method is not in the class."""
        code = textwrap.dedent("""\
            class MyClass:
                def other(self):
                    pass
        """)
        tree = ast.parse(code)
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = get_first_top_level_function_or_method_ast(
            "missing",
            parents,
            tree,
        )
        assert result is None

    def test_finds_async_top_level_function(self) -> None:
        """Finds async functions at module level."""
        code = textwrap.dedent("""\
            async def atarget():
                return 1
        """)
        tree = ast.parse(code)
        result = get_first_top_level_function_or_method_ast(
            "atarget",
            [],
            tree,
        )
        assert result is not None
        assert "atarget" == result.name

    def test_finds_async_class_method(self) -> None:
        """Finds an async method inside a class."""
        code = textwrap.dedent("""\
            class MyClass:
                async def amethod(self):
                    return 1
        """)
        tree = ast.parse(code)
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = get_first_top_level_function_or_method_ast(
            "amethod",
            parents,
            tree,
        )
        assert result is not None
        assert "amethod" == result.name


class TestFunctionKindClassification:
    """function_kind classification."""

    def test_bare_function(self) -> None:
        """A top-level function returns FUNCTION."""
        code = "def f(): pass\n"
        node = ast.parse(code).body[0]
        result = function_kind(node, [])
        assert FunctionKind.FUNCTION == result

    def test_function_nested_in_function(self) -> None:
        """A function inside another function returns FUNCTION."""
        code = "def f(): pass\n"
        node = ast.parse(code).body[0]
        parents = [
            FunctionParent(name="outer", type="FunctionDef"),
        ]
        result = function_kind(node, parents)
        assert FunctionKind.FUNCTION == result

    def test_classmethod(self) -> None:
        """A @classmethod returns CLASS_METHOD."""
        code = textwrap.dedent("""\
            @classmethod
            def f(cls): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = function_kind(node, parents)
        assert FunctionKind.CLASS_METHOD == result

    def test_staticmethod(self) -> None:
        """A @staticmethod returns STATIC_METHOD."""
        code = textwrap.dedent("""\
            @staticmethod
            def f(): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = function_kind(node, parents)
        assert FunctionKind.STATIC_METHOD == result

    def test_instance_method(self) -> None:
        """An undecorated method in a class returns INSTANCE_METHOD."""
        code = "def f(self): pass\n"
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        result = function_kind(node, parents)
        assert FunctionKind.INSTANCE_METHOD == result

    def test_unknown_parent_type_returns_none(self) -> None:
        """Returns None when parent type is unrecognized."""
        code = "def f(): pass\n"
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="x", type="Unknown")]
        result = function_kind(node, parents)
        assert result is None


class TestHasTypedParameters:
    """has_typed_parameters function."""

    def test_all_typed_function(self) -> None:
        """Returns True when all parameters are annotated."""
        code = "def f(a: int, b: str): pass\n"
        node = ast.parse(code).body[0]
        assert has_typed_parameters(node, []) is True

    def test_untyped_function(self) -> None:
        """Returns False when any parameter lacks annotation."""
        code = "def f(a: int, b): pass\n"
        node = ast.parse(code).body[0]
        assert has_typed_parameters(node, []) is False

    def test_no_parameters(self) -> None:
        """Returns True for a function with no parameters."""
        code = "def f(): pass\n"
        node = ast.parse(code).body[0]
        assert has_typed_parameters(node, []) is True

    def test_instance_method_skips_self(self) -> None:
        """Skips self when checking an instance method."""
        code = "def f(self, a: int): pass\n"
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is True

    def test_instance_method_untyped_after_self(self) -> None:
        """Returns False when parameter after self is untyped."""
        code = "def f(self, a): pass\n"
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is False

    def test_classmethod_skips_cls(self) -> None:
        """Skips cls when checking a classmethod."""
        code = textwrap.dedent("""\
            @classmethod
            def f(cls, a: int): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is True

    def test_classmethod_untyped_after_cls(self) -> None:
        """Returns False when parameter after cls is untyped."""
        code = textwrap.dedent("""\
            @classmethod
            def f(cls, a): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is False

    def test_staticmethod_all_typed(self) -> None:
        """Returns True for staticmethod with all typed params."""
        code = textwrap.dedent("""\
            @staticmethod
            def f(a: int, b: str): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is True

    def test_staticmethod_untyped(self) -> None:
        """Returns False for staticmethod with untyped params."""
        code = textwrap.dedent("""\
            @staticmethod
            def f(a, b): pass
        """)
        node = ast.parse(code).body[0]
        parents = [FunctionParent(name="MyClass", type="ClassDef")]
        assert has_typed_parameters(node, parents) is False
