"""Tests for Go, Rust, Java, C, C++, C#, Ruby, PHP, Kotlin, Swift, Solidity, and Vue parsing."""

from pathlib import Path

import pytest

from code_review_graph.parser import CodeParser

FIXTURES = Path(__file__).parent / "fixtures"


class TestGoParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_go.go")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("main.go")) == "go"

    def test_finds_structs_and_interfaces(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names
        assert "InMemoryRepo" in names
        assert "UserRepository" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "NewInMemoryRepo" in names
        assert "CreateUser" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "errors" in targets
        assert "fmt" in targets

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 1

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        assert len(contains) >= 3

    def test_methods_attached_to_receiver(self):
        """Go methods should be attached to their receiver type (#190).

        `func (r *InMemoryRepo) FindByID(...)` should produce a Function node
        with parent_name='InMemoryRepo' and a CONTAINS edge from the type to
        the method, so `inheritors_of`/`query_graph` can find methods via the
        struct they belong to.
        """
        funcs = [n for n in self.nodes if n.kind == "Function"]
        by_name = {f.name: f for f in funcs}
        assert "FindByID" in by_name
        assert "Save" in by_name
        assert by_name["FindByID"].parent_name == "InMemoryRepo"
        assert by_name["Save"].parent_name == "InMemoryRepo"
        # Free functions should still have no parent.
        assert by_name["NewInMemoryRepo"].parent_name is None
        assert by_name["CreateUser"].parent_name is None

        contains = [(e.source, e.target) for e in self.edges if e.kind == "CONTAINS"]
        find_by_id_contains = [
            (s, t) for (s, t) in contains
            if t.endswith("::InMemoryRepo.FindByID")
        ]
        save_contains = [
            (s, t) for (s, t) in contains
            if t.endswith("::InMemoryRepo.Save")
        ]
        assert find_by_id_contains, (
            f"no CONTAINS edge for InMemoryRepo.FindByID in {contains}"
        )
        assert save_contains, (
            f"no CONTAINS edge for InMemoryRepo.Save in {contains}"
        )
        # Source of each CONTAINS should be the InMemoryRepo type,
        # not the file path.
        assert find_by_id_contains[0][0].endswith("::InMemoryRepo")
        assert save_contains[0][0].endswith("::InMemoryRepo")


class TestRustParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_rust.rs")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("lib.rs")) == "rust"

    def test_finds_structs_and_traits(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names
        assert "InMemoryRepo" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "new" in names
        assert "create_user" in names
        assert "find_by_id" in names
        assert "save" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) >= 1

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 3

    def test_detects_test_attribute(self):
        tests = [n for n in self.nodes if n.kind == "Test"]
        names = {t.name for t in tests}
        assert "new_repo_is_empty" in names
        assert "create_user_saves_to_repo" in names
        assert all(t.is_test for t in tests)

    def test_detects_tokio_test_attribute(self):
        tests = {n.name for n in self.nodes if n.kind == "Test"}
        assert "async_test_is_detected" in tests

    def test_non_test_functions_not_misclassified(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert "create_user" in funcs
        assert "new" in funcs
        # `create_user` carries no `#[test]` — must stay Function.
        for n in self.nodes:
            if n.name == "create_user":
                assert not n.is_test


class TestJavaParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "SampleJava.java")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("Main.java")) == "java"

    def test_finds_classes_and_interfaces(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "UserRepository" in names
        assert "User" in names
        assert "InMemoryRepo" in names
        assert "UserService" in names

    def test_finds_methods(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "findById" in names
        assert "save" in names
        assert "getUser" in names

    def test_method_names_not_return_types(self):
        """Method names must be the actual name, not the return type.

        tree-sitter-java puts type_identifier (return type) before
        identifier (method name).  Without the Java-specific branch in
        _get_name the generic loop picks up the return type instead.
        """
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        # getName()/getEmail() return String — must not be indexed as "String"
        assert "getName" in names
        assert "getEmail" in names
        assert "getId" in names
        # createUser() returns User — must not be indexed as "User" (the class)
        assert "createUser" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) >= 2

    def test_finds_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        # InMemoryRepo implements UserRepository + CachedRepo extends InMemoryRepo
        assert len(inherits) >= 2
        targets = {e.target for e in inherits}
        assert "UserRepository" in targets
        assert "InMemoryRepo" in targets

    def test_inheritance_target_is_bare_name(self):
        """INHERITS edge target must be the type name, not 'implements Foo'.

        tree-sitter-java wraps extends/implements in superclass and
        super_interfaces nodes whose .text includes the keyword.
        Without the Java-specific branch in _get_bases the full text
        (e.g. 'implements UserRepository') is stored as the edge target.
        """
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        # Must have both extends and implements edges to test both paths
        assert len(inherits) >= 2, (
            "Expected at least 2 INHERITS edges (extends + implements)"
        )
        for e in inherits:
            assert not e.target.startswith("implements "), (
                f"INHERITS target should be bare type name, got: {e.target!r}"
            )
            assert not e.target.startswith("extends "), (
                f"INHERITS target should be bare type name, got: {e.target!r}"
            )

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 3


class TestJavaImportResolution:
    """Test that Java imports are resolved to absolute file paths."""

    def test_resolves_project_import(self, tmp_path):
        """Import of a project class resolves to its .java file."""
        # Create a mini Java project with two packages
        auth = tmp_path / "src/main/java/com/example/auth"
        auth.mkdir(parents=True)
        (auth / "User.java").write_text(
            "package com.example.auth;\npublic class User {}\n"
        )
        svc = tmp_path / "src/main/java/com/example/service"
        svc.mkdir(parents=True)
        (svc / "App.java").write_text(
            "package com.example.service;\n"
            "import com.example.auth.User;\n"
            "public class App {}\n"
        )

        parser = CodeParser()
        _, edges = parser.parse_file(svc / "App.java")
        imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) == 1
        assert imports[0].target == str((auth / "User.java").resolve())

    def test_jdk_import_stays_unresolved(self):
        """JDK imports have no local file and remain as raw strings."""
        parser = CodeParser()
        _, edges = parser.parse_file(FIXTURES / "SampleJava.java")
        imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
        # All imports in SampleJava.java are java.util.* (JDK)
        for e in imports:
            assert not e.target.endswith(".java"), (
                f"JDK import should not resolve to a file: {e.target!r}"
            )

    def test_static_import_resolves_to_class(self, tmp_path):
        """Static import of a member resolves to the enclosing class file."""
        pkg = tmp_path / "src/main/java/com/example/util"
        pkg.mkdir(parents=True)
        (pkg / "Helper.java").write_text(
            "package com.example.util;\n"
            "public class Helper { public static int MAX = 1; }\n"
        )
        app_dir = tmp_path / "src/main/java/com/example/app"
        app_dir.mkdir(parents=True)
        (app_dir / "App.java").write_text(
            "package com.example.app;\n"
            "import static com.example.util.Helper.MAX;\n"
            "public class App {}\n"
        )

        parser = CodeParser()
        _, edges = parser.parse_file(app_dir / "App.java")
        imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) == 1
        assert imports[0].target == str((pkg / "Helper.java").resolve())

    def test_wildcard_import_stays_unresolved(self, tmp_path):
        """Wildcard imports cannot resolve to a single file."""
        app_dir = tmp_path / "src/main/java/com/example"
        app_dir.mkdir(parents=True)
        (app_dir / "App.java").write_text(
            "package com.example;\n"
            "import java.util.*;\n"
            "public class App {}\n"
        )

        parser = CodeParser()
        _, edges = parser.parse_file(app_dir / "App.java")
        imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) == 1
        assert imports[0].target == "java.util.*"


class TestCParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.c")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("main.c")) == "c"

    def test_finds_structs(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "print_user" in names
        assert "main" in names
        assert "create_user" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "stdio.h" in targets


class TestCppParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.cpp")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("main.cpp")) == "cpp"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Animal" in names
        assert "Dog" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "greet" in names or "main" in names

    def test_finds_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        assert len(inherits) >= 1


class TestHhParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.hh")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("types.hh")) == "cpp"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Shape" in names
        assert "Circle" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "perimeter" in names

    def test_finds_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        assert len(inherits) >= 1


def _has_csharp_parser():
    try:
        import tree_sitter_language_pack as tslp
        tslp.get_parser("csharp")
        return True
    except (LookupError, ImportError):
        return False


@pytest.mark.skipif(not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed")
class TestCSharpParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "Sample.cs")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("Program.cs")) == "csharp"

    def test_finds_classes_and_interfaces(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names
        assert "InMemoryRepo" in names

    def test_finds_methods(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "FindById" in names or "Save" in names


class TestRubyParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.rb")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("app.rb")) == "ruby"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names or "UserRepository" in names

    def test_finds_methods(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "initialize" in names or "find_by_id" in names or "save" in names


class TestPHPParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.php")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("index.php")) == "php"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names or "InMemoryRepo" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert len(names) > 0

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        target_names = {t.split("::")[-1].split(".")[-1] for t in targets}

        run_queries_targets = {
            e.target for e in calls if e.source.endswith("::ExtendedRepo.runQueries")
        }

        # Plain function calls
        assert "sqlQuery" in target_names
        assert "xl" in target_names
        assert "text" in target_names

        # Member and nullsafe method calls
        assert "execute" in target_names
        assert "search" in target_names

        # Scoped/static calls
        assert "QueryUtils::fetchRecords" in targets
        assert "EncounterService::create" in targets
        assert any(t.endswith("__construct") for t in run_queries_targets)
        assert any(t.endswith("factory") for t in run_queries_targets)

        # Global namespaced calls should normalize to a stable name
        assert "dirname" in target_names


class TestKotlinParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.kt")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("Main.kt")) == "kotlin"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names or "InMemoryRepo" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "createUser" in names or "findById" in names or "save" in names

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {c.target for c in calls}
        # Simple call: println(...)
        assert "println" in targets
        # Method call: repo.save(user)
        assert any("save" in t for t in targets)


class TestSwiftParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.swift")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("App.swift")) == "swift"

    def test_finds_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names
        assert "InMemoryRepo" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "createUser" in names or "findById" in names or "save" in names

    def test_finds_enum(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Direction" in names

    def test_finds_actor(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "DataStore" in names

    def test_finds_extension(self):
        """Extensions should be detected and linked to the extended type."""
        classes = [n for n in self.nodes if n.kind == "Class"]
        # Extension of InMemoryRepo should produce a Class node named InMemoryRepo
        # with swift_kind == "extension"
        ext_nodes = [c for c in classes if c.extra.get("swift_kind") == "extension"]
        assert len(ext_nodes) >= 1
        assert ext_nodes[0].name == "InMemoryRepo"

    def test_finds_protocol(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "UserRepository" in names

    def test_swift_kind_extra(self):
        """Each Swift type should have the correct swift_kind in extra."""
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert classes["User"].extra.get("swift_kind") == "struct"
        assert classes["Direction"].extra.get("swift_kind") == "enum"
        assert classes["DataStore"].extra.get("swift_kind") == "actor"
        assert classes["UserRepository"].extra.get("swift_kind") == "protocol"
        # InMemoryRepo appears twice (class + extension); check at least one is "class"
        repo_nodes = [n for n in self.nodes if n.kind == "Class" and n.name == "InMemoryRepo"]
        kinds = {n.extra.get("swift_kind") for n in repo_nodes}
        assert "class" in kinds
        assert "extension" in kinds

    def test_inheritance_edges(self):
        """Swift inheritance / conformance should produce INHERITS edges."""
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        targets = {e.target for e in inherits}
        # InMemoryRepo: UserRepository
        assert "UserRepository" in targets
        # Direction: String
        assert "String" in targets
        # extension InMemoryRepo: CustomStringConvertible
        assert "CustomStringConvertible" in targets


class TestScalaParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.scala")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("Main.scala")) == "scala"

    def test_finds_classes_traits_objects(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Repository" in names
        assert "User" in names
        assert "InMemoryRepo" in names
        assert "UserService" in names
        assert "Color" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "findById" in names
        assert "save" in names
        assert "createUser" in names
        assert "getUser" in names
        assert "apply" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "scala.util.Try" in targets
        assert "scala.collection.mutable" in targets
        assert "scala.collection.mutable.HashMap" in targets
        assert "scala.collection.mutable.ListBuffer" in targets
        assert "scala.concurrent.*" in targets
        assert len(imports) >= 3

    def test_finds_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        targets = {e.target for e in inherits}
        assert "Repository" in targets
        assert "Serializable" in targets

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 3


class TestSolidityParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sol")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("Vault.sol")) == "solidity"

    def test_finds_contracts_interfaces_libraries(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "StakingVault" in names
        assert "BoostedPool" in names
        assert "IStakingPool" in names
        assert "RewardMath" in names

    def test_finds_structs(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "StakerPosition" in names

    def test_finds_enums(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "PoolStatus" in names

    def test_finds_custom_errors(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "InsufficientStake" in names
        assert "PoolNotActive" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "stake" in names
        assert "unstake" in names
        assert "stakedBalance" in names
        assert "pendingBonus" in names

    def test_finds_constructors(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        constructors = [f for f in funcs if f.name == "constructor"]
        assert len(constructors) == 2  # StakingVault + BoostedPool

    def test_finds_modifiers(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "nonZero" in names
        assert "whenPoolActive" in names

    def test_finds_events(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "Staked" in names
        assert "Unstaked" in names
        assert "BonusClaimed" in names

    def test_finds_file_level_events(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        # file-level events declared outside any contract
        assert "Staked" in names or "Unstaked" in names

    def test_finds_user_defined_value_types(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Price" in names
        assert "PositionId" in names

    def test_finds_file_level_constants(self):
        constants = [
            n for n in self.nodes
            if n.extra.get("solidity_kind") == "constant"
        ]
        names = {c.name for c in constants}
        assert "MAX_SUPPLY" in names
        assert "ZERO_ADDRESS" in names

    def test_finds_free_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        free = [f for f in funcs if f.name == "protocolFee"]
        assert len(free) == 1
        assert free[0].parent_name is None

    def test_finds_using_directive(self):
        depends = [e for e in self.edges if e.kind == "DEPENDS_ON"]
        targets = {e.target for e in depends}
        assert "RewardMath" in targets

    def test_finds_selective_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol" in targets

    def test_finds_state_variables(self):
        state_vars = [
            n for n in self.nodes
            if n.extra.get("solidity_kind") == "state_variable"
        ]
        names = {v.name for v in state_vars}
        assert "stakes" in names
        assert "totalStaked" in names
        assert "guardian" in names
        assert "status" in names
        assert "MIN_STAKE" in names
        assert "launchTime" in names
        assert "bonusRate" in names
        assert "assetPrice" in names

    def test_state_variable_types(self):
        state_vars = {
            n.name: n for n in self.nodes
            if n.extra.get("solidity_kind") == "state_variable"
        }
        assert state_vars["totalStaked"].return_type == "uint256"
        assert state_vars["guardian"].return_type == "address"
        assert state_vars["stakes"].modifiers == "public"

    def test_finds_receive_and_fallback(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "receive" in names
        assert "fallback" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "@openzeppelin/contracts/token/ERC20/ERC20.sol" in targets
        assert "@openzeppelin/contracts/access/Ownable.sol" in targets

    def test_finds_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        pairs = {(e.source.split("::")[-1], e.target) for e in inherits}
        assert ("StakingVault", "ERC20") in pairs
        assert ("StakingVault", "Ownable") in pairs
        assert ("StakingVault", "IStakingPool") in pairs
        assert ("BoostedPool", "StakingVault") in pairs

    def test_finds_function_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target.split("::")[-1] if "::" in e.target else e.target for e in calls}
        assert "require" in targets
        assert "_mint" in targets
        assert "_burn" in targets
        assert "pendingBonus" in targets or "BoostedPool.pendingBonus" in targets

    def test_finds_emit_edges(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        # Targets may be qualified (e.g. "file::BoostedPool.BonusClaimed")
        target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
        assert "Staked" in target_basenames
        assert "Unstaked" in target_basenames
        assert "BonusClaimed" in target_basenames

    def test_finds_modifier_invocations(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        # Extract (source_basename, target_basename) to handle qualified names
        target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
        assert "nonZero" in target_basenames
        assert "whenPoolActive" in target_basenames

    def test_finds_constructor_modifier_invocations(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
        assert "ERC20" in target_basenames
        assert "Ownable" in target_basenames
        assert "StakingVault" in target_basenames

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target.split("::")[-1] for e in contains}
        assert "StakingVault" in targets
        assert "StakingVault.stake" in targets
        assert "StakingVault.stakes" in targets
        assert "StakingVault.Staked" not in targets  # Staked is file-level
        assert "BoostedPool.claimBonus" in targets

    def test_extracts_params(self):
        funcs = {
            n.name: n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "RewardMath"
        }
        assert funcs["mulPrecise"].params == "(uint256 a, uint256 b)"

    def test_extracts_return_type(self):
        funcs = {
            n.name: n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "RewardMath"
        }
        assert "uint256" in funcs["mulPrecise"].return_type


class TestVueParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("App.vue")) == "vue"

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "increment" in names
        assert "onSelectUser" in names
        assert "fetchUsers" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "vue" in targets
        assert "./UserList.vue" in targets

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        assert len(contains) >= 3

    def test_nodes_have_vue_language(self):
        for node in self.nodes:
            assert node.language == "vue"

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 1


class TestRParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.R")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("script.r")) == "r"
        assert self.parser.detect_language(Path("script.R")) == "r"

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function" and n.parent_name is None]
        names = {f.name for f in funcs}
        assert "add" in names
        assert "multiply" in names
        assert "process_data" in names

    def test_finds_s4_classes(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "MyClass" in names

    def test_finds_class_methods(self):
        methods = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "MyClass"
        ]
        names = {m.name for m in methods}
        assert "greet" in names
        assert "get_age" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "dplyr" in targets
        assert "ggplot2" in targets
        assert "utils.R" in targets

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert "dplyr::filter" in targets
        assert "dplyr::summarize" in targets

    def test_finds_params(self):
        funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
        assert funcs["add"].params is not None
        assert "x" in funcs["add"].params
        assert "y" in funcs["add"].params

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target.split("::")[-1] for e in contains}
        assert "add" in targets
        assert "multiply" in targets
        assert "MyClass" in targets
        assert "MyClass.greet" in targets

    def test_detects_test_functions(self):
        parser = CodeParser()
        nodes, _edges = parser.parse_file(FIXTURES / "test_sample.R")
        file_node = [n for n in nodes if n.kind == "File"][0]
        assert file_node.is_test is True
        test_funcs = [n for n in nodes if n.is_test and n.kind == "Test"]
        names = {f.name for f in test_funcs}
        assert "test_add" in names


class TestPerlParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.pl")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("script.pl")) == "perl"
        assert self.parser.detect_language(Path("Module.pm")) == "perl"
        assert self.parser.detect_language(Path("test.t")) == "perl"

    def test_finds_packages(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Animal" in names
        assert "Dog" in names

    def test_finds_subroutines(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "new" in names
        assert "speak" in names
        assert "fetch" in names
        assert "bark" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) >= 1

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert any(t == "speak" or t.endswith("::speak") for t in targets)  # $self->speak() — method_call_expression
        assert "bless" in targets  # ambiguous_function_call_expression

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        assert len(contains) >= 3


class TestXSParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.xs")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("MyModule.xs")) == "c"

    def test_finds_structs(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "Point" in names

    def test_finds_functions(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "_add" in names
        assert "compute_distance" in names

    def test_finds_includes(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "XSUB.h" in targets
        assert "string.h" in targets

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert any(t == "_add" or t.endswith("::_add") for t in targets)

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        assert len(contains) >= 3


class TestLuaParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.lua")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("init.lua")) == "lua"
        assert self.parser.detect_language(Path("config.lua")) == "lua"

    def test_finds_top_level_functions(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        assert "greet" in names
        assert "helper" in names
        assert "process_animals" in names

    def test_finds_variable_assigned_functions(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        assert "transform" in names
        assert "validate" in names

    def test_finds_dot_syntax_methods(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "Animal"
        ]
        names = {f.name for f in funcs}
        assert "new" in names

    def test_finds_colon_syntax_methods(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "Animal"
        ]
        names = {f.name for f in funcs}
        assert "speak" in names
        assert "rename" in names

    def test_finds_inherited_table_methods(self):
        dog_funcs = [
            n for n in self.nodes
            if n.kind in ("Function", "Test") and n.parent_name == "Dog"
        ]
        names = {f.name for f in dog_funcs}
        assert "new" in names
        assert "fetch" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "cjson" in targets
        assert "lib.utils" in targets
        assert "logging" in targets
        assert len(imports) == 3

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert "print" in targets
        assert "setmetatable" in targets
        assert "assert" in targets

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target.split("::")[-1] for e in contains}
        assert "greet" in targets
        assert "helper" in targets
        assert "Animal.new" in targets
        assert "Animal.speak" in targets
        assert "Dog.fetch" in targets

    def test_method_parent_names(self):
        funcs = {
            (n.name, n.parent_name) for n in self.nodes
            if n.kind == "Function" and n.parent_name is not None
        }
        assert ("new", "Animal") in funcs
        assert ("speak", "Animal") in funcs
        assert ("rename", "Animal") in funcs
        assert ("new", "Dog") in funcs
        assert ("fetch", "Dog") in funcs

    def test_detects_test_functions(self):
        tests = [n for n in self.nodes if n.kind == "Test"]
        names = {t.name for t in tests}
        assert "test_greet" in names
        assert "test_animal_speak" in names
        assert "test_dog_fetch" in names
        assert len(tests) == 3

    def test_extracts_params(self):
        funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
        assert funcs["greet"].params is not None
        assert "name" in funcs["greet"].params
        # Animal.new has (name, sound)
        animal_new = [
            n for n in self.nodes
            if n.name == "new" and n.parent_name == "Animal"
        ][0]
        assert animal_new.params is not None
        assert "name" in animal_new.params
        assert "sound" in animal_new.params

    def test_nodes_have_lua_language(self):
        for node in self.nodes:
            assert node.language == "lua"

    def test_calls_inside_methods(self):
        """Verify that calls inside methods have correct source qualified names."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        sources = {e.source.split("::")[-1] for e in calls}
        assert "Dog.fetch" in sources  # Dog:fetch calls self:speak and print
        assert "Animal.speak" in sources  # Animal:speak calls log:info


class TestLuauParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.luau")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("init.luau")) == "luau"
        assert self.parser.detect_language(Path("module.luau")) == "luau"

    def test_finds_type_aliases(self):
        types = [n for n in self.nodes if n.kind == "Class"]
        names = {t.name for t in types}
        assert "Vector3" in names
        assert "Callback" in names

    def test_finds_top_level_functions(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        assert "greet" in names
        assert "add" in names
        assert "process_animals" in names

    def test_finds_variable_assigned_functions(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        assert "transform" in names

    def test_finds_dot_syntax_methods(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "Animal"
        ]
        names = {f.name for f in funcs}
        assert "new" in names

    def test_finds_colon_syntax_methods(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "Animal"
        ]
        names = {f.name for f in funcs}
        assert "speak" in names
        assert "rename" in names

    def test_finds_inherited_table_methods(self):
        dog_funcs = [
            n for n in self.nodes
            if n.kind in ("Function", "Test") and n.parent_name == "Dog"
        ]
        names = {f.name for f in dog_funcs}
        assert "new" in names
        assert "fetch" in names

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "lib.utils" in targets
        assert "logging" in targets
        assert len(imports) >= 2

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert "print" in targets
        assert "setmetatable" in targets
        assert "assert" in targets

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target.split("::")[-1] for e in contains}
        assert "greet" in targets
        assert "add" in targets
        assert "Animal.new" in targets
        assert "Animal.speak" in targets
        assert "Dog.fetch" in targets

    def test_method_parent_names(self):
        funcs = {
            (n.name, n.parent_name) for n in self.nodes
            if n.kind == "Function" and n.parent_name is not None
        }
        assert ("new", "Animal") in funcs
        assert ("speak", "Animal") in funcs
        assert ("rename", "Animal") in funcs
        assert ("new", "Dog") in funcs
        assert ("fetch", "Dog") in funcs

    def test_detects_test_functions(self):
        tests = [n for n in self.nodes if n.kind == "Test"]
        names = {t.name for t in tests}
        assert "test_greet" in names
        assert "test_animal_speak" in names
        assert "test_dog_fetch" in names
        assert len(tests) == 3

    def test_nodes_have_luau_language(self):
        for node in self.nodes:
            assert node.language == "luau"

    def test_calls_inside_methods(self):
        """Verify that calls inside methods have correct source qualified names."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        sources = {e.source.split("::")[-1] for e in calls}
        assert "Dog.fetch" in sources
        assert "Animal.speak" in sources


class TestObjectiveCParsing:
    """Objective-C parser — closes #88."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.m")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("foo.m")) == "objc"

    def test_nodes_have_objc_language(self):
        for n in self.nodes:
            assert n.language == "objc"

    def test_finds_class(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        # Both @interface and @implementation produce Class nodes; that's
        # fine because they upsert to the same qualified name in the store.
        names = {c.name for c in classes}
        assert "Calculator" in names

    def test_finds_instance_and_class_methods(self):
        funcs = {
            (n.name, n.parent_name) for n in self.nodes if n.kind == "Function"
        }
        assert ("add", "Calculator") in funcs
        assert ("reset", "Calculator") in funcs
        assert ("logResult", "Calculator") in funcs
        assert ("sharedCalculator", "Calculator") in funcs

    def test_finds_c_main(self):
        """Top-level C-style main() must be extracted via the
        function_declarator pattern that C/C++ already use (#88)."""
        funcs = [n for n in self.nodes if n.kind == "Function"]
        main_fn = next((f for f in funcs if f.name == "main"), None)
        assert main_fn is not None
        assert main_fn.parent_name is None  # top-level, not attached to a class

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        # Angle-bracket system headers and quoted user headers both arrive
        # as preproc_include in tree-sitter-objc.
        assert any("Foundation" in t for t in targets)
        assert any("Logger" in t for t in targets)

    def test_extracts_message_expression_calls(self):
        """Objective-C uses [receiver method:args] for method calls; these
        must produce CALLS edges (#88)."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = [e.target for e in calls]
        # Internal [self logResult:sum] should resolve to Calculator.logResult
        assert any(t.endswith("::Calculator.logResult") for t in targets)
        # [Calculator sharedCalculator] from main should also resolve
        assert any(t.endswith("::Calculator.sharedCalculator") for t in targets)
        # External NSLog(...) call_expression should be captured too
        assert "NSLog" in targets


class TestBashParsing:
    """Bash/Shell parser — closes #197."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sh")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("build.sh")) == "bash"
        assert self.parser.detect_language(Path("build.bash")) == "bash"
        assert self.parser.detect_language(Path("run.zsh")) == "bash"
        # Regression for #235 — Korn shell (.ksh) should parse as bash.
        assert self.parser.detect_language(Path("legacy.ksh")) == "bash"

    def test_ksh_extension_parses_as_bash(self, tmp_path):
        """Regression for #235: a real .ksh file is parsed through the bash
        grammar end-to-end and produces the same structural nodes/edges
        as an equivalent .sh file."""
        fixture_source = (FIXTURES / "sample.sh").read_text(encoding="utf-8")
        ksh_copy = tmp_path / "legacy.ksh"
        ksh_copy.write_text(fixture_source, encoding="utf-8")

        ksh_nodes, ksh_edges = self.parser.parse_file(ksh_copy)

        # Language tagging: every node must be "bash".
        assert ksh_nodes, "parser produced zero nodes for .ksh file"
        for n in ksh_nodes:
            assert n.language == "bash"

        # Same function set as the .sh fixture.
        ksh_funcs = {n.name for n in ksh_nodes if n.kind == "Function"}
        sh_funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert ksh_funcs == sh_funcs, (
            f".ksh and .sh produced different function sets: "
            f"sh-only={sh_funcs - ksh_funcs}, ksh-only={ksh_funcs - sh_funcs}"
        )

        # Same structural-edge totals by kind.
        def by_kind(edges):
            counts: dict[str, int] = {}
            for e in edges:
                counts[e.kind] = counts.get(e.kind, 0) + 1
            return counts
        assert by_kind(ksh_edges) == by_kind(self.edges)

    def test_nodes_have_bash_language(self):
        for n in self.nodes:
            assert n.language == "bash"

    def test_finds_functions(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert "log_info" in funcs
        assert "log_error" in funcs
        assert "ensure_dir" in funcs
        assert "cleanup" in funcs
        assert "main" in funcs

    def test_functions_have_no_parent(self):
        """Bash has no classes so every function should be top-level."""
        for n in self.nodes:
            if n.kind == "Function":
                assert n.parent_name is None

    def test_source_creates_import_edge(self):
        """`source ./lib.sh` / `. ./config.sh` should produce IMPORTS_FROM
        edges (#197)."""
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        assert len(imports) >= 2
        targets = [e.target for e in imports]
        # sample_lib.sh exists on disk so should be resolved to an absolute path
        assert any(t.endswith("sample_lib.sh") for t in targets)
        # sample_config.sh doesn't exist; unresolved path is kept as-is
        assert any("sample_config.sh" in t for t in targets)

    def test_command_invocations_create_call_edges(self):
        """Each `command` node inside a function body should become a
        CALLS edge keyed on its command_name (#197)."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        # Built-ins and external commands kept as bare names
        assert "echo" in targets
        assert "mkdir" in targets
        # Internal function calls should resolve to qualified names
        assert any(t.endswith("::log_info") for t in targets)
        assert any(t.endswith("::ensure_dir") for t in targets)
        assert any(t.endswith("::cleanup") for t in targets)

    def test_main_calls_resolve_to_internal_functions(self):
        """main() should have CALLS edges to log_info, ensure_dir, and cleanup."""
        calls = [
            e for e in self.edges
            if e.kind == "CALLS" and e.source.endswith("::main")
        ]
        call_targets = {e.target for e in calls}
        assert any(t.endswith("::log_info") for t in call_targets)
        assert any(t.endswith("::ensure_dir") for t in call_targets)
        assert any(t.endswith("::cleanup") for t in call_targets)


class TestElixirParsing:
    """Elixir parser — closes #112."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.ex")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("lib.ex")) == "elixir"
        assert self.parser.detect_language(Path("script.exs")) == "elixir"

    def test_nodes_have_elixir_language(self):
        for n in self.nodes:
            assert n.language == "elixir"

    def test_modules_become_classes(self):
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "Calculator" in classes
        assert "MathHelpers" in classes

    def test_def_defp_produce_functions_with_parent_module(self):
        funcs = {
            (n.name, n.parent_name) for n in self.nodes if n.kind == "Function"
        }
        # public defs
        assert ("add", "Calculator") in funcs
        assert ("subtract", "Calculator") in funcs
        assert ("compute", "Calculator") in funcs
        assert ("double", "MathHelpers") in funcs
        assert ("triple", "MathHelpers") in funcs
        # private defp
        assert ("log", "Calculator") in funcs

    def test_alias_import_require_produce_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = [e.target for e in imports]
        # alias Calculator, import Calculator, require Logger
        assert targets.count("Calculator") >= 2
        assert "Logger" in targets

    def test_internal_calls_resolve_to_qualified_names(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        # Calculator.compute calls add() and log() — both inside Calculator
        assert any(t.endswith("::Calculator.add") for t in targets)
        assert any(t.endswith("::Calculator.log") for t in targets)
        # MathHelpers.double calls Calculator.compute
        assert any(t.endswith("::Calculator.compute") for t in targets)
        # MathHelpers.triple calls double() — within the same module
        assert any(t.endswith("::MathHelpers.double") for t in targets)

    def test_contains_edges_wire_module_to_functions(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        # Each function should be CONTAINS-linked to its parent module
        function_targets = {
            e.target for e in contains
            if "::" in e.source and "Calculator" in e.source
        }
        assert any(t.endswith("::Calculator.add") for t in function_targets)
        assert any(t.endswith("::Calculator.compute") for t in function_targets)


class TestGDScriptParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.gd")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("player.gd")) == "gdscript"
        assert self.parser.detect_language(Path("globals/manager.gd")) == "gdscript"

    def test_finds_class_name_statement(self):
        """File-level ``class_name X`` declaration becomes a Class node."""
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "SampleManager" in classes

    def test_finds_inner_class(self):
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "Item" in classes

    def test_finds_top_level_functions(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name is None
        ]
        names = {f.name for f in funcs}
        for expected in ("_ready", "_load_items", "get_item", "helper"):
            assert expected in names, f"missing top-level function {expected}"

    def test_finds_inner_class_methods(self):
        """Methods defined inside ``class Inner:`` should attach to the inner class."""
        inner_funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.parent_name == "Item"
        ]
        names = {f.name for f in inner_funcs}
        assert "promote" in names

    def test_finds_extends_as_import(self):
        """``extends Node`` is the GDScript analogue of an import — parent class."""
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "Node" in targets, f"expected Node in imports, got {targets}"

    def test_finds_direct_calls(self):
        """Bare calls (``range(...)``, ``_load_items()``) produce CALLS edges."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert "range" in targets

    def test_finds_attribute_calls(self):
        """``obj.method(...)`` calls live inside ``attribute`` nodes as ``attribute_call``."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        # timer.start(), items.append(item), item_added.emit(item)
        assert "start" in targets
        assert "append" in targets
        assert "emit" in targets

    def test_internal_calls_resolve_to_qualified_names(self):
        """A bare ``_load_items()`` call inside _ready should resolve to the
        same-file function's qualified name."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert any(t.endswith("::_load_items") for t in targets), (
            f"expected ::_load_items in call targets, got {targets}"
        )

    def test_contains_edges_wire_classes_and_functions(self):
        contains = [(e.source, e.target) for e in self.edges if e.kind == "CONTAINS"]
        # File CONTAINS the top-level Class and Function nodes.
        file_contains = {t for s, t in contains if not s.endswith(".gd::Item")
                         and not s.endswith(".gd::SampleManager")}
        assert any(t.endswith("::SampleManager") for t in file_contains)
        assert any(t.endswith("::Item") for t in file_contains)
        assert any(t.endswith("::_ready") for t in file_contains)
        # Inner class CONTAINS its method.
        item_contains = {t for s, t in contains if s.endswith("::Item")}
        assert any(t.endswith("::Item.promote") for t in item_contains)

class TestJuliaParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.jl")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("foo.jl")) == "julia"

    def test_finds_module(self):
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "SampleModule" in classes

    def test_finds_structs(self):
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "Dog" in classes
        assert "MutablePoint" in classes

    def test_finds_abstract_types(self):
        classes = {n.name for n in self.nodes if n.kind == "Class"}
        assert "AbstractAnimal" in classes

    def test_struct_inheritance(self):
        inherits = [e for e in self.edges if e.kind == "INHERITS"]
        # Dog's qualified source is file::SampleModule.Dog; we only care
        # about the trailing struct name and the target.
        pairs = {
            (e.source.split("::")[-1].split(".")[-1], e.target)
            for e in inherits
        }
        assert ("Dog", "AbstractAnimal") in pairs

    def test_finds_long_form_functions(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert "greet" in funcs
        assert "outer" in funcs
        assert "inner" in funcs
        assert "process" in funcs
        assert "show" in funcs

    def test_finds_short_form_functions(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert "add" in funcs
        assert "square" in funcs

    def test_finds_macros(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        assert "sayhello" in funcs

    def test_finds_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "LinearAlgebra" in targets
        assert "JSON" in targets

    def test_finds_selective_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "Statistics.mean" in targets or "Statistics" in targets
        assert "Statistics.std" in targets or "Statistics" in targets

    def test_finds_base_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "Base.show" in targets or "Base" in targets
        assert "Base.print" in targets or "Base" in targets

    def test_finds_include(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert any("utils.jl" in t for t in targets)

    def test_finds_calls(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert len(calls) >= 1

    def test_finds_contains(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        assert len(contains) >= 3

    def test_finds_exports(self):
        refs = [
            e for e in self.edges
            if e.kind == "REFERENCES"
            and e.extra
            and e.extra.get("julia_export")
        ]
        # Targets may be resolved to qualified names (file::SampleModule.greet)
        # if the exported symbol is defined locally; otherwise they stay bare.
        trailing = {e.target.split(".")[-1] for e in refs}
        assert "greet" in trailing
        assert "Dog" in trailing
        assert "process" in trailing

    def test_finds_testsets(self):
        tests = [n for n in self.nodes if n.kind == "Test"]
        assert any("Arithmetic" in t.name for t in tests)

    def test_nested_function_parent(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        # The CONTAINS edge for inner should originate from outer, and
        # its qualified target should carry `outer.inner` in the name.
        assert any(
            e.source.endswith("outer")
            and e.target.endswith("outer.inner")
            for e in contains
        )

    def test_qualified_function_name(self):
        funcs = {n.name for n in self.nodes if n.kind == "Function"}
        # function Base.show(...) -> name is "show", not "Base.show"
        assert "show" in funcs
        assert "Base.show" not in funcs

    def test_nodes_have_julia_language(self):
        nameable = [n for n in self.nodes if n.kind in ("Class", "Function", "Test")]
        assert all(n.language == "julia" for n in nameable)
        assert len(nameable) >= 5

    def test_finds_enum_type(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        by_name = {c.name: c for c in classes}
        assert "Color" in by_name
        assert by_name["Color"].extra.get("julia_kind") == "enum"

    def test_finds_enum_variants(self):
        variants = {
            n.name for n in self.nodes
            if n.kind == "Function"
            and (n.extra or {}).get("julia_kind") == "enum_variant"
        }
        assert {"RED", "BLUE", "GREEN"} <= variants

    def test_enum_variants_contained_by_type(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        # Color -> RED, BLUE, GREEN
        variants_under_color = {
            e.target.split(".")[-1]
            for e in contains
            if e.source.endswith("Color")
        }
        assert {"RED", "BLUE", "GREEN"} <= variants_under_color

    def test_finds_public_symbols(self):
        refs = [
            e for e in self.edges
            if e.kind == "REFERENCES"
            and e.extra
            and e.extra.get("julia_public")
        ]
        trailing = {e.target.split(".")[-1] for e in refs}
        assert "square" in trailing
        assert "add" in trailing

    def test_qualified_function_references_base(self):
        refs = [e for e in self.edges if e.kind == "REFERENCES"]
        # function Base.show(...) should emit a REFERENCES edge to Base
        assert any(
            "show" in e.source and e.target == "Base"
            for e in refs
        )

class TestRescriptParser:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.res")

    def test_detects_language_for_res_and_resi(self):
        assert self.parser.detect_language(Path("lib.res")) == "rescript"
        assert self.parser.detect_language(Path("lib.resi")) == "rescript"

    def test_file_node(self):
        files = [n for n in self.nodes if n.kind == "File"]
        assert len(files) == 1
        assert files[0].language == "rescript"
        assert files[0].extra.get("rescript_interface") is not True

    def test_finds_top_level_modules(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert {"User", "App", "Validator"}.issubset(names)

    def test_nested_module_has_parent(self):
        validator = next(
            n for n in self.nodes if n.kind == "Class" and n.name == "Validator"
        )
        assert validator.parent_name == "User"

    def test_finds_top_level_lets(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "main" in names
        assert "defaultTimeout" in names
        assert "fact" in names
        assert "helper" in names

    def test_let_inside_let_body_is_not_top_level(self):
        # `let u = ...` inside App.start should NOT appear as a Function node.
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "u" not in names
        assert "valid" not in names
        assert "n" not in names

    def test_external_binding_extracted(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        by_name = {f.name: f for f in funcs}
        assert "readFile" in by_name
        assert by_name["readFile"].extra.get("rescript_external") is True

    def test_module_attr_creates_import_edge(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "fs" in targets

    def test_open_and_include_create_import_edges(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "Belt" in targets
        assert "Js.Promise" in targets

    def test_types_extracted(self):
        types = [n for n in self.nodes if n.kind == "Type"]
        names = {t.name for t in types}
        assert {"status", "result", "t", "config"}.intersection(names)

    def test_member_let_has_parent_module(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        by_name = {f.name: f for f in funcs}
        assert by_name["greet"].parent_name == "User"
        assert by_name["isAdult"].parent_name == "Validator"
        assert by_name["start"].parent_name == "App"

    def test_calls_attributed_to_enclosing_let(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        sources = {e.source for e in calls}
        targets = {e.target for e in calls}
        assert any(s.endswith("::App.start") for s in sources)
        assert "User.make" in targets or any(
            t.endswith("::User.make") for t in targets
        )

    def test_contains_edges_wire_module_to_members(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target for e in contains}
        assert any(t.endswith("::User.greet") for t in targets)
        assert any(t.endswith("::Validator.isAdult") for t in targets)

    def test_nodes_have_rescript_language(self):
        non_file = [n for n in self.nodes if n.kind != "File"]
        assert all(n.language == "rescript" for n in non_file)


class TestRescriptInterfaceParser:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.resi")

    def test_file_flagged_as_interface(self):
        file_node = next(n for n in self.nodes if n.kind == "File")
        assert file_node.extra.get("rescript_interface") is True

    def test_modules_extracted_from_interface(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "User" in names
        assert "App" in names
        assert "Validator" in names

    def test_signatures_extracted_without_bodies(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        # Top-level and module-member signatures should both appear.
        assert "defaultTimeout" in names
        assert "fact" in names
        assert "make" in names
        assert "greet" in names
        assert "isAdult" in names
        assert "start" in names

    def test_external_signature_extracted(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        by_name = {f.name: f for f in funcs}
        assert "readFile" in by_name
        assert by_name["readFile"].extra.get("rescript_external") is True

    def test_no_calls_extracted_from_interface(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        assert calls == []


class TestRescriptEdgeCases:
    """Bug-fix tests: IMPORTS_FROM dedup, JS binding tag, JSX, module alias."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.res")

    def test_duplicate_open_produces_single_import_edge(self):
        # sample.res has `open Belt` twice — should emit only one edge.
        belt_edges = [
            e for e in self.edges
            if e.kind == "IMPORTS_FROM" and e.target == "Belt"
        ]
        assert len(belt_edges) == 1

    def test_module_alias_emits_import_edge(self):
        # `module IntMap = Belt.Map.Int` → IMPORTS_FROM Belt.Map.Int
        aliases = [
            e for e in self.edges
            if e.extra.get("rescript_import_kind") == "module_alias"
        ]
        assert any(e.target == "Belt.Map.Int" for e in aliases)
        assert any(e.extra.get("alias_name") == "IntMap" for e in aliases)

    def test_module_alias_is_not_treated_as_block_module(self):
        # IntMap is an alias — should NOT appear as a Class node.
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "IntMap" not in names

    def test_js_binding_module_is_tagged(self):
        text_encoder = next(
            n for n in self.nodes if n.kind == "Class" and n.name == "TextEncoder"
        )
        assert text_encoder.extra.get("rescript_kind") == "js_binding"

    def test_regular_module_keeps_module_tag(self):
        user = next(
            n for n in self.nodes if n.kind == "Class" and n.name == "User"
        )
        assert user.extra.get("rescript_kind") == "module"

    def test_jsx_emits_import_and_call_edges(self):
        jsx_imports = [
            e for e in self.edges
            if e.extra.get("rescript_import_kind") == "jsx"
        ]
        jsx_targets = {e.target for e in jsx_imports}
        assert "Layout" in jsx_targets
        assert "User" in jsx_targets
        assert "AnalyticsFilterUi" in jsx_targets

        jsx_calls = [
            e for e in self.edges
            if e.kind == "CALLS"
            and e.extra.get("rescript_call_kind") == "jsx"
        ]
        call_targets = {e.target for e in jsx_calls}
        assert "User.Badge" in call_targets
        assert "AnalyticsFilterUi.Filter" in call_targets

    def test_jsx_call_attributed_to_enclosing_let(self):
        jsx_calls = [
            e for e in self.edges
            if e.kind == "CALLS"
            and e.extra.get("rescript_call_kind") == "jsx"
        ]
        assert all(e.source.endswith("::render") for e in jsx_calls)


class TestRescriptCrossModuleResolver:
    """Integration test for the cross-module resolver post-pass."""

    def _build(self, tmp_path):
        from code_review_graph.graph import GraphStore
        from code_review_graph.incremental import full_build

        (tmp_path / ".git").mkdir()

        (tmp_path / "LogicUtils.res").write_text(
            "let safeParse = (s) => s\n"
            "let trim = (s) => s\n"
        )
        (tmp_path / "CurrencyFormatUtils.res").write_text(
            "let format = (n) => n\n"
        )
        (tmp_path / "Caller.res").write_text(
            "open CurrencyFormatUtils\n"
            "let run = () => {\n"
            "  let a = LogicUtils.safeParse(\"x\")\n"
            "  let b = LogicUtils.safeParse(\"y\")\n"
            "  let c = format(12.0)\n"
            "  let d = <Layout name=\"hi\" />\n"
            "  (a, b, c, d)\n"
            "}\n"
        )
        (tmp_path / "Layout.res").write_text(
            "let make = (~name) => name\n"
        )

        store = GraphStore(tmp_path / "graph.db")
        result = full_build(tmp_path, store)
        return store, result

    def test_qualified_call_resolves_to_canonical_node(self, tmp_path):
        store, _ = self._build(tmp_path)
        cur = store._conn.cursor()
        rows = cur.execute(
            "SELECT target_qualified FROM edges "
            "WHERE kind='CALLS' AND source_qualified LIKE '%Caller.res::run'"
        ).fetchall()
        targets = {r["target_qualified"] for r in rows}
        # Both LogicUtils.safeParse callsites should now point to the canonical
        # node path, not the bare `LogicUtils.safeParse` string.
        assert any(
            t.endswith("LogicUtils.res::safeParse") for t in targets
        ), f"no canonical resolution in {targets}"
        assert not any(t == "LogicUtils.safeParse" for t in targets)

    def test_callers_of_canonical_node_finds_both_sites(self, tmp_path):
        store, _ = self._build(tmp_path)
        # Two calls to safeParse from the same caller — both should survive
        # as separate edges pointing to the canonical node.
        cur = store._conn.cursor()
        count = cur.execute(
            "SELECT COUNT(*) as c FROM edges "
            "WHERE kind='CALLS' "
            "AND target_qualified LIKE '%LogicUtils.res::safeParse'"
        ).fetchone()["c"]
        assert count == 2

    def test_bare_call_resolves_via_open_directive(self, tmp_path):
        store, _ = self._build(tmp_path)
        cur = store._conn.cursor()
        rows = cur.execute(
            "SELECT target_qualified FROM edges WHERE kind='CALLS' "
            "AND target_qualified LIKE '%CurrencyFormatUtils.res::format'"
        ).fetchall()
        assert len(rows) == 1

    def test_imports_from_rewrites_to_file_path(self, tmp_path):
        store, _ = self._build(tmp_path)
        cur = store._conn.cursor()
        rows = cur.execute(
            "SELECT target_qualified FROM edges WHERE kind='IMPORTS_FROM' "
            "AND file_path LIKE '%Caller.res'"
        ).fetchall()
        targets = {r["target_qualified"] for r in rows}
        # `open CurrencyFormatUtils` and `<Layout />` should both resolve
        # to file paths.
        assert any(t.endswith("CurrencyFormatUtils.res") for t in targets)
        assert any(t.endswith("Layout.res") for t in targets)

    def test_resolver_stats_in_build_result(self, tmp_path):
        _, result = self._build(tmp_path)
        stats = result["rescript_resolution"]
        assert stats["files_indexed"] == 4
        assert stats["calls_resolved"] >= 3
        assert stats["imports_resolved"] >= 2

    def test_resolver_is_idempotent(self, tmp_path):
        from code_review_graph.rescript_resolver import (
            resolve_rescript_cross_module,
        )
        store, _ = self._build(tmp_path)
        second = resolve_rescript_cross_module(store)
        # Second run should find nothing new — all already resolved.
        assert second["calls_resolved"] == 0
        assert second["imports_resolved"] == 0


class TestNixParsing:
    """Flake-aware Nix parser — see the Nix language-support epic."""

    def setup_method(self):
        self.parser = CodeParser()
        # Parse the flake-shaped fixture as if its basename were ``flake.nix``
        # so the ``inputs.*.url`` branch of _extract_nix_constructs fires.
        flake_bytes = (FIXTURES / "sample.nix").read_bytes()
        self.flake_path = FIXTURES / "flake.nix"
        self.flake_nodes, self.flake_edges = self.parser.parse_bytes(
            self.flake_path, flake_bytes,
        )
        # The non-flake fixture retains its actual path; it's used to verify
        # the flake-input branch does *not* fire on non-flake files.
        module_path = FIXTURES / "sample_module.nix"
        self.module_nodes, self.module_edges = self.parser.parse_file(module_path)

    def test_detects_language(self):
        assert self.parser.detect_language(Path("flake.nix")) == "nix"
        assert self.parser.detect_language(Path("modules/foo.nix")) == "nix"

    def test_nodes_have_nix_language(self):
        for n in self.flake_nodes:
            assert n.language == "nix"
        for n in self.module_nodes:
            assert n.language == "nix"

    def test_top_level_bindings_become_functions(self):
        funcs = {n.name for n in self.flake_nodes if n.kind == "Function"}
        # Top-level bindings from sample.nix (flake-shaped).
        assert "description" in funcs
        assert "inputs" in funcs
        assert "outputs" in funcs
        # Nested bindings flattened to dotted names.
        assert "packages.default" in funcs
        assert "devShells.default" in funcs

    def test_flake_inputs_produce_import_edges(self):
        targets = {
            e.target for e in self.flake_edges if e.kind == "IMPORTS_FROM"
        }
        assert "github:NixOS/nixpkgs/nixos-unstable" in targets
        assert "github:numtide/flake-utils" in targets

    def test_import_and_callpackage_produce_import_edges(self):
        targets = {
            e.target for e in self.flake_edges if e.kind == "IMPORTS_FROM"
        }
        # callPackage ./default.nix and import ./shell.nix. Relative paths
        # are resolved against the caller's directory when possible; since
        # neither file exists alongside the fixture, the raw relative
        # path is preserved.
        assert "./default.nix" in targets
        assert "./shell.nix" in targets

    def test_non_flake_file_has_no_input_edges(self):
        # ``sample_module.nix`` is not named ``flake.nix``, so the
        # inputs.*.url branch must not fire — no github:-prefixed targets.
        targets = [
            e.target for e in self.module_edges if e.kind == "IMPORTS_FROM"
        ]
        assert not any(t.startswith("github:") for t in targets)
        # The import ./foo.nix inside the `let` body still produces an edge.
        assert any("foo.nix" in t for t in targets)

    def test_contains_edges_wire_file_to_top_level_bindings(self):
        file_path = str(self.flake_path)
        contains_targets = {
            e.target for e in self.flake_edges
            if e.kind == "CONTAINS" and e.source == file_path
        }
        # Each top-level binding should be CONTAINS-linked from the file.
        for name in ("description", "inputs", "outputs"):
            qualified = f"{file_path}::{name}"
            assert qualified in contains_targets, (
                f"missing CONTAINS edge for {qualified}"
            )


class TestSpringDIParsing:
    """Tests for Spring DI annotation detection and INJECTS edge generation."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "SpringDI.java")

    def test_detects_spring_stereotype_on_repository(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "JpaOrderRepository" in classes
        assert classes["JpaOrderRepository"].extra.get("spring_stereotype") == "Repository"

    def test_detects_spring_stereotype_on_service(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "NotificationService" in classes
        assert classes["NotificationService"].extra.get("spring_stereotype") == "Service"
        assert "OrderService" in classes
        assert classes["OrderService"].extra.get("spring_stereotype") == "Service"

    def test_detects_spring_stereotype_on_configuration(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "AppConfig" in classes
        assert classes["AppConfig"].extra.get("spring_stereotype") == "Configuration"

    def test_no_stereotype_on_plain_interface(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "OrderRepository" in classes
        assert "spring_stereotype" not in classes["OrderRepository"].extra

    def test_spring_annotations_list_stored(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        annotations = classes["OrderService"].extra.get("spring_annotations", [])
        assert "Service" in annotations
        assert "RequiredArgsConstructor" in annotations

    def test_autowired_field_injection_edge(self):
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        # NotificationService has @Autowired OrderRepository field
        field_edges = [e for e in injects if e.extra.get("injection_type") == "field"]
        targets = {e.target for e in field_edges}
        assert "OrderRepository" in targets

    def test_autowired_field_source_is_class(self):
        injects = [e for e in self.edges if e.kind == "INJECTS"
                   and e.extra.get("injection_type") == "field"]
        sources = {e.source for e in injects}
        assert any("NotificationService" in s for s in sources)

    def test_lombok_required_args_constructor_injection(self):
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        lombok_edges = [e for e in injects
                        if e.extra.get("injection_type") == "constructor_lombok"]
        targets = {e.target for e in lombok_edges}
        # OrderService has two final injected fields
        assert "OrderRepository" in targets
        assert "NotificationService" in targets

    def test_static_final_field_not_injected(self):
        """static final String TAG should NOT produce an INJECTS edge."""
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        targets = {e.target for e in injects}
        assert "String" not in targets

    def test_explicit_autowired_constructor_injection(self):
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        ctor_edges = [e for e in injects
                      if e.extra.get("injection_type") == "constructor"]
        targets = {e.target for e in ctor_edges}
        # AuditLogger has @Autowired constructor with OrderRepository param
        assert "OrderRepository" in targets

    def test_autowired_constructor_source_is_class(self):
        injects = [e for e in self.edges if e.kind == "INJECTS"
                   and e.extra.get("injection_type") == "constructor"]
        sources = {e.source for e in injects}
        assert any("AuditLogger" in s for s in sources)

    def test_total_injects_edge_count(self):
        """Sanity check: total INJECTS edges matches known injection points."""
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        # NotificationService: 1 field
        # OrderService: 2 lombok (orderRepository + notificationService)
        # AuditLogger: 1 constructor
        assert len(injects) >= 4

    def test_field_name_stored_in_injects_extra(self):
        """INJECTS edges must carry extra.field_name for the resolver."""
        injects = [e for e in self.edges if e.kind == "INJECTS"]
        names = {e.extra.get("field_name") for e in injects}
        # @Autowired field in NotificationService
        assert "orderRepository" in names
        # @RequiredArgsConstructor final fields in OrderService
        assert "orderRepository" in names
        assert "notificationService" in names
        # @Autowired constructor param in AuditLogger
        assert "orderRepository" in names

    def test_java_method_call_target_is_method_not_receiver(self):
        """Java receiver.method() must emit CALLS with method as target, not receiver."""
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        # placeOrder calls orderRepository.save() — target must end in "save"
        # (possibly qualified to "::OrderRepository.save" if same-file resolution kicks in)
        assert any("save" in t for t in targets), f"expected 'save' in targets, got {targets}"
        # receiver variable names must NOT appear as CALLS targets
        assert "orderRepository" not in targets
        assert "notificationService" not in targets

    def test_java_receiver_stored_in_calls_extra(self):
        """CALLS edges for Java method calls must carry extra.receiver."""
        calls = [e for e in self.edges if e.kind == "CALLS" and e.extra.get("receiver")]
        receivers = {e.extra["receiver"] for e in calls}
        assert "orderRepository" in receivers or "notificationService" in receivers


class TestSpringDIResolver:
    """Integration tests for the Spring DI post-build resolver."""

    def _build(self, tmp_path):
        """Build a mini Spring repo and run the resolver."""
        pkg = tmp_path / "src/main/java/com/example"
        pkg.mkdir(parents=True)

        (pkg / "OrderRepository.java").write_text(
            "package com.example;\n"
            "public interface OrderRepository {\n"
            "    void save(Order o);\n"
            "}\n"
        )
        (pkg / "JpaOrderRepository.java").write_text(
            "package com.example;\n"
            "import org.springframework.stereotype.Repository;\n"
            "@Repository\n"
            "public class JpaOrderRepository implements OrderRepository {\n"
            "    public void save(Order o) {}\n"
            "}\n"
        )
        (pkg / "OrderService.java").write_text(
            "package com.example;\n"
            "import org.springframework.stereotype.Service;\n"
            "import lombok.RequiredArgsConstructor;\n"
            "@Service\n"
            "@RequiredArgsConstructor\n"
            "public class OrderService {\n"
            "    private final OrderRepository orderRepository;\n"
            "    public void place(Order o) {\n"
            "        orderRepository.save(o);\n"
            "    }\n"
            "}\n"
        )

        from code_review_graph.graph import GraphStore
        from code_review_graph.incremental import full_build
        from code_review_graph.postprocessing import run_post_processing

        store = GraphStore(str(tmp_path / "graph.db"))
        result = full_build(tmp_path, store)
        run_post_processing(store)
        return store, result

    def test_resolver_runs_and_reports(self, tmp_path):
        _, result = self._build(tmp_path)
        stats = result.get("spring_resolution")
        assert stats is not None
        assert stats["files_indexed"] > 0

    def test_calls_resolved_through_field(self, tmp_path):
        store, result = self._build(tmp_path)
        stats = result.get("spring_resolution", {})
        assert stats.get("calls_resolved", 0) >= 1

    def test_resolved_target_includes_method_name(self, tmp_path):
        store, _ = self._build(tmp_path)
        cur = store._conn.cursor()
        rows = cur.execute(
            "SELECT target_qualified FROM edges WHERE kind='CALLS' "
            "AND extra LIKE '%spring_resolved%'"
        ).fetchall()
        assert rows, "Expected at least one spring-resolved CALLS edge"
        for (target,) in rows:
            assert "." in target or "::" in target, (
                f"Resolved target should contain type.method or ::, got: {target!r}"
            )


class TestTemporalParsing:
    """Tests for Temporal @WorkflowInterface / @ActivityInterface detection."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "TemporalWorkflow.java")

    def test_workflow_interface_gets_temporal_role(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "OrderWorkflow" in classes
        assert classes["OrderWorkflow"].extra.get("temporal_role") == "workflow_interface"

    def test_activity_interface_gets_temporal_role(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "PaymentActivity" in classes
        assert classes["PaymentActivity"].extra.get("temporal_role") == "activity_interface"
        assert "ShippingActivity" in classes
        assert classes["ShippingActivity"].extra.get("temporal_role") == "activity_interface"

    def test_impl_class_has_no_temporal_role(self):
        classes = {n.name: n for n in self.nodes if n.kind == "Class"}
        assert "OrderWorkflowImpl" in classes
        assert "temporal_role" not in classes["OrderWorkflowImpl"].extra

    def test_temporal_stub_edges_emitted_for_activity_fields(self):
        stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
        targets = {e.target for e in stubs}
        assert "PaymentActivity" in targets
        assert "ShippingActivity" in targets

    def test_temporal_stub_field_name_stored(self):
        stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
        field_names = {e.extra.get("field_name") for e in stubs}
        assert "paymentActivity" in field_names
        assert "shippingActivity" in field_names

    def test_static_field_not_in_temporal_stubs(self):
        stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
        field_names = {e.extra.get("field_name") for e in stubs}
        assert "TAG" not in field_names

    def test_temporal_stub_source_is_workflow_impl(self):
        stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
        sources = {e.source for e in stubs}
        assert any("OrderWorkflowImpl" in s for s in sources)

    def test_workflow_method_annotation_stored_on_method(self):
        interface_methods = [
            n for n in self.nodes if n.kind == "Function" and n.parent_name == "OrderWorkflow"
        ]
        names = {n.name: n for n in interface_methods}
        assert "processOrder" in names
        assert names["processOrder"].extra.get("temporal_role") == "workflowmethod"

    def test_signal_method_annotation_stored(self):
        interface_methods = [
            n for n in self.nodes if n.kind == "Function" and n.parent_name == "OrderWorkflow"
        ]
        names = {n.name: n for n in interface_methods}
        assert "cancelOrder" in names
        assert names["cancelOrder"].extra.get("temporal_role") == "signalmethod"

    def test_activity_method_annotation_stored(self):
        activity_methods = [
            n for n in self.nodes if n.kind == "Function" and n.parent_name == "PaymentActivity"
        ]
        names = {n.name: n for n in activity_methods}
        assert "chargeCard" in names
        assert names["chargeCard"].extra.get("temporal_role") == "activitymethod"


class TestTemporalResolver:
    """Integration tests for the Temporal post-build call resolver."""

    def _build(self, tmp_path):
        pkg = tmp_path / "src/main/java/com/example"
        pkg.mkdir(parents=True)

        (pkg / "PaymentActivity.java").write_text(
            "package com.example;\n"
            "import io.temporal.activity.ActivityInterface;\n"
            "import io.temporal.activity.ActivityMethod;\n"
            "@ActivityInterface\n"
            "public interface PaymentActivity {\n"
            "    @ActivityMethod\n"
            "    boolean charge(String orderId);\n"
            "}\n"
        )
        (pkg / "PaymentActivityImpl.java").write_text(
            "package com.example;\n"
            "public class PaymentActivityImpl implements PaymentActivity {\n"
            "    public boolean charge(String orderId) { return true; }\n"
            "}\n"
        )
        (pkg / "OrderWorkflowImpl.java").write_text(
            "package com.example;\n"
            "public class OrderWorkflowImpl {\n"
            "    private PaymentActivity paymentActivity;\n"
            "    public String process(String id) {\n"
            "        return paymentActivity.charge(id) ? \"OK\" : \"FAIL\";\n"
            "    }\n"
            "}\n"
        )

        from code_review_graph.graph import GraphStore
        from code_review_graph.incremental import full_build

        store = GraphStore(str(tmp_path / "graph.db"))
        result = full_build(tmp_path, store)
        return store, result

    def test_temporal_resolver_runs_and_reports(self, tmp_path):
        _, result = self._build(tmp_path)
        stats = result.get("temporal_resolution")
        assert stats is not None
        assert stats["files_indexed"] > 0

    def test_calls_resolved_through_activity_stub(self, tmp_path):
        _, result = self._build(tmp_path)
        stats = result.get("temporal_resolution", {})
        assert stats.get("calls_resolved", 0) >= 1

    def test_resolved_target_is_fully_qualified(self, tmp_path):
        store, _ = self._build(tmp_path)
        rows = store._conn.execute(
            "SELECT target_qualified FROM edges WHERE kind='CALLS' "
            "AND extra LIKE '%temporal_resolved%'"
        ).fetchall()
        assert rows, "Expected at least one temporal-resolved CALLS edge"
        for (target,) in rows:
            assert "." in target or "::" in target, (
                f"Resolved target should be qualified, got: {target!r}"
            )


class TestKafkaParsing:
    """Tests for Kafka CONSUMES / PRODUCES edge detection."""

    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "KafkaPatterns.java")

    def test_kafka_listener_annotation_emits_consumes_edge(self):
        consumes = [e for e in self.edges if e.kind == "CONSUMES"]
        targets = {e.target for e in consumes}
        assert "kafka:order-events" in targets

    def test_kafka_listener_multiple_topics(self):
        consumes = [e for e in self.edges if e.kind == "CONSUMES"]
        targets = {e.target for e in consumes}
        assert "kafka:order-dlq" in targets
        assert "kafka:order-retry" in targets

    def test_kafka_listener_topic_in_extra(self):
        consumes = [e for e in self.edges if e.kind == "CONSUMES"
                    and e.target == "kafka:order-events"]
        assert consumes
        assert consumes[0].extra.get("topic") == "order-events"

    def test_kafka_template_field_emits_produces_edge(self):
        produces = [e for e in self.edges if e.kind == "PRODUCES"]
        sources = {e.source for e in produces}
        assert any("NotificationProducer" in s for s in sources)

    def test_kafka_receiver_field_emits_consumes_edge(self):
        consumes = [e for e in self.edges if e.kind == "CONSUMES"]
        sources = {e.source for e in consumes}
        assert any("ReactiveOrderConsumer" in s for s in sources)

    def test_kafka_receiver_message_type_stored(self):
        consumes = [e for e in self.edges if e.kind == "CONSUMES"
                    and "ReactiveOrderConsumer" in e.source]
        assert consumes
        assert consumes[0].extra.get("message_type") == "OrderEvent"

    def test_kafka_operations_field_emits_produces_edge(self):
        produces = [e for e in self.edges if e.kind == "PRODUCES"]
        sources = {e.source for e in produces}
        assert any("ReactiveOrderConsumer" in s for s in sources)

    def test_static_field_not_in_kafka_edges(self):
        all_kafka = [e for e in self.edges if e.kind in ("CONSUMES", "PRODUCES")]
        field_names = {e.extra.get("field_name") for e in all_kafka}
        assert "TOPIC" not in field_names

    def test_no_kafka_edges_for_plain_class(self):
        # OrderEvent (plain class, no Kafka) should not appear as a source
        kafka = [e for e in self.edges if e.kind in ("CONSUMES", "PRODUCES")]
        bare_sources = {e.source.split("::")[-1].split(".")[0] for e in kafka}
        assert "OrderEvent" not in bare_sources


# ---------------------------------------------------------------------------
# Verilog / SystemVerilog
# ---------------------------------------------------------------------------


def _has_verilog_parser():
    try:
        import tree_sitter_language_pack as tslp
        tslp.get_parser("verilog")
        return True
    except (LookupError, ImportError):
        return False


@pytest.mark.skipif(not _has_verilog_parser(), reason="verilog tree-sitter grammar not installed")
class TestVerilogParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sv")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("top.sv")) == "verilog"
        assert self.parser.detect_language(Path("pkg.svh")) == "verilog"
        assert self.parser.detect_language(Path("cpu.v")) == "verilog"
        assert self.parser.detect_language(Path("header.vh")) == "verilog"

    def test_finds_modules(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "FIFOController" in names
        assert "Adder" in names

    def test_finds_interfaces(self):
        classes = [n for n in self.nodes if n.kind == "Class"]
        names = {c.name for c in classes}
        assert "BusIf" in names

    def test_finds_tasks(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "do_write" in names

    def test_finds_functions_in_module(self):
        funcs = [n for n in self.nodes if n.kind == "Function"]
        names = {f.name for f in funcs}
        assert "is_full" in names

    def test_task_and_function_parent_is_module(self):
        funcs = {f.name: f for f in self.nodes if f.kind == "Function"}
        assert funcs["do_write"].parent_name == "FIFOController"
        assert funcs["is_full"].parent_name == "FIFOController"

    def test_finds_package_imports(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        assert "utils_pkg" in targets
        assert "arith_pkg" in targets

    def test_module_instantiation_creates_call_edge(self):
        calls = [e for e in self.edges if e.kind == "CALLS"]
        targets = {e.target for e in calls}
        assert any("Adder" in t for t in targets)

    def test_module_instantiation_caller_is_enclosing_module(self):
        # module_instantiation CALLS must be attributed to the containing
        # module, not a function — Verilog-specific fallback in _extract_calls.
        calls = [e for e in self.edges if e.kind == "CALLS"]
        adder_calls = [e for e in calls if "Adder" in e.target]
        assert adder_calls, "Expected a CALLS edge for Adder instantiation"
        assert any("FIFOController" in e.source for e in adder_calls)

    def test_file_node_language(self):
        file_nodes = [n for n in self.nodes if n.kind == "File"]
        assert len(file_nodes) == 1
        assert file_nodes[0].language == "verilog"

class TestSQLParsing:
    def setup_method(self):
        self.parser = CodeParser()
        self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sql")

    def test_detects_language(self):
        assert self.parser.detect_language(Path("schema.sql")) == "sql"

    def test_file_node(self):
        file_nodes = [n for n in self.nodes if n.kind == "File"]
        assert len(file_nodes) == 1
        assert file_nodes[0].language == "sql"

    def test_finds_tables(self):
        tables = [n for n in self.nodes if n.kind == "Class" and n.extra.get("sql_kind") == "table"]
        names = {t.name for t in tables}
        assert "users" in names
        assert "orders" in names

    def test_finds_view(self):
        views = [n for n in self.nodes if n.kind == "Class" and n.extra.get("sql_kind") == "view"]
        names = {v.name for v in views}
        assert "active_orders" in names

    def test_finds_function(self):
        funcs = [
            n for n in self.nodes
            if n.kind == "Function" and n.extra.get("sql_kind") == "function"
        ]
        names = {f.name for f in funcs}
        assert "get_user_total" in names

    def test_finds_procedure(self):
        procs = [
            n for n in self.nodes
            if n.kind == "Function" and n.extra.get("sql_kind") == "procedure"
        ]
        names = {p.name for p in procs}
        assert "archive_old_orders" in names

    def test_contains_edges(self):
        contains = [e for e in self.edges if e.kind == "CONTAINS"]
        targets = {e.target.split("::")[-1] for e in contains}
        assert "users" in targets
        assert "orders" in targets
        assert "active_orders" in targets
        assert "get_user_total" in targets
        assert "archive_old_orders" in targets

    def test_table_reference_edges(self):
        imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
        targets = {e.target for e in imports}
        # active_orders view and archive procedure both reference orders/users
        assert "orders" in targets or "users" in targets
