"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, Fortran, JS/TS, .NET project files, XAML."""
from __future__ import annotations
from pathlib import Path
import pytest
from graphify.extract import (
    extract_java, extract_c, extract_cpp, extract_ruby,
    extract_csharp, extract_kotlin, extract_scala, extract_php,
    extract_swift, extract_go, extract_julia, extract_js, extract_fortran,
    extract_groovy, extract_sln, extract_csproj, extract_xaml, extract_razor,
    extract_dm, extract_dmi, extract_dmm, extract_dmf,
    extract_powershell, extract_apex, extract_verilog,
    extract_powershell_manifest,
)

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

# tree-sitter-dm is an optional extra (#1104) - it ships no Linux/Mac wheel, so it
# is not installed by a default `uv sync`. Skip the .dm/.dme grammar tests when the
# grammar is absent (.dmi/.dmm/.dmf use no tree-sitter and are always tested).
import importlib.util as _ilu
_needs_dm = pytest.mark.skipif(
    _ilu.find_spec("tree_sitter_dm") is None,
    reason="tree-sitter-dm not installed (optional [dm] extra)",
)


def _labels(r):
    return [n["label"] for n in r["nodes"]]

def _relations(r):
    return {e["relation"] for e in r["edges"]}

def _calls(r):
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    return {
        (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
        for e in r["edges"] if e["relation"] == "calls"
    }


def _references(r):
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    return [
        (
            node_by_id.get(e["source"], e["source"]),
            node_by_id.get(e["target"], e["target"]),
            e,
        )
        for e in r["edges"] if e["relation"] == "references"
    ]


def _edges_with_relation(r, *relations):
    return [e for e in r["edges"] if e["relation"] in relations]


def _normalize_symbol_label(label: str) -> str:
    return label.strip("()").lstrip(".")


def _node_by_label(result: dict, label: str) -> dict:
    for node in result["nodes"]:
        if node.get("label") == label or _normalize_symbol_label(node.get("label", "")) == label:
            return node
    raise AssertionError(f"missing node label {label!r}")


def _edge_labels(result: dict, relation: str, context: str | None = None) -> set[tuple[str, str]]:
    labels = {node["id"]: _normalize_symbol_label(node["label"]) for node in result["nodes"]}
    pairs = set()
    for edge in result["edges"]:
        if edge.get("relation") != relation:
            continue
        if context is not None and edge.get("context") != context:
            continue
        pairs.add((labels.get(edge["source"], edge["source"]), labels.get(edge["target"], edge["target"])))
    return pairs


# ── Java ──────────────────────────────────────────────────────────────────────

def test_java_no_error():
    r = extract_java(FIXTURES / "sample.java")
    assert "error" not in r

def test_java_finds_class():
    r = extract_java(FIXTURES / "sample.java")
    assert any("DataProcessor" in l for l in _labels(r))

def test_java_finds_interface():
    r = extract_java(FIXTURES / "sample.java")
    assert any("Processor" in l for l in _labels(r))

def test_java_finds_methods():
    r = extract_java(FIXTURES / "sample.java")
    labels = _labels(r)
    assert any("addItem" in l for l in labels)
    assert any("process" in l for l in labels)

def test_java_finds_imports():
    r = extract_java(FIXTURES / "sample.java")
    assert "imports" in _relations(r)


def test_java_import_edges_have_import_context():
    r = extract_java(FIXTURES / "sample.java")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)

def test_java_no_dangling_edges():
    r = extract_java(FIXTURES / "sample.java")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids


def test_java_enum_constants_have_case_of_edge():
    r = extract_java(FIXTURES / "sample.java")
    labels = _labels(r)
    assert "OK" in labels
    assert "GAME_DONE" in labels
    assert ("ErrorCode", "OK") in _edge_labels(r, "case_of")
    assert ("ErrorCode", "GAME_DONE") in _edge_labels(r, "case_of")


# ── C ────────────────────────────────────────────────────────────────────────

def test_c_no_error():
    r = extract_c(FIXTURES / "sample.c")
    assert "error" not in r

def test_c_finds_functions():
    r = extract_c(FIXTURES / "sample.c")
    labels = _labels(r)
    assert any("process" in l for l in labels)
    assert any("main" in l for l in labels)

def test_c_finds_includes():
    r = extract_c(FIXTURES / "sample.c")
    assert "imports" in _relations(r)

def test_c_emits_calls():
    r = extract_c(FIXTURES / "sample.c")
    assert any(e["relation"] == "calls" for e in r["edges"])

def test_c_calls_are_extracted():
    r = extract_c(FIXTURES / "sample.c")
    for e in r["edges"]:
        if e["relation"] == "calls":
            assert e["confidence"] == "EXTRACTED"


def test_c_import_edges_have_import_context():
    r = extract_c(FIXTURES / "sample.c")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_c_parameter_and_return_type_contexts():
    r = extract_c(FIXTURES / "sample.c")
    assert ("make_rect", "Rectangle") in _edge_labels(r, "references", "parameter_type")
    assert ("make_rect", "Rectangle") in _edge_labels(r, "references", "return_type")


def test_c_call_edges_have_call_context():
    r = extract_c(FIXTURES / "sample.c")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)


# ── C++ ───────────────────────────────────────────────────────────────────────

def test_cpp_no_error():
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert "error" not in r

def test_cpp_finds_class():
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert any("HttpClient" in l for l in _labels(r))

def test_cpp_finds_methods():
    r = extract_cpp(FIXTURES / "sample.cpp")
    labels = _labels(r)
    # C++ extractor captures the constructor and public-visible methods
    assert any("HttpClient" in l for l in labels)

def test_cpp_finds_includes():
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert "imports" in _relations(r)


def test_cpp_import_edges_have_import_context():
    r = extract_cpp(FIXTURES / "sample.cpp")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_cpp_method_parameter_and_return_type_contexts():
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert ("get", "string") in _edge_labels(r, "references", "parameter_type")
    assert ("get", "string") in _edge_labels(r, "references", "return_type")


def test_cpp_field_and_template_argument_contexts():
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert ("HttpClient", "string") in _edge_labels(r, "references", "field")
    assert ("HttpClient", "vector") in _edge_labels(r, "references", "field")
    assert ("HttpClient", "string") in _edge_labels(r, "references", "generic_arg")


def test_cpp_class_inherits_edge():
    """Regression for #915: `class Derived : public Base {}` should emit an inherits edge."""
    r = extract_cpp(FIXTURES / "sample.cpp")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "AuthedHttpClient" in node_by_id.get(e["source"], "")
        and "HttpClient" in node_by_id.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "inherits"
    )
    assert found, "AuthedHttpClient should have inherits edge to HttpClient"


def test_cpp_struct_inherits_edge():
    """Structs use the same `: Base` syntax as classes and must also emit inherits."""
    r = extract_cpp(FIXTURES / "sample.cpp")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "RetryingHttpClient" in node_by_id.get(e["source"], "")
        and "HttpClient" in node_by_id.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "inherits"
    )
    assert found, "RetryingHttpClient (struct) should have inherits edge to HttpClient"


def test_cpp_generic_parents_include_type_argument_references():
    """`class PooledClient : public Connection<HttpClient>` must emit the inherits
    edge to Connection AND a generic_arg reference to the HttpClient type argument,
    matching the Java base-class behaviour (_emit_java_parent_type)."""
    r = extract_cpp(FIXTURES / "sample.cpp")
    assert ("PooledClient", "Connection") in _edge_labels(r, "inherits")
    assert ("PooledClient", "HttpClient") in _edge_labels(r, "references", "generic_arg")


# ── CUDA ──────────────────────────────────────────────────────────────────────
# CUDA is a C++ superset, so .cu/.cuh route through the C++ (tree-sitter-cpp)
# extractor. These tests guard that __global__/__device__ kernels, host
# functions, structs and includes are all extracted.

def test_cuda_no_error():
    r = extract_cpp(FIXTURES / "sample.cu")
    assert "error" not in r

def test_cuda_finds_kernel_and_device_functions():
    r = extract_cpp(FIXTURES / "sample.cu")
    labels = _labels(r)
    assert any("saxpy" in l for l in labels)   # __global__ kernel
    assert any("dot" in l for l in labels)     # __device__ function

def test_cuda_finds_struct():
    r = extract_cpp(FIXTURES / "sample.cu")
    assert any("Vec3" in l for l in _labels(r))

def test_cuda_finds_includes():
    r = extract_cpp(FIXTURES / "sample.cu")
    assert "imports" in _relations(r)

def test_cuda_host_call_edges():
    r = extract_cpp(FIXTURES / "sample.cu")
    calls = _calls(r)
    assert ("host_norm()", "dot()") in calls
    assert ("main()", "host_norm()") in calls


# Metal Shading Language is a C++14-derived language, so .metal files route
# through the C++ extractor just like CUDA does.

def test_metal_is_code_extension():
    from graphify.detect import CODE_EXTENSIONS
    assert ".metal" in CODE_EXTENSIONS


def test_metal_no_error():
    r = extract_cpp(FIXTURES / "sample.metal")
    assert "error" not in r


def test_metal_finds_kernel_function_and_struct():
    r = extract_cpp(FIXTURES / "sample.metal")
    labels = _labels(r)
    assert any("Vec3" in l for l in labels)
    assert any("dot3" in l for l in labels)
    assert any("saxpy" in l for l in labels)


# ── Ruby ─────────────────────────────────────────────────────────────────────

def test_ruby_no_error():
    r = extract_ruby(FIXTURES / "sample.rb")
    assert "error" not in r

def test_ruby_finds_class():
    r = extract_ruby(FIXTURES / "sample.rb")
    assert any("ApiClient" in l for l in _labels(r))

def test_ruby_finds_methods():
    r = extract_ruby(FIXTURES / "sample.rb")
    labels = _labels(r)
    assert any("get" in l for l in labels)
    assert any("post" in l for l in labels)

def test_ruby_finds_function():
    r = extract_ruby(FIXTURES / "sample.rb")
    assert any("parse_response" in l for l in _labels(r))


def test_ruby_inherits_edge():
    """`class Sub < Base` must emit an inherits edge.

    Ruby exposes the base class in the `superclass` field, but there was no
    Ruby branch in the inheritance handler, so the edge was silently dropped.
    """
    r = extract_ruby(FIXTURES / "sample.rb")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "TimeoutApiClient" in node_by_id.get(e["source"], "")
        and node_by_id.get(e["target"], "") == "ApiClient"
        for e in r["edges"] if e["relation"] == "inherits"
    )
    assert found, "TimeoutApiClient should have inherits edge to ApiClient"


# ── C# ───────────────────────────────────────────────────────────────────────

def test_csharp_no_error():
    r = extract_csharp(FIXTURES / "sample.cs")
    assert "error" not in r

def test_csharp_finds_class():
    r = extract_csharp(FIXTURES / "sample.cs")
    assert any("DataProcessor" in l for l in _labels(r))

def test_csharp_finds_interface():
    r = extract_csharp(FIXTURES / "sample.cs")
    assert any("IProcessor" in l for l in _labels(r))

def test_csharp_finds_methods():
    r = extract_csharp(FIXTURES / "sample.cs")
    labels = _labels(r)
    assert any("Process" in l for l in labels)

def test_csharp_finds_usings():
    r = extract_csharp(FIXTURES / "sample.cs")
    assert "imports" in _relations(r)

def test_csharp_inherits_edge():
    r = extract_csharp(FIXTURES / "sample.cs")
    inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
    assert len(inherits) >= 1

def test_csharp_implements_iprocessor():
    r = extract_csharp(FIXTURES / "sample.cs")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "DataProcessor" in node_by_id.get(e["source"], "") and
        "IProcessor" in node_by_id.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "implements"
    )
    assert found, "DataProcessor should have implements edge to IProcessor"


def test_csharp_splits_inherits_and_implements_edges():
    result = extract_csharp(FIXTURES / "sample.cs")
    assert ("DataProcessor", "Processor") in _edge_labels(result, "inherits")
    assert ("DataProcessor", "IProcessor") in _edge_labels(result, "implements")


def test_csharp_parameter_return_and_generic_contexts():
    result = extract_csharp(FIXTURES / "sample.cs")
    assert ("Build", "HttpClient") in _edge_labels(result, "references", "parameter_type")
    assert ("Build", "Result") in _edge_labels(result, "references", "return_type")
    assert ("Build", "DataProcessor") in _edge_labels(result, "references", "generic_arg")


def test_java_normalizes_inherits_and_implements():
    result = extract_java(FIXTURES / "sample.java")
    assert ("DataProcessor", "BaseProcessor") in _edge_labels(result, "inherits")
    assert ("DataProcessor", "Processor") in _edge_labels(result, "implements")


def test_java_generic_parents_include_type_argument_references(tmp_path):
    source = tmp_path / "GenericParents.java"
    source.write_text(
        "class Dependency {}\n"
        "interface Event {}\n"
        "class Base<T> {}\n"
        "interface Handler<T> {}\n"
        "interface DerivedHandler extends Handler<Event> {}\n"
        "class Service extends Base<Dependency> implements Handler<Event> {}\n"
    )

    result = extract_java(source)

    assert ("Service", "Base") in _edge_labels(result, "inherits")
    assert ("Service", "Handler") in _edge_labels(result, "implements")
    refs = _edge_labels(result, "references", "generic_arg")
    assert ("Service", "Dependency") in refs
    assert ("Service", "Event") in refs
    assert ("DerivedHandler", "Handler") in _edge_labels(result, "inherits")
    assert ("DerivedHandler", "Event") in refs


def test_java_type_parameters_do_not_emit_references(tmp_path):
    source = tmp_path / "TypeParameters.java"
    source.write_text(
        "class Payload {}\n"
        "class Base<X> {}\n"
        "class Box<T> extends Base<T> {\n"
        "    T value;\n"
        "    List<T> values;\n"
        "    <U> U convert(T input, List<U> mapped, List<Payload> retained) {\n"
        "        return null;\n"
        "    }\n"
        "    <V> Box(V value) {}\n"
        "}\n"
    )

    result = extract_java(source)

    references = _references(result)
    assert not [edge for _, target, edge in references if target in {"T", "U", "V"}]
    assert not [
        node
        for node in result["nodes"]
        if node.get("label") in {"T", "U", "V"} and not node.get("source_file")
    ]
    assert ("Box", "Base") in _edge_labels(result, "inherits")
    assert ("convert", "Payload") in _edge_labels(result, "references", "generic_arg")


def test_java_parameter_return_generic_and_attribute_contexts():
    result = extract_java(FIXTURES / "sample.java")
    assert ("build", "HttpClient") in _edge_labels(result, "references", "parameter_type")
    assert ("build", "Result") in _edge_labels(result, "references", "return_type")
    assert ("build", "DataProcessor") in _edge_labels(result, "references", "generic_arg")
    assert ("build", "Override") in _edge_labels(result, "references", "attribute")


def test_java_field_type_references_have_field_context(tmp_path):
    source = tmp_path / "Fields.java"
    source.write_text(
        "class PaymentGateway {}\n"
        "class Handler {}\n"
        "class CheckoutService {\n"
        "    PaymentGateway gateway;\n"
        "    List<Handler> handlers;\n"
        "}\n"
    )
    result = extract_java(source)
    assert ("CheckoutService", "PaymentGateway") in _edge_labels(
        result, "references", "field"
    )
    assert ("CheckoutService", "Handler") in _edge_labels(
        result, "references", "generic_arg"
    )


def test_java_record_component_type_references(tmp_path):
    source = tmp_path / "RecordComponents.java"
    source.write_text(
        "class Payload {}\n"
        "class Item {}\n"
        "class Attachment {}\n"
        "record Order(Payload payload, List<Item> items, int count, "
        "Attachment... attachments) {}\n"
    )

    result = extract_java(source)

    assert ("Order", "Payload") in _edge_labels(result, "references", "field")
    # `List` is a java.util library type: skipped as noise, so only its user-type
    # generic argument (`Item`) survives, not the container itself.
    assert ("Order", "List") not in _edge_labels(result, "references")
    assert ("Order", "Item") in _edge_labels(result, "references", "generic_arg")
    assert ("Order", "Attachment") in _edge_labels(result, "references", "field")


def test_java_record_components_skip_type_parameters(tmp_path):
    source = tmp_path / "GenericRecord.java"
    source.write_text(
        "class Payload {}\n"
        "class Box<X> {}\n"
        "record Batch<T>(T value, Box<T> boxed, Box<Payload> retained) {}\n"
    )

    result = extract_java(source)

    assert ("Batch", "T") not in _edge_labels(result, "references")
    assert not [
        node
        for node in result["nodes"]
        if node.get("label") == "T" and not node.get("source_file")
    ]
    assert ("Batch", "Box") in _edge_labels(result, "references", "field")
    assert ("Batch", "Payload") in _edge_labels(result, "references", "generic_arg")


def test_java_type_annotations_have_attribute_context(tmp_path):
    source = tmp_path / "TypeAnnotations.java"
    source.write_text(
        '@Service\n'
        '@Entity(name = "checkout")\n'
        'class CheckoutService {}\n'
    )

    result = extract_java(source)

    refs = _edge_labels(result, "references", "attribute")
    assert ("CheckoutService", "Service") in refs
    assert ("CheckoutService", "Entity") in refs


def test_java_enum_and_annotation_declarations_are_type_nodes(tmp_path):
    source = tmp_path / "TypeDeclarations.java"
    source.write_text(
        "enum PaymentStatus { PENDING, PAID }\n"
        "@interface Audited {}\n"
        "class Order { PaymentStatus status; }\n"
        "@Audited class CheckoutService {}\n"
    )

    result = extract_java(source)

    assert ("TypeDeclarations.java", "PaymentStatus") in _edge_labels(
        result, "contains"
    )
    assert ("TypeDeclarations.java", "Audited") in _edge_labels(result, "contains")
    assert ("Order", "PaymentStatus") in _edge_labels(
        result, "references", "field"
    )
    assert ("CheckoutService", "Audited") in _edge_labels(
        result, "references", "attribute"
    )
    definitions = {
        node["label"]: node
        for node in result["nodes"]
        if node.get("label") in {"PaymentStatus", "Audited"}
    }
    assert definitions["PaymentStatus"].get("source_file") == str(source)
    assert definitions["Audited"].get("source_file") == str(source)


def test_nested_types_contained_by_enclosing_type(tmp_path):
    """#2040: a nested class/object/trait's `contains` edge sources from its
    ENCLOSING type, not the file node; top-level types still source from the file
    (keeping the tree connected: file -> Outer -> Inner)."""
    # Java inner class
    j = tmp_path / "Outer.java"
    j.write_text("class Outer {\n  class Inner { void m() {} }\n}\n")
    cj = _edge_labels(extract_java(j), "contains")
    assert ("Outer.java", "Outer") in cj      # top-level still file-sourced
    assert ("Outer", "Inner") in cj           # nested sourced from enclosing type
    assert ("Outer.java", "Inner") not in cj  # NOT from the file (the bug)

    # Scala nested class + object (the issue's repro shape)
    s = tmp_path / "Outer.scala"
    s.write_text("class Outer {\n  class Inner\n  object Obj\n}\n")
    cs = _edge_labels(extract_scala(s), "contains")
    assert ("Outer.scala", "Outer") in cs
    assert ("Outer", "Inner") in cs
    assert ("Outer", "Obj") in cs
    assert ("Outer.scala", "Inner") not in cs


def test_csharp_nested_type_gets_containment_edge(tmp_path):
    """#2040 for C#: the nested type now gets a real `contains` edge from its
    enclosing type (the is_nested_type flag it already carried is retained and
    covered by test_csharp_type_resolution)."""
    c = tmp_path / "N.cs"
    c.write_text("namespace N {\n  class Outer {\n    class Inner {}\n  }\n}\n")
    cc = _edge_labels(extract_csharp(c), "contains")
    assert ("Outer", "Inner") in cc
    assert ("N.cs", "Inner") not in cc


def test_csharp_field_type_references_have_field_context():
    r = extract_csharp(FIXTURES / "sample.cs")
    refs = _references(r)
    assert any(
        "DataProcessor" in src and "HttpClient" in tgt and edge.get("context") == "field"
        for src, tgt, edge in refs
    ), "DataProcessor field declarations should reference HttpClient with field context"


def test_csharp_property_type_references_have_field_context():
    r = extract_csharp(FIXTURES / "sample.cs")
    field_refs = _edge_labels(r, "references", "field")
    # `public Processor Owner { get; set; }` — property type -> field ref.
    assert ("DataProcessor", "Processor") in field_refs
    # `public List<Processor> Workers { get; set; }` — the List container -> field.
    assert ("DataProcessor", "List") in field_refs
    # ...and the generic argument -> generic_arg.
    assert ("DataProcessor", "Processor") in _edge_labels(r, "references", "generic_arg")


def test_csharp_call_edges_have_call_context():
    r = extract_csharp(FIXTURES / "sample.cs")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    assert any(
        "Process" in node_by_id.get(e["source"], "")
        and "Validate" in node_by_id.get(e["target"], "")
        and e.get("context") == "call"
        for e in r["edges"] if e["relation"] == "calls"
    ), "C# call edges should retain call context"


def test_csharp_import_edges_have_import_context():
    r = extract_csharp(FIXTURES / "sample.cs")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


# ── Kotlin ───────────────────────────────────────────────────────────────────

def test_kotlin_no_error():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert "error" not in r

def test_kotlin_finds_class():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert any("HttpClient" in l for l in _labels(r))

def test_kotlin_finds_data_class():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert any("Config" in l for l in _labels(r))

def test_kotlin_finds_methods():
    r = extract_kotlin(FIXTURES / "sample.kt")
    labels = _labels(r)
    assert any("get" in l for l in labels)
    assert any("post" in l for l in labels)

def test_kotlin_finds_function():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert any("createClient" in l for l in _labels(r))

def test_kotlin_enum_entries_have_case_of_edge():
    # #1700 (Kotlin half): enum entries must be nodes with case_of edges to the enum.
    r = extract_kotlin(FIXTURES / "sample.kt")
    labels = _labels(r)
    assert "NORMAL" in labels and "GROUP" in labels and "SYSTEM" in labels
    assert ("ChatType", "NORMAL") in _edge_labels(r, "case_of")
    assert ("ChatType", "SYSTEM") in _edge_labels(r, "case_of")

def test_kotlin_emits_in_file_calls():
    """Regression test for the call-walker `simple_identifier` /
    `identifier` rename — see graphify-kmp's PythonParityTest."""
    r = extract_kotlin(FIXTURES / "sample.kt")
    calls = _calls(r)
    # In sample.kt: get() and post() both call buildRequest(), and
    # createClient() invokes Config and HttpClient (constructor calls).
    assert (".get()", ".buildRequest()") in calls
    assert (".post()", ".buildRequest()") in calls
    assert ("createClient()", "Config") in calls
    assert ("createClient()", "HttpClient") in calls


def test_kotlin_splits_inherits_and_implements():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
    assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")


def test_kotlin_interface_delegation_emits_implements():
    """`class Foo : Bar by baz` wraps the delegated interface in an
    `explicit_delegation` node — it must still emit an implements edge."""
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert ("LoggingList", "MutableList") in _edge_labels(r, "implements")


def test_kotlin_parameter_return_generic_and_field_contexts():
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
    assert ("run", "Result") in _edge_labels(r, "references", "return_type")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
    assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")

def test_kotlin_builtin_types_not_emitted_as_references():
    # kotlin.* scalar/collection/core types used as parameter, return, or field
    # types carry no useful graph meaning: they never resolve to a project node,
    # so emitting `references` edges to them is pure noise (mirrors the Java
    # _JAVA_BUILTIN_TYPES / Python _PYTHON_ANNOTATION_NOISE handling).
    r = extract_kotlin(FIXTURES / "sample.kt")
    ref_targets = {target for (_, target) in _edge_labels(r, "references")}
    for builtin in ("String", "Int"):
        assert builtin not in ref_targets, (
            f"builtin type {builtin!r} should not be a references target"
        )

def test_kotlin_user_types_still_emit_references():
    # Guard against over-filtering: a user-defined class sharing its name with a
    # common domain-modeling identifier (Result) must still resolve to a real
    # edge - the builtin filter is a fixed name list, so it must stay narrow
    # enough not to swallow common user-chosen names like a sealed-class "Result".
    r = extract_kotlin(FIXTURES / "sample.kt")
    assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")


# ── Scala ─────────────────────────────────────────────────────────────────────

def test_scala_no_error():
    r = extract_scala(FIXTURES / "sample.scala")
    assert "error" not in r

def test_scala_finds_class():
    r = extract_scala(FIXTURES / "sample.scala")
    assert any("HttpClient" in l for l in _labels(r))

def test_scala_finds_object():
    r = extract_scala(FIXTURES / "sample.scala")
    assert any("HttpClientFactory" in l for l in _labels(r))

def test_scala_finds_methods():
    r = extract_scala(FIXTURES / "sample.scala")
    labels = _labels(r)
    assert any("get" in l for l in labels)
    assert any("post" in l for l in labels)


def test_scala_import_edges_have_import_context():
    r = extract_scala(FIXTURES / "sample.scala")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_scala_splits_inherits_and_mixes_in():
    r = extract_scala(FIXTURES / "sample.scala")
    assert ("HttpClient", "BaseClient") in _edge_labels(r, "inherits")
    assert ("HttpClient", "Loggable") in _edge_labels(r, "mixes_in")


def test_scala_constructor_parameter_field_context():
    r = extract_scala(FIXTURES / "sample.scala")
    assert ("HttpClient", "Config") in _edge_labels(r, "references", "field")


def test_scala_val_definition_field_context():
    r = extract_scala(FIXTURES / "sample.scala")
    assert ("HttpClient", "Config") in _edge_labels(r, "references", "field")


def test_scala_var_definition_field_context():
    r = extract_scala(FIXTURES / "sample.scala")
    assert ("HttpClient", "BaseClient") in _edge_labels(r, "references", "field")


def test_scala_method_return_type_context():
    r = extract_scala(FIXTURES / "sample.scala")
    assert ("create", "HttpClient") in _edge_labels(r, "references", "return_type")


def test_scala_call_edges_have_call_context():
    r = extract_scala(FIXTURES / "sample.scala")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)


# ── PHP ───────────────────────────────────────────────────────────────────────

def test_php_no_error():
    r = extract_php(FIXTURES / "sample.php")
    assert "error" not in r

def test_php_finds_class():
    r = extract_php(FIXTURES / "sample.php")
    assert any("ApiClient" in l for l in _labels(r))

def test_php_finds_methods():
    r = extract_php(FIXTURES / "sample.php")
    labels = _labels(r)
    assert any("get" in l for l in labels)
    assert any("post" in l for l in labels)

def test_php_finds_function():
    r = extract_php(FIXTURES / "sample.php")
    assert any("parseResponse" in l for l in _labels(r))

def test_php_finds_imports():
    r = extract_php(FIXTURES / "sample.php")
    assert "imports" in _relations(r)


def test_php_import_edges_have_import_context():
    r = extract_php(FIXTURES / "sample.php")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_php_call_edges_have_call_context():
    r = extract_php(FIXTURES / "sample.php")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)

def test_php_finds_static_property_access():
    r = extract_php(FIXTURES / "sample_php_static_prop.php")
    assert "uses_static_prop" in _relations(r)

def test_php_static_prop_target_is_holding_class():
    r = extract_php(FIXTURES / "sample_php_static_prop.php")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    uses_prop = [
        (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
        for e in r["edges"] if e["relation"] == "uses_static_prop"
    ]
    assert any("DefaultPalette" in tgt for _, tgt in uses_prop)

def test_php_finds_config_helper_call():
    r = extract_php(FIXTURES / "sample_php_config.php")
    assert "uses_config" in _relations(r)

def test_php_config_helper_target_matches_first_segment():
    r = extract_php(FIXTURES / "sample_php_config.php")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    uses_cfg = [
        (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
        for e in r["edges"] if e["relation"] == "uses_config"
    ]
    assert any("Throttle" in tgt for _, tgt in uses_cfg)

def test_php_finds_container_bind():
    r = extract_php(FIXTURES / "sample_php_container.php")
    assert "bound_to" in _relations(r)

def test_php_container_bind_links_contract_to_implementation():
    r = extract_php(FIXTURES / "sample_php_container.php")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    bound = [
        (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
        for e in r["edges"] if e["relation"] == "bound_to"
    ]
    assert any("PaymentGateway" in src and "StripeGateway" in tgt for src, tgt in bound)

def test_php_finds_event_listeners():
    r = extract_php(FIXTURES / "sample_php_listen.php")
    assert "listened_by" in _relations(r)

def test_php_event_listener_links_event_to_listener():
    r = extract_php(FIXTURES / "sample_php_listen.php")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    listened = [
        (node_by_id.get(e["source"], e["source"]), node_by_id.get(e["target"], e["target"]))
        for e in r["edges"] if e["relation"] == "listened_by"
    ]
    assert any("UserRegistered" in src and "SendWelcomeEmail" in tgt for src, tgt in listened)


def test_php_splits_inherits_implements_mixes_in():
    r = extract_php(FIXTURES / "sample.php")
    assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
    assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
    assert ("DataProcessor", "HasName") in _edge_labels(r, "mixes_in")


def test_php_property_parameter_and_return_contexts():
    r = extract_php(FIXTURES / "sample.php")
    assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
    assert ("run", "Result") in _edge_labels(r, "references", "return_type")


def test_php_constructor_property_promotion_contexts():
    # PHP 8 constructor property promotion: a promoted param is both a
    # constructor parameter (parameter_type) and a class field (field).
    r = extract_php(FIXTURES / "sample.php")
    assert ("Service", "Result") in _edge_labels(r, "references", "field")
    assert ("__construct", "Result") in _edge_labels(r, "references", "parameter_type")
    # A non-promoted param must not leak a field edge onto the class.
    assert ("Service", "string") not in _edge_labels(r, "references", "field")


# ── Swift ────────────────────────────────────────────────────────────────────

def test_swift_no_error():
    r = extract_swift(FIXTURES / "sample.swift")
    assert "error" not in r

def test_swift_finds_class():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("DataProcessor" in l for l in _labels(r))

def test_swift_finds_protocol():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("Processor" in l for l in _labels(r))

def test_swift_finds_struct():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("Config" in l for l in _labels(r))

def test_swift_finds_methods():
    r = extract_swift(FIXTURES / "sample.swift")
    labels = _labels(r)
    assert any("addItem" in l for l in labels)
    assert any("process" in l for l in labels)

def test_swift_finds_function():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("createProcessor" in l for l in _labels(r))

def test_swift_finds_imports():
    r = extract_swift(FIXTURES / "sample.swift")
    assert "imports" in _relations(r)


def test_swift_import_edges_have_import_context():
    r = extract_swift(FIXTURES / "sample.swift")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)

def test_swift_no_dangling_edges():
    r = extract_swift(FIXTURES / "sample.swift")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids
        # #1327: targets must resolve to a node too, else build.py prunes the edge.
        assert e["target"] in node_ids, f"dangling target {e['target']} ({e['relation']})"


def test_swift_imports_survive_build():
    # #1327: `import Foundation` / `import UIKit` previously emitted edges to bare
    # module ids with no backing node, so build.py dropped 100% of Swift imports.
    from graphify.build import build_from_json
    r = extract_swift(FIXTURES / "sample.swift")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert import_edges, "extractor should emit Swift import edges"
    node_ids = {n["id"] for n in r["nodes"]}
    for e in import_edges:
        assert e["target"] in node_ids  # synthesized module node exists
    # Imported modules are tagged type=module (anchor nodes, #1327/#1330).
    module_labels = {n["label"] for n in r["nodes"] if n.get("type") == "module"}
    assert {"Foundation", "UIKit"} <= module_labels
    # No private bookkeeping key should leak into output edges.
    assert all("_import_label" not in e for e in r["edges"])
    # Edges must survive the build (which prunes edges with unknown endpoints).
    G = build_from_json(r)
    surviving = [
        (u, v) for u, v, d in G.edges(data=True) if d.get("relation") == "imports"
    ]
    assert surviving, "Swift import edges must survive build_from_json (#1327)"

def test_swift_finds_actor():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("CacheManager" in l for l in _labels(r))

def test_swift_finds_enum():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("NetworkError" in l for l in _labels(r))

def test_swift_finds_enum_methods():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("describe" in l for l in _labels(r))

def test_swift_finds_enum_cases():
    r = extract_swift(FIXTURES / "sample.swift")
    labels = _labels(r)
    assert any("timeout" in l for l in labels)
    assert any("connectionFailed" in l for l in labels)

def test_swift_enum_cases_have_case_of_edge():
    r = extract_swift(FIXTURES / "sample.swift")
    case_edges = [e for e in r["edges"] if e["relation"] == "case_of"]
    assert len(case_edges) >= 2

def test_swift_enum_associated_value_type_emits_references():
    r = extract_swift(FIXTURES / "sample.swift")
    assert ("NetworkError", "Config") in _edge_labels(r, "references", "type")

def test_swift_finds_deinit():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("deinit" in l for l in _labels(r))

def test_swift_finds_subscript():
    r = extract_swift(FIXTURES / "sample.swift")
    assert any("subscript" in l for l in _labels(r))

def test_swift_extension_methods_attach_to_type():
    r = extract_swift(FIXTURES / "sample.swift")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    method_edges = [e for e in r["edges"] if e["relation"] == "method"]
    found = False
    for e in method_edges:
        src_label = node_by_id.get(e["source"], "")
        tgt_label = node_by_id.get(e["target"], "")
        if "Config" in src_label and "isValid" in tgt_label:
            found = True
            break
    assert found, "extension method isValid should attach to Config"

def test_swift_extension_does_not_duplicate_type_node():
    r = extract_swift(FIXTURES / "sample.swift")
    config_nodes = [n for n in r["nodes"] if n["label"] == "Config"]
    assert len(config_nodes) == 1, f"Config should appear once, got {len(config_nodes)}"

def test_swift_protocol_conformance_emits_implements():
    r = extract_swift(FIXTURES / "sample.swift")
    assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")


def test_swift_extension_conformance_emits_implements():
    r = extract_swift(FIXTURES / "sample.swift")
    assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")


def test_swift_splits_inherits_and_implements():
    r = extract_swift(FIXTURES / "sample.swift")
    assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
    assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")


def test_swift_parameter_return_generic_and_field_contexts():
    r = extract_swift(FIXTURES / "sample.swift")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
    assert ("run", "Result") in _edge_labels(r, "references", "return_type")
    assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
    assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")

def test_swift_emits_calls():
    r = extract_swift(FIXTURES / "sample.swift")
    calls = _calls(r)
    assert any("process" in src and "validate" in tgt for src, tgt in calls)

def test_swift_call_edges_have_call_context():
    r = extract_swift(FIXTURES / "sample.swift")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)


def test_swift_extension_across_files_merges_into_canonical_type():
    """`extension Foo` in a separate file from `class Foo` must resolve to a
    single Foo node. tree-sitter-swift parses both as `class_declaration` and
    node ids carry the file stem, so without a corpus-level merge each file
    would emit its own Foo."""
    from graphify.extract import extract
    paths = sorted((FIXTURES / "swift_cross_file").glob("*.swift"))
    r = extract(paths, cache_root=Path("/tmp/graphify-test-no-cache"))
    foo_nodes = [n for n in r["nodes"] if n["label"] == "Foo"]
    assert len(foo_nodes) == 1, f"Foo should appear once, got {len(foo_nodes)}: {[n['id'] for n in foo_nodes]}"
    foo_id = foo_nodes[0]["id"]
    method_targets = {
        e["target"] for e in r["edges"]
        if e["relation"] == "method" and e["source"] == foo_id
    }
    method_labels = {n["label"] for n in r["nodes"] if n["id"] in method_targets}
    assert any("one" in l for l in method_labels), f"one() should attach to Foo, got {method_labels}"
    assert any("two" in l for l in method_labels), f"extension method two() should attach to Foo, got {method_labels}"


# ── Elixir ────────────────────────────────────────────────────────────────────

from graphify.extract import extract_elixir

def test_elixir_finds_module():
    r = extract_elixir(FIXTURES / "sample.ex")
    assert "error" not in r
    labels = [n["label"] for n in r["nodes"]]
    assert any("MyApp.Accounts.User" in l for l in labels)

def test_elixir_finds_functions():
    r = extract_elixir(FIXTURES / "sample.ex")
    labels = [n["label"] for n in r["nodes"]]
    assert any("create" in l for l in labels)
    assert any("find" in l for l in labels)
    assert any("validate" in l for l in labels)

def test_elixir_finds_imports():
    r = extract_elixir(FIXTURES / "sample.ex")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert len(import_edges) >= 2


def test_elixir_import_edges_have_import_context():
    r = extract_elixir(FIXTURES / "sample.ex")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_elixir_multi_alias_expands():
    """`alias Foo.{Bar, Baz}` must emit one imports edge per expanded module.

    The brace form is a `dot` node with a trailing `tuple`; the single-alias
    handler only matched a bare `alias` child, so every multi-alias import was
    silently dropped.
    """
    r = extract_elixir(FIXTURES / "sample.ex")
    import_segs = [
        e["target"].rsplit("_", 1)[-1]
        for e in r["edges"] if e["relation"] == "imports"
    ]
    # from `alias MyApp.Schemas.{Account, Token}`
    assert "account" in import_segs, "MyApp.Schemas.Account import missing"
    assert "token" in import_segs, "MyApp.Schemas.Token import missing"

def test_elixir_finds_calls():
    r = extract_elixir(FIXTURES / "sample.ex")
    calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
    labels = {n["id"]: n["label"] for n in r["nodes"]}
    assert any("create" in labels.get(src, "") and "validate" in labels.get(tgt, "") for src, tgt in calls)


def test_elixir_call_edges_have_call_context():
    r = extract_elixir(FIXTURES / "sample.ex")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)

def test_elixir_method_edges():
    r = extract_elixir(FIXTURES / "sample.ex")
    methods = [e for e in r["edges"] if e["relation"] == "method"]
    assert len(methods) >= 3


# ── Objective-C ──────────────────────────────────────────────────────────────
from graphify.extract import extract_objc


def test_objc_finds_interface():
    r = extract_objc(FIXTURES / "sample.m")
    labels = [n["label"] for n in r["nodes"]]
    assert "Animal" in labels


def test_objc_finds_subclass():
    r = extract_objc(FIXTURES / "sample.m")
    labels = [n["label"] for n in r["nodes"]]
    assert "Dog" in labels


def test_objc_finds_methods():
    r = extract_objc(FIXTURES / "sample.m")
    labels = [n["label"] for n in r["nodes"]]
    assert any("speak" in l or "fetch" in l or "initWithName" in l for l in labels)


def test_objc_finds_imports():
    r = extract_objc(FIXTURES / "sample.m")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert len(import_edges) >= 1


def test_objc_import_edges_have_import_context():
    r = extract_objc(FIXTURES / "sample.m")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_objc_inherits_edge():
    r = extract_objc(FIXTURES / "sample.m")
    inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
    assert len(inherits) >= 1


def test_objc_splits_inherits_and_implements():
    r = extract_objc(FIXTURES / "sample.m")
    assert ("Animal", "NSObject") in _edge_labels(r, "inherits")
    assert ("Dog", "Animal") in _edge_labels(r, "inherits")
    assert ("Animal", "SampleDelegate") in _edge_labels(r, "implements")


def test_objc_protocol_adopts_protocol():
    """`@protocol Derived <Base>` must emit an implements edge Derived->Base.
    Protocol-on-protocol adoption nests under a protocol_reference_list node
    (distinct from the parameterized_arguments node used by @interface
    adoption), so the edge was previously dropped. Protocol nodes are labeled
    `<Name>`, so the edge reads (<Derived>, <Base>)."""
    r = extract_objc(FIXTURES / "sample.m")
    assert ("<Derived>", "<Base>") in _edge_labels(r, "implements")


def test_objc_property_type_context():
    r = extract_objc(FIXTURES / "sample.m")
    assert ("Animal", "NSString") in _edge_labels(r, "references", "field")


def test_objc_no_dangling_edges():
    r = extract_objc(FIXTURES / "sample.m")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"Dangling source: {e}"


def test_objc_resolves_self_method_calls():
    """`[self speak]` inside Dog.fetch must produce a calls edge. The method-body
    second pass was dead code for ObjC because the grammar emits a simple selector
    as `identifier`, not `selector`/`keyword_argument_list` (#1475)."""
    r = extract_objc(FIXTURES / "sample.m")
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    calls = [nid2label.get(e["target"]) for e in r["edges"] if e["relation"] == "calls"]
    assert any(t and "speak" in t for t in calls), calls


def test_objc_class_method_labeled_with_plus(tmp_path):
    """`+ (…)shared` is a class method and must be labeled +shared, not -shared (#1475)."""
    p = tmp_path / "S.m"
    p.write_text("@implementation S\n+ (instancetype)shared { return nil; }\n- (void)go { }\n@end\n")
    labels = {n["label"] for n in extract_objc(p)["nodes"]}
    assert "+shared" in labels and "-go" in labels


def test_objc_compound_selector_call_resolves(tmp_path):
    """A compound message `[self a:x b:y]` resolves to the compound method def (#1475)."""
    p = tmp_path / "V.m"
    p.write_text(
        "@implementation V\n"
        "- (void)tableView:(id)tv numberOfRowsInSection:(int)s { }\n"
        "- (void)go { [self tableView:nil numberOfRowsInSection:0]; }\n"
        "@end\n"
    )
    r = extract_objc(p)
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    calls = [nid2label.get(e["target"]) for e in r["edges"] if e["relation"] == "calls"]
    assert any(t and "tableViewnumberOfRowsInSection" in t for t in calls), calls


def test_objc_generic_property_type_extracted(tmp_path):
    """`NSArray<Product *> *` must reference the element type Product (and the
    container NSArray); the generic wrapper made the type invisible before (#1475)."""
    p = tmp_path / "M.h"
    p.write_text("@interface M : NSObject\n@property (strong) NSArray<Product *> *items;\n@end\n")
    refs = _edge_labels(extract_objc(p), "references", "field")
    assert ("M", "Product") in refs
    assert ("M", "NSArray") in refs


def test_objc_module_import_edge(tmp_path):
    """`@import Foundation;` / `@import UIKit.UIView;` produce imports edges (#1475)."""
    from graphify.extract import _make_id
    p = tmp_path / "X.m"
    p.write_text("@import Foundation;\n@import UIKit.UIView;\n@implementation X\n@end\n")
    targets = {e["target"] for e in extract_objc(p)["edges"] if e["relation"] == "imports"}
    assert _make_id("Foundation") in targets and _make_id("UIKit") in targets


def test_objc_header_dispatch_routes_objc_not_c(tmp_path):
    """An ObjC `.h` (has @interface) routes to extract_objc; a plain C `.h` stays
    on extract_c, so C/C++ headers are never hijacked by the sniff (#1475)."""
    from graphify.extract import _get_extractor, extract_objc as _eo, extract_c as _ec
    objc_h = tmp_path / "AppDelegate.h"
    objc_h.write_text("@interface AppDelegate : NSObject <UIApplicationDelegate>\n@end\n")
    c_h = tmp_path / "util.h"
    c_h.write_text("#include <stdio.h>\nint add(int a, int b);\nstruct Point { int x; };\n")
    assert _get_extractor(objc_h) is _eo
    assert _get_extractor(c_h) is _ec


def test_objc_ns_assume_nonnull_macro_does_not_break_parsing(tmp_path):
    """`NS_ASSUME_NONNULL_BEGIN` before `@interface` made tree-sitter-objc fail to
    emit a class_interface node, swallowing the whole interface; blanking the
    argument-less macro restores it (#1475)."""
    p = tmp_path / "AlertManager.h"
    p.write_text(
        "#import <Foundation/Foundation.h>\n"
        "NS_ASSUME_NONNULL_BEGIN\n"
        "@class Other;\n"
        "@interface AlertManager : NSObject\n"
        "- (void)show;\n"
        "@end\n"
        "NS_ASSUME_NONNULL_END\n"
    )
    r = extract_objc(p)
    labels = {n["label"] for n in r["nodes"]}
    assert "AlertManager" in labels
    assert ("AlertManager", "NSObject") in _edge_labels(r, "inherits")
    # `@class Other;` is only a forward declaration; it must not mint a class node.
    assert "Other" not in labels


def test_objc_macro_free_header_unchanged(tmp_path):
    """A macro-free header still parses exactly as before (regression)."""
    p = tmp_path / "Plain.h"
    p.write_text(
        "@interface Plain : NSObject\n"
        "- (void)go;\n"
        "@end\n"
    )
    r = extract_objc(p)
    labels = {n["label"] for n in r["nodes"]}
    assert "Plain" in labels
    assert ("Plain", "NSObject") in _edge_labels(r, "inherits")


def test_objc_quoted_import_edges_resolve_to_real_nodes(tmp_path):
    """Quoted `#import "X.h"` edges must target the real (disambiguated) file node id,
    not the bare stem, which gets salted away when a `.h`/`.m` pair exists and left
    the import edge dangling (#1475)."""
    from graphify.extract import extract
    (tmp_path / "Product.h").write_text("@interface Product : NSObject\n@end\n")
    (tmp_path / "Product.m").write_text("#import \"Product.h\"\n@implementation Product\n@end\n")
    (tmp_path / "Order.h").write_text("@interface Order : NSObject\n@end\n")
    (tmp_path / "Order.m").write_text("#import \"Order.h\"\n@implementation Order\n@end\n")
    consumer_a = tmp_path / "ConsumerA.m"
    consumer_a.write_text("#import \"Product.h\"\n@implementation ConsumerA\n@end\n")
    consumer_b = tmp_path / "ConsumerB.m"
    consumer_b.write_text("#import \"Order.h\"\n@implementation ConsumerB\n@end\n")
    files = [
        tmp_path / "Product.h", tmp_path / "Product.m",
        tmp_path / "Order.h", tmp_path / "Order.m",
        consumer_a, consumer_b,
    ]
    r = extract(files, parallel=False)
    node_ids = {n["id"] for n in r["nodes"]}
    id_to_label = {n["id"]: n.get("label", "") for n in r["nodes"]}
    import_edges = [e for e in r["edges"] if e["relation"] in ("imports", "imports_from")]
    assert import_edges
    for e in import_edges:
        # No dangling targets...
        assert e["target"] in node_ids, f"dangling import target: {e}"
        # ...and no self-loops: a `.m` importing its own `.h` must resolve to the
        # header file node, not get salted back to the importing `.m` (#1475).
        assert e["source"] != e["target"], f"self-loop import edge: {e}"
        # every quoted import targets a header (.h) file node
        assert str(id_to_label.get(e["target"], "")).endswith(".h"), (
            f"import target is not a header file node: {e} -> {id_to_label.get(e['target'])}"
        )
    # the self-import (Product.m -> Product.h) specifically lands on the .h variant
    prod_imports = [e for e in import_edges if id_to_label.get(e["source"], "").endswith("Product.m")]
    assert prod_imports and all(id_to_label.get(e["target"]) == "Product.h" for e in prod_imports), (
        f"Product.m should import the Product.h node, got {[(id_to_label.get(e['source']), id_to_label.get(e['target'])) for e in prod_imports]}"
    )


def test_objc_alloc_init_emits_type_reference(tmp_path):
    """`[[Foo alloc] init]` must emit a `references` edge to the project class Foo (#1475)."""
    from graphify.extract import extract
    (tmp_path / "Foo.h").write_text("@interface Foo : NSObject\n@end\n")
    (tmp_path / "Foo.m").write_text("#import \"Foo.h\"\n@implementation Foo\n@end\n")
    user = tmp_path / "User.m"
    user.write_text(
        "#import \"Foo.h\"\n"
        "@implementation User\n"
        "- (void)build { Foo *x = [[Foo alloc] init]; }\n"
        "@end\n"
    )
    r = extract([tmp_path / "Foo.h", tmp_path / "Foo.m", user], parallel=False)
    assert ("-build", "Foo") in _edge_labels(r, "references")


def test_objc_alloc_init_unknown_class_no_resolved_edge(tmp_path):
    """`[[Unknown alloc] init]` with no such class must not produce a resolved
    reference edge (the sourceless stub is collapsed only when a real class exists)."""
    p = tmp_path / "Caller.m"
    p.write_text(
        "@implementation Caller\n"
        "- (void)build { id x = [[Unknown alloc] init]; }\n"
        "- (void)other { [self build]; [x doStuff]; }\n"
        "@end\n"
    )
    r = extract_objc(p)
    # The single-file extractor emits the edge to a sourceless stub; assert there is
    # no resolved reference to a *real* (sourced) Unknown node and that ordinary
    # selector sends ([self build] / [x doStuff]) produce no alloc reference.
    sourced_ids = {n["id"] for n in r["nodes"] if n.get("source_file")}
    refs = [e for e in r["edges"] if e["relation"] == "references"]
    for e in refs:
        assert e["target"] not in sourced_ids, f"unexpected resolved ref: {e}"


def test_objc_dot_syntax_property_accesses_edge(tmp_path):
    """self.name dot-syntax resolves to an accesses edge within the same class."""
    p = tmp_path / "Dog.m"
    p.write_text(
        "@implementation Dog\n"
        "- (NSString *)name { return @\"Rex\"; }\n"
        "- (void)greet { NSLog(@\"%@\", self.name); }\n"
        "@end\n"
    )
    r = extract_objc(p)
    accesses = [(e["source"], e["target"]) for e in r["edges"]
                if e["relation"] == "accesses"]
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    assert len(accesses) == 1
    assert nid2label[accesses[0][1]] == "-name"


def test_objc_dot_syntax_no_fanout_two_same_named_properties(tmp_path):
    """Two classes each declaring -name: self.name in A must NOT fan out to B's -name."""
    p = tmp_path / "AB.m"
    p.write_text(
        "@implementation A\n"
        "- (NSString *)name { return @\"A\"; }\n"
        "- (void)show { NSLog(@\"%@\", self.name); }\n"
        "@end\n"
        "@implementation B\n"
        "- (NSString *)name { return @\"B\"; }\n"
        "- (void)show { NSLog(@\"%@\", self.name); }\n"
        "@end\n"
    )
    r = extract_objc(p)
    accesses = [e for e in r["edges"] if e["relation"] == "accesses"]
    assert len(accesses) == 2, f"expected 2 scoped accesses, got {len(accesses)}: {accesses}"
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    for e in accesses:
        src_label = nid2label[e["source"]]
        tgt_label = nid2label[e["target"]]
        assert src_label == "-show" and tgt_label == "-name"


def test_objc_dot_syntax_unresolvable_property_zero_edges(tmp_path):
    """Accessing a property not defined in the current class produces zero accesses edges."""
    p = tmp_path / "X.m"
    p.write_text(
        "@implementation X\n"
        "- (void)run { NSLog(@\"%@\", self.missing); }\n"
        "@end\n"
    )
    r = extract_objc(p)
    accesses = [e for e in r["edges"] if e["relation"] == "accesses"]
    assert len(accesses) == 0


def test_objc_selector_expression_calls_edge(tmp_path):
    """@selector(uniqueMethod) with exactly one match produces a calls edge."""
    p = tmp_path / "Sched.m"
    p.write_text(
        "@implementation Sched\n"
        "- (void)fetch { }\n"
        "- (void)schedule { [self performSelector:@selector(fetch)]; }\n"
        "@end\n"
    )
    r = extract_objc(p)
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
                 for e in r["edges"]
                 if e["relation"] == "calls" and e.get("context") == "call"]
    assert ("-schedule", "-fetch") in sel_calls


def test_objc_selector_no_fanout_two_same_named_methods(tmp_path):
    """@selector(doThing) with two doThing methods must emit zero calls edges."""
    p = tmp_path / "Dual.m"
    p.write_text(
        "@implementation A\n"
        "- (void)doThing { }\n"
        "- (void)run { [self performSelector:@selector(doThing)]; }\n"
        "@end\n"
        "@implementation B\n"
        "- (void)doThing { }\n"
        "@end\n"
    )
    r = extract_objc(p)
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    sel_edges = [e for e in r["edges"]
                 if e["relation"] == "calls"
                 and nid2label.get(e["target"], "").endswith("doThing")]
    assert len(sel_edges) == 0, f"expected 0 selector edges with ambiguous name, got {sel_edges}"


def test_objc_dot_syntax_substring_sibling_exact_match(tmp_path):
    """A substring-colliding sibling must neither be falsely matched nor suppress
    the real match: `self.name` with both `-name` and `-surname` present resolves
    to `-name` ONLY (exact id, not a `endswith` suffix) (#1475)."""
    p = tmp_path / "Person.m"
    p.write_text(
        "@implementation Person\n"
        "- (NSString *)name { return @\"n\"; }\n"
        "- (NSString *)surname { return @\"s\"; }\n"
        "- (void)show { NSLog(@\"%@\", self.name); }\n"
        "@end\n"
    )
    r = extract_objc(p)
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    accesses = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
                for e in r["edges"] if e["relation"] == "accesses"]
    assert ("-show", "-name") in accesses, f"self.name must resolve to -name: {accesses}"
    assert ("-show", "-surname") not in accesses, f"self.name must NOT match -surname: {accesses}"


def test_objc_selector_substring_method_exact_match(tmp_path):
    """@selector(doThing) must resolve to `-doThing` exactly, not be suppressed by
    a substring-colliding `-reallyDoThing` (exact match, not suffix) (#1475)."""
    p = tmp_path / "Worker.m"
    p.write_text(
        "@implementation Worker\n"
        "- (void)doThing { }\n"
        "- (void)reallyDoThing { }\n"
        "- (void)run { [self performSelector:@selector(doThing)]; }\n"
        "@end\n"
    )
    r = extract_objc(p)
    nid2label = {n["id"]: n["label"] for n in r["nodes"]}
    sel_calls = [(nid2label.get(e["source"]), nid2label.get(e["target"]))
                 for e in r["edges"]
                 if e["relation"] == "calls" and e.get("context") == "call"]
    assert ("-run", "-doThing") in sel_calls, f"@selector(doThing) must resolve to -doThing: {sel_calls}"
    assert ("-run", "-reallyDoThing") not in sel_calls


# ---------------------------------------------------------------------------
# Go
# ---------------------------------------------------------------------------

def test_go_receiver_methods_share_type_node():
    """Methods on the same receiver type must share one canonical type node."""
    r = extract_go(FIXTURES / "sample.go")
    server_nodes = [n for n in r["nodes"] if n["label"] == "Server"]
    # Both Start() and Stop() are on *Server — should produce exactly one Server node
    assert len(server_nodes) == 1

def test_go_receiver_uses_pkg_scope():
    """Type node id should be scoped to directory, not file stem."""
    r = extract_go(FIXTURES / "sample.go")
    server_nodes = [n for n in r["nodes"] if n["label"] == "Server"]
    assert server_nodes
    # Should NOT contain the file stem "sample" in the type node id
    assert "sample" not in server_nodes[0]["id"].split(":")[0]


# ---------------------------------------------------------------------------
# Julia
# ---------------------------------------------------------------------------

def test_julia_finds_module():
    r = extract_julia(FIXTURES / "sample.jl")
    labels = [n["label"] for n in r["nodes"]]
    assert "Geometry" in labels


def test_julia_finds_structs():
    r = extract_julia(FIXTURES / "sample.jl")
    labels = [n["label"] for n in r["nodes"]]
    assert "Point" in labels
    assert "Circle" in labels


def test_julia_finds_abstract_type():
    r = extract_julia(FIXTURES / "sample.jl")
    labels = [n["label"] for n in r["nodes"]]
    assert "Shape" in labels


def test_julia_finds_functions():
    r = extract_julia(FIXTURES / "sample.jl")
    labels = [n["label"] for n in r["nodes"]]
    assert any("area" in l for l in labels)
    assert any("distance" in l for l in labels)


def test_julia_finds_short_function():
    r = extract_julia(FIXTURES / "sample.jl")
    labels = [n["label"] for n in r["nodes"]]
    assert any("perimeter" in l for l in labels)


def test_julia_finds_imports():
    r = extract_julia(FIXTURES / "sample.jl")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert len(import_edges) >= 1


def test_julia_import_edges_have_import_context():
    r = extract_julia(FIXTURES / "sample.jl")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_julia_qualified_and_relative_imports():
    """Qualified (`using Base.Threads`) and relative (`using ..Mod`) imports
    must emit edges.

    The handler only matched bare identifiers, so scoped_identifier and
    import_path forms — and the scoped package of a selected_import — were
    silently dropped.
    """
    r = extract_julia(FIXTURES / "sample.jl")
    targets = [e["target"] for e in r["edges"] if e["relation"] == "imports"]
    assert any("base_threads" in t for t in targets), "qualified import Base.Threads missing"
    assert any("parentmodule" in t for t in targets), "relative import ParentModule missing"


def test_julia_finds_inherits():
    r = extract_julia(FIXTURES / "sample.jl")
    inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
    assert len(inherits) >= 1


def test_julia_abstract_concrete_hierarchy_inherits():
    r = extract_julia(FIXTURES / "sample.jl")
    assert ("Point", "Shape") in _edge_labels(r, "inherits")
    assert ("Circle", "Shape") in _edge_labels(r, "inherits")


def test_julia_struct_field_type_context():
    r = extract_julia(FIXTURES / "sample.jl")
    assert ("Point", "Float64") in _edge_labels(r, "references", "field")
    assert ("Circle", "Point") in _edge_labels(r, "references", "field")
    assert ("Circle", "Float64") in _edge_labels(r, "references", "field")


def test_julia_finds_calls():
    r = extract_julia(FIXTURES / "sample.jl")
    call_edges = [e for e in r["edges"] if e["relation"] == "calls"]
    assert len(call_edges) >= 1


def test_julia_call_edges_have_call_context():
    r = extract_julia(FIXTURES / "sample.jl")
    call_edges = _edges_with_relation(r, "calls")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)


def test_julia_no_dangling_edges():
    r = extract_julia(FIXTURES / "sample.jl")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"Dangling source: {e}"


# ── Fortran extractor ────────────────────────────────────────────────────────

def test_fortran_finds_module():
    r = extract_fortran(FIXTURES / "sample.f90")
    assert "error" not in r
    labels = [n["label"] for n in r["nodes"]]
    assert "geometry" in labels


def test_fortran_finds_subroutines():
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = [n["label"] for n in r["nodes"]]
    assert any("circle_area" in l for l in labels)
    assert any("print_area" in l for l in labels)


def test_fortran_finds_function():
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = [n["label"] for n in r["nodes"]]
    assert any("distance" in l for l in labels)


def test_fortran_finds_program():
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = [n["label"] for n in r["nodes"]]
    assert "main" in labels


def test_fortran_finds_use_imports():
    r = extract_fortran(FIXTURES / "sample.f90")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert len(import_edges) >= 2


def test_fortran_use_edges_have_use_context():
    r = extract_fortran(FIXTURES / "sample.f90")
    import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
    assert all(e.get("context") == "use" for e in import_edges)


def test_fortran_finds_calls():
    r = extract_fortran(FIXTURES / "sample.f90")
    call_edges = [e for e in r["edges"] if e["relation"] == "calls"]
    assert len(call_edges) >= 1


def test_fortran_finds_function_call():
    """`y = f(x)` function invocations must emit a calls edge.

    Function calls are `call_expression` (not `subroutine_call`); that node was
    never handled, so every function-to-function call was dropped. The callee is
    resolved against defined procedures so array indexing (`arr(i)`) can't
    fabricate a spurious edge.
    """
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "report" in labels.get(e["source"], "")
        and "double_val" in labels.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "calls"
    )
    assert found, "report() should have a calls edge to double_val()"


def test_fortran_case_insensitive_names():
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = [n["label"] for n in r["nodes"]]
    assert all(l == l.lower() or "(" in l for l in labels if l.endswith(("()", "")) and not "." in l)
    assert "geometry" in labels
    assert "main" in labels


def test_fortran_finds_derived_type():
    r = extract_fortran(FIXTURES / "sample.f90")
    labels = [n["label"] for n in r["nodes"]]
    assert "point" in labels


def test_fortran_parameter_and_return_type_contexts():
    r = extract_fortran(FIXTURES / "sample.f90")
    assert ("translate", "point") in _edge_labels(r, "references", "parameter_type")
    assert ("origin", "point") in _edge_labels(r, "references", "return_type")


def test_fortran_no_dangling_edges():
    r = extract_fortran(FIXTURES / "sample.f90")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"Dangling source: {e}"


def test_fortran_capital_F_parses_preprocessed():
    r = extract_fortran(FIXTURES / "sample_preprocessed.F90")
    assert "error" not in r
    labels = [n["label"] for n in r["nodes"]]
    assert "shapes" in labels
    assert any("compute_volume" in l for l in labels)


# ── PowerShell ───────────────────────────────────────────────────────────────

def test_powershell_no_error():
    r = extract_powershell(FIXTURES / "sample.ps1")
    assert "error" not in r


def test_powershell_psm1_dispatched_and_extracted(tmp_path):
    # #1315: .psm1 modules were never indexed — no dispatch entry, no CODE_EXTENSIONS.
    from graphify.extract import _get_extractor
    mod = tmp_path / "Utils.psm1"
    mod.write_text(
        "function Get-Greeting { param([string]$Name) return \"Hi $Name\" }\n",
        encoding="utf-8",
    )
    assert _get_extractor(mod) is extract_powershell
    r = extract_powershell(mod)
    assert "error" not in r
    assert any("Get-Greeting" in n["label"] for n in r["nodes"])


def test_powershell_finds_class_and_method():
    r = extract_powershell(FIXTURES / "sample.ps1")
    labels = [n["label"] for n in r["nodes"]]
    assert "DataProcessor" in labels
    assert any("Transform" in l for l in labels)


def test_powershell_class_base_type_emits_inherits_edge():
    # `class Circle : Shape` — the base type after ':' was previously dropped
    # because the handler only read the first simple_name (the class name).
    r = extract_powershell(FIXTURES / "sample.ps1")
    assert ("Circle", "Shape") in _edge_labels(r, "inherits")


def test_powershell_property_field_type_context():
    r = extract_powershell(FIXTURES / "sample.ps1")
    assert ("DataProcessor", "string") in _edge_labels(r, "references", "field")


def test_powershell_method_parameter_and_return_type_contexts():
    r = extract_powershell(FIXTURES / "sample.ps1")
    assert ("Transform", "string") in _edge_labels(r, "references", "parameter_type")
    assert ("Transform", "string") in _edge_labels(r, "references", "return_type")
    assert ("Save", "void") in _edge_labels(r, "references", "return_type")


# ── PowerShell: Import-Module + dot-source (#1331) ───────────────────────────

def test_powershell_import_module_emits_edge():
    """Import-Module Foo at top level emits an imports_from edge."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    assert "error" not in r
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("foo" in t for t in targets), f"Missing Import-Module Foo edge; targets={targets}"


def test_powershell_import_module_with_name_param():
    """Import-Module -Name Bar.psm1 resolves to module stem 'bar'."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("bar" in t for t in targets), f"Missing Import-Module -Name Bar edge; targets={targets}"


def test_powershell_dot_source_forward_slash_emits_edge():
    """Dot-source `. ./Shared.psm1` emits an imports_from edge."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("shared" in t for t in targets), f"Missing dot-source Shared edge; targets={targets}"


def test_powershell_dot_source_backslash_emits_edge():
    """Dot-source `. .\\Utils.ps1` (backslash path) emits an imports_from edge."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("utils" in t for t in targets), f"Missing dot-source Utils edge; targets={targets}"


def test_powershell_import_module_inside_function_emits_edge():
    """Import-Module inside a function body still produces an imports_from edge."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("innermod" in t for t in targets), (
        f"Missing Import-Module InnerMod edge from function body; targets={targets}"
    )


def test_powershell_import_module_not_a_raw_call():
    """Import-Module must not appear in raw_calls (it is an import, not a function call)."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    import_module_calls = [
        rc for rc in r.get("raw_calls", [])
        if rc.get("callee", "").lower() == "import-module"
    ]
    assert not import_module_calls, (
        f"Import-Module appeared in raw_calls but should be emitted as import edge: {import_module_calls}"
    )


def test_powershell_dot_source_inside_function_emits_edge():
    """Dot-source inside a function body still produces an imports_from edge."""
    r = extract_powershell(FIXTURES / "sample_import.ps1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("innershared" in t for t in targets), (
        f"Missing dot-source InnerShared edge from function body; targets={targets}"
    )


# ── PowerShell manifest (.psd1) (#1331) ──────────────────────────────────────

def test_powershell_psd1_dispatched():
    """_get_extractor should route .psd1 to extract_powershell_manifest."""
    from graphify.extract import _get_extractor
    import tempfile, os
    with tempfile.NamedTemporaryFile(suffix=".psd1", delete=False) as f:
        f.write(b"@{ RootModule = 'X.psm1' }")
        path = f.name
    try:
        assert _get_extractor(Path(path)) is extract_powershell_manifest
    finally:
        os.unlink(path)


def test_powershell_psd1_no_error():
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    assert "error" not in r


def test_powershell_psd1_has_file_node():
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    assert any("sample.psd1" in n["label"] for n in r["nodes"]), (
        f"Missing file node for sample.psd1; nodes={[n['label'] for n in r['nodes']]}"
    )


def test_powershell_psd1_root_module():
    """RootModule = 'MyModule.psm1' produces an imports_from edge to 'mymodule'."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("mymodule" in t for t in targets), (
        f"Missing RootModule edge for MyModule; targets={targets}"
    )


def test_powershell_psd1_nested_modules():
    """NestedModules = @('Helpers.psm1', 'Logger.psm1') produces edges for both."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("helpers" in t for t in targets), f"Missing NestedModules Helpers edge; targets={targets}"
    assert any("logger" in t for t in targets), f"Missing NestedModules Logger edge; targets={targets}"


def test_powershell_psd1_required_modules_string():
    """RequiredModules string form 'PSReadLine' produces an imports_from edge."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("psreadline" in t for t in targets), (
        f"Missing RequiredModules PSReadLine edge; targets={targets}"
    )


def test_powershell_psd1_required_modules_hashtable():
    """RequiredModules hashtable form @{{ ModuleName='Pester' }} produces an imports_from edge."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("pester" in t for t in targets), (
        f"Missing RequiredModules Pester (hashtable form) edge; targets={targets}"
    )


def test_powershell_psd1_no_moduleversion_as_edge():
    """ModuleVersion values ('5.0', '1.0.0') must NOT appear as import targets."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert not any(t in targets for t in ("5_0", "1_0_0", "5.0", "1.0.0")), (
        f"ModuleVersion string leaked into import targets: {targets}"
    )


def test_powershell_psd1_no_dangling_edges():
    """All imports_from edge sources must exist in the node set."""
    r = extract_powershell_manifest(FIXTURES / "sample.psd1")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"Dangling source in edge: {e}"


# ── TypeScript dynamic imports ───────────────────────────────────────────────

def test_ts_dynamic_import_no_error():
    r = extract_js(FIXTURES / "dynamic_import.ts")
    assert "error" not in r

def test_ts_dynamic_import_extracts_edges():
    """Dynamic import() calls inside functions should produce imports_from edges."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    dyn_edges = [e for e in r["edges"] if e["relation"] == "imports_from"]
    targets = {e["target"] for e in dyn_edges}
    # Should find: static ./logger, dynamic ./mayaEngine.js, dynamic ./queue.js
    assert any("logger" in t for t in targets), f"Missing static import of logger: {targets}"
    assert any("mayaengine" in t.lower() for t in targets), f"Missing dynamic import of mayaEngine: {targets}"
    assert any("queue" in t.lower() for t in targets), f"Missing dynamic import of queue: {targets}"

def test_ts_dynamic_import_confidence():
    """Dynamic imports should have EXTRACTED confidence (they are deterministic string literals)."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    dyn_edges = [e for e in r["edges"]
                 if e["relation"] == "imports_from"
                 and "mayaengine" in e["target"].lower()]
    assert len(dyn_edges) >= 1
    assert dyn_edges[0]["confidence"] == "EXTRACTED"

def test_ts_dynamic_import_source_is_function():
    """Dynamic import edge source should be the enclosing function, not the file."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    node_labels = {n["id"]: n["label"] for n in r["nodes"]}
    dyn_edges = [e for e in r["edges"]
                 if e["relation"] == "imports_from"
                 and "mayaengine" in e["target"].lower()]
    assert len(dyn_edges) >= 1
    src_label = node_labels.get(dyn_edges[0]["source"], "")
    assert "processInbound" in src_label, f"Expected processInbound as source, got {src_label}"

def test_ts_no_dynamic_import_in_sync_fn():
    """Functions without dynamic imports should not get spurious imports_from edges."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    node_ids = {n["label"]: n["id"] for n in r["nodes"]}
    sync_nid = node_ids.get("syncOnly()")
    if sync_nid:
        sync_imports = [e for e in r["edges"]
                        if e["source"] == sync_nid and e["relation"] == "imports_from"]
        assert len(sync_imports) == 0

def test_ts_dynamic_template_literal_skipped():
    """Dynamic template literals (with ${}) must not produce an imports_from edge."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    # loadHandler uses `./handlers/${handlerName}` — no static path, must be absent
    assert not any("handler" in t.lower() and "$" in t for t in targets), \
        f"Garbage edge from dynamic template literal found: {targets}"
    # More robust: no target should contain a brace character
    assert not any("{" in t or "}" in t for t in targets), \
        f"Target contains unresolved template expression: {targets}"

def test_ts_static_template_literal_resolved():
    """Static template literals (no ${}) should resolve the same as a plain string."""
    r = extract_js(FIXTURES / "dynamic_import.ts")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"}
    assert any("statichelper" in t.lower() for t in targets), \
        f"Static template literal import not resolved: {targets}"


def test_js_local_const_does_not_emit_phantom_node(tmp_path):
    """Local const/let/var inside an arrow callback must NOT emit a node (#1077).

    Previously `_js_extra_walk` recursed into arrow_function bodies and
    emitted a node for every `const x = ...` inside e.g. `describe(() => {})`,
    so bare names like `set`, `sorted` collided across unrelated test files.
    """
    src = (
        "describe('suite', () => {\n"
        "  const inner = new Set([1, 2, 3]);\n"
        "  let other = [1, 2];\n"
        "});\n"
        "\n"
        "const moduleConst = new Set([4, 5]);\n"
        "export const exportedConst = { a: 1 };\n"
    )
    f = tmp_path / "scope_guard.js"
    f.write_text(src)
    r = extract_js(f)
    labels = _labels(r)

    # Locals inside the arrow callback must not produce nodes.
    assert "inner" not in labels, f"phantom node for arrow-body local 'inner': {labels}"
    assert "other" not in labels, f"phantom node for arrow-body local 'other': {labels}"

    # Module-level consts should still produce nodes.
    assert "moduleConst" in labels, f"module-level const 'moduleConst' missing: {labels}"
    assert "exportedConst" in labels, f"exported const 'exportedConst' missing: {labels}"


def test_js_module_level_arrow_produces_node_and_call_edges(tmp_path):
    """Module-level arrow functions must still emit a node and capture their calls (#1077).

    The scope guard must not accidentally suppress top-level arrow functions.
    """
    src = (
        "function helper() { return 1; }\n"
        "const handler = () => {\n"
        "  helper();\n"
        "};\n"
    )
    f = tmp_path / "arrows.js"
    f.write_text(src)
    r = extract_js(f)
    labels = _labels(r)
    relations = _relations(r)

    assert any("handler" in l for l in labels), f"module-level arrow 'handler' missing: {labels}"
    assert "calls" in relations, f"expected 'calls' edge from handler->helper: {relations}"


def test_ts_local_const_does_not_emit_phantom_node(tmp_path):
    """Scope guard applies to TypeScript files too (shared _js_extra_walk path)."""
    src = (
        "describe('suite', () => {\n"
        "  const inner: Set<number> = new Set([1, 2]);\n"
        "});\n"
        "\n"
        "export const topLevel = { a: 1 };\n"
    )
    f = tmp_path / "scope_guard.ts"
    f.write_text(src)
    r = extract_js(f)
    labels = _labels(r)

    assert "inner" not in labels, f"phantom TS node for arrow-body local 'inner': {labels}"
    assert "topLevel" in labels, f"module-level TS const 'topLevel' missing: {labels}"


def test_ts_constructor_injection_calls_edge(tmp_path):
    """this.repo.findById() in a class with constructor(private repo: IUserRepository)
    must produce a calls edge from getUser() to findById() (#1316)."""
    from graphify.extract import extract
    repo_ts = tmp_path / "repo.ts"
    repo_ts.write_text(
        "export interface IUserRepository {\n"
        "  findById(id: string): Promise<any>;\n"
        "  save(user: any): Promise<void>;\n"
        "}\n"
    )
    svc_ts = tmp_path / "service.ts"
    svc_ts.write_text(
        "import { IUserRepository } from './repo';\n"
        "\n"
        "export class UserService {\n"
        "  constructor(private repo: IUserRepository) {}\n"
        "\n"
        "  getUser(id: string) {\n"
        "    return this.repo.findById(id);\n"
        "  }\n"
        "}\n"
    )
    r = extract([repo_ts, svc_ts], cache_root=tmp_path / "cache")
    edge_triples = {
        (e["source"], e["relation"], e["target"])
        for e in r["edges"]
    }
    labels_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    label_triples = {
        (labels_by_id.get(s, s), rel, labels_by_id.get(t, t))
        for s, rel, t in edge_triples
    }
    calls_from_get_user = [
        (s, rel, t) for s, rel, t in label_triples
        if "getUser" in s and rel == "calls"
    ]
    assert any("findById" in t for _, _, t in calls_from_get_user), (
        f"expected getUser()->findById() calls edge, got: {calls_from_get_user}"
    )


def test_ts_this_field_receiver_not_same_file_collision(tmp_path):
    """this.db.query() should NOT match an unrelated query() in the same file (#1316)."""
    f = tmp_path / "collision.ts"
    f.write_text(
        "function query() { return 'global'; }\n"
        "\n"
        "export class Service {\n"
        "  constructor(private db: Database) {}\n"
        "\n"
        "  run() {\n"
        "    return this.db.query();\n"
        "  }\n"
        "}\n"
    )
    r = extract_js(f)
    calls_edges = [
        e for e in r["edges"]
        if e["relation"] == "calls"
    ]
    caller_labels = {n["id"]: n["label"] for n in r["nodes"]}
    run_to_query = [
        e for e in calls_edges
        if "run" in caller_labels.get(e["source"], "")
        and "query" in caller_labels.get(e["target"], "")
    ]
    assert len(run_to_query) == 0, (
        f"this.db.query() should NOT resolve to bare query() in same file: {run_to_query}"
    )


def _ts_label_calls(r, src_sub):
    labels = {n["id"]: n["label"] for n in r["nodes"]}
    return [
        labels.get(e["target"], e["target"])
        for e in r["edges"]
        if e["relation"] == "calls" and src_sub in labels.get(e["source"], e["source"])
    ]


def test_ts_injected_field_resolves_to_typed_class_not_same_named_collision(tmp_path):
    """The decisive #1316 guardrail: two classes each define `query`, but the
    injected field is typed `Database`, so `this.db.query()` must resolve to
    Database.query ONLY — never HttpClient.query (no global name-match fan-out)."""
    from graphify.extract import extract
    (tmp_path / "database.ts").write_text(
        "export class Database {\n  query(sql: string) { return sql; }\n}\n"
    )
    (tmp_path / "http.ts").write_text(
        "export class HttpClient {\n  query(url: string) { return url; }\n}\n"
    )
    (tmp_path / "service.ts").write_text(
        "import { Database } from './database';\n"
        "export class Service {\n"
        "  constructor(private db: Database) {}\n"
        "  run() { return this.db.query('x'); }\n"
        "}\n"
    )
    r = extract(
        [tmp_path / "database.ts", tmp_path / "http.ts", tmp_path / "service.ts"],
        cache_root=tmp_path / "cache",
    )
    labels = {n["id"]: n["label"] for n in r["nodes"]}
    # Find the run()->query calls edge and confirm its target is owned by Database.
    method_owner = {
        e["target"]: e["source"]
        for e in r["edges"] if e["relation"] == "method"
    }
    run_query_targets = [
        e["target"] for e in r["edges"]
        if e["relation"] == "calls"
        and "run" in labels.get(e["source"], "")
        and "query" in labels.get(e["target"], "")
    ]
    assert run_query_targets, "expected this.db.query() to resolve to a query method"
    for tgt in run_query_targets:
        owner = method_owner.get(tgt)
        assert owner is not None and labels.get(owner) == "Database", (
            f"this.db.query() must resolve to Database.query, got owner {labels.get(owner)}"
        )


def test_ts_injected_field_ambiguous_type_emits_no_edge(tmp_path):
    """If the injected field's type name is ambiguous (two classes named Database),
    the god-node guard bails — no calls edge rather than a guess (#1316)."""
    from graphify.extract import extract
    (tmp_path / "a" ).mkdir()
    (tmp_path / "b").mkdir()
    (tmp_path / "a" / "database.ts").write_text(
        "export class Database {\n  query(sql: string) { return sql; }\n}\n"
    )
    (tmp_path / "b" / "database.ts").write_text(
        "export class Database {\n  query(sql: string) { return sql; }\n}\n"
    )
    (tmp_path / "service.ts").write_text(
        "export class Service {\n"
        "  constructor(private db: Database) {}\n"
        "  run() { return this.db.query('x'); }\n"
        "}\n"
    )
    r = extract(sorted(tmp_path.rglob("*.ts")), cache_root=tmp_path / "cache")
    # `query` resolution must bail (2 Database defs) -> no run()->query calls edge.
    assert not [t for t in _ts_label_calls(r, "run") if "query" in t], (
        "ambiguous Database type must not produce a this.db.query() edge"
    )


# ── Markdown ─────────────────────────────────────────────────────────────────

from graphify.extract import extract_markdown

def test_markdown_no_error():
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    assert "error" not in r

def test_markdown_finds_headings():
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    labels = _labels(r)
    assert any("Deploy Guide" in l for l in labels)
    assert any("Prerequisites" in l for l in labels)
    assert any("Full Deploy" in l for l in labels)
    assert any("Rollback" in l for l in labels)

def test_markdown_finds_nested_heading():
    """### Database Migration is nested under ## Full Deploy."""
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    labels = _labels(r)
    assert any("Database Migration" in l for l in labels)

def test_markdown_skips_fenced_code_blocks():
    """Fenced code blocks should NOT emit nodes (#1077).

    They were always orphans (single contains edge to parent doc) and
    inflated the disconnected-component count. We still skip over their
    *contents* when parsing so the inside of a fence is not misread as a
    heading.
    """
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    labels = _labels(r)
    assert not any(l.startswith("code:") for l in labels), \
        f"Expected no code:* nodes after #1077 fix, got: {[l for l in labels if l.startswith('code:')]}"

def test_markdown_contains_edges():
    """Headings should be connected via 'contains' edges (file->h, h->h)."""
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    assert "contains" in _relations(r)
    contains_edges = [e for e in r["edges"] if e["relation"] == "contains"]
    # deploy_guide.md has: file->h1, h1->h2(Prerequisites), h1->h2(Full Deploy),
    # h2(Full Deploy)->h3(Database Migration), h1->h2(Rollback) = 5 edges
    assert len(contains_edges) >= 5, f"expected >= 5 contains edges, got {len(contains_edges)}"


def test_markdown_fenced_heading_not_parsed():
    """A '## heading' inside a fenced block must not produce a heading node (#1077).

    The fence-toggle skips over fenced contents so interior markdown syntax
    is not misread as document structure.
    """
    import tempfile, os
    src = (
        "# Real Heading\n"
        "\n"
        "```bash\n"
        "## Not A Heading\n"
        "echo hello\n"
        "```\n"
        "\n"
        "## Another Real Heading\n"
    )
    with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as fh:
        fh.write(src)
        fpath = fh.name
    try:
        r = extract_markdown(Path(fpath))
        labels = _labels(r)
    finally:
        os.unlink(fpath)

    assert any("Real Heading" in l for l in labels), f"'Real Heading' missing: {labels}"
    assert any("Another Real Heading" in l for l in labels), f"'Another Real Heading' missing: {labels}"
    assert not any("Not A Heading" in l for l in labels), \
        f"fenced '## Not A Heading' was incorrectly parsed as a node: {labels}"

def test_markdown_no_dangling_edges():
    r = extract_markdown(FIXTURES / "deploy_guide.md")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"Dangling source: {e}"


def _md_link_fixture(tmp_path):
    """A hub doc linking to sibling docs, plus those docs (#1376)."""
    pkg = tmp_path / "packages" / "coding-standards-csharp"
    pkg.mkdir(parents=True)
    (pkg / "index.md").write_text(
        "# C# Coding Standards\n\n"
        "| Topic | Doc |\n| --- | --- |\n"
        "| Repository | [C# Repository Standards](./repository.md) |\n"
        "| HTTP Client | [C# HTTP Client Standards](http-client.md) |\n"
        "| Unit Tests | [C# Unit Test Standards](unit-tests.md) |\n\n"
        "See also [external](https://example.com/x) and ![logo](./logo.png).\n"
        "Anchor: [section](./repository.md#setup).\n"
        "Wikilink: [[http-client]].\n"
    )
    (pkg / "repository.md").write_text("# C# Repository Standards\nContent.\n")
    (pkg / "http-client.md").write_text("# C# HTTP Client Standards\nContent.\n")
    (pkg / "unit-tests.md").write_text("# C# Unit Test Standards\nContent.\n")
    return pkg


def test_markdown_link_edges_emitted(tmp_path):
    """Inline/wikilink markdown links to sibling docs become references edges (#1376)."""
    pkg = _md_link_fixture(tmp_path)
    r = extract_markdown(pkg / "index.md")
    refs = [e for e in r["edges"] if e["relation"] == "references"]
    targets = {e["target"] for e in refs}
    # repository, http-client, unit-tests — each exactly once (deduped despite
    # the anchor link and wikilink pointing at repository/http-client again).
    assert len(refs) == 3, f"expected 3 reference edges, got {refs}"
    assert any("repository" in t for t in targets)
    assert any("http_client" in t for t in targets)
    assert any("unit_tests" in t for t in targets)


def test_markdown_link_skips_external_and_images(tmp_path):
    """External URLs, in-page anchors and images must not produce edges (#1376)."""
    pkg = _md_link_fixture(tmp_path)
    r = extract_markdown(pkg / "index.md")
    refs = [e for e in r["edges"] if e["relation"] == "references"]
    for e in refs:
        assert "example.com" not in e["target"]
        assert "logo" not in e["target"]


def test_markdown_link_edges_resolve_to_real_nodes(tmp_path):
    """End-to-end: after extract()'s ID remap, link targets are real doc nodes,
    so the hub doc gains edges into existing nodes instead of ghost nodes (#1376)."""
    from graphify.extract import extract
    pkg = _md_link_fixture(tmp_path)
    paths = sorted(pkg.glob("*.md"))
    res = extract(paths, cache_root=tmp_path, parallel=False)
    node_ids = {n["id"] for n in res["nodes"]}
    refs = [e for e in res["edges"] if e["relation"] == "references"]
    assert refs, "expected reference edges after full extract"
    for e in refs:
        assert e["target"] in node_ids, f"link target is a ghost node: {e}"
    # index.md must connect to all three sibling docs.
    index_id = next(n["id"] for n in res["nodes"] if n["label"] == "index.md")
    index_refs = {e["target"] for e in refs if e["source"] == index_id}
    assert len(index_refs) == 3, f"hub doc under-connected: {index_refs}"


# ── Groovy ───────────────────────────────────────────────────────────────────


def test_groovy_no_error():
    r = extract_groovy(FIXTURES / "sample.groovy")
    assert "error" not in r


def test_groovy_finds_class():
    r = extract_groovy(FIXTURES / "sample.groovy")
    assert any("SampleService" in l for l in _labels(r))


def test_groovy_finds_methods():
    r = extract_groovy(FIXTURES / "sample.groovy")
    labels = _labels(r)
    assert any("process" in l for l in labels)
    assert any("reset" in l for l in labels)


def test_groovy_finds_imports():
    r = extract_groovy(FIXTURES / "sample.groovy")
    assert "imports" in _relations(r)


def test_groovy_import_edges_have_import_context():
    r = extract_groovy(FIXTURES / "sample.groovy")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)


def test_groovy_no_dangling_edges():
    r = extract_groovy(FIXTURES / "sample.groovy")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids


def test_groovy_extends_edge():
    """`class X extends Base` must emit an inherits edge.

    tree-sitter-groovy exposes inheritance via the same `superclass` field as
    tree-sitter-java, but the inheritance handler was gated to Java only, so
    Groovy extends/implements were silently dropped.
    """
    r = extract_groovy(FIXTURES / "sample.groovy")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "ExtendedService" in node_by_id.get(e["source"], "")
        and "SampleService" in node_by_id.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "inherits"
    )
    assert found, "ExtendedService should have inherits edge to SampleService"


def test_groovy_implements_edge():
    """`class X implements Iface` must emit an implements edge."""
    r = extract_groovy(FIXTURES / "sample.groovy")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    found = any(
        "ExtendedService" in node_by_id.get(e["source"], "")
        and "Resettable" in node_by_id.get(e["target"], "")
        for e in r["edges"] if e["relation"] == "implements"
    )
    assert found, "ExtendedService should have implements edge to Resettable"


def test_groovy_spock_finds_class():
    r = extract_groovy(FIXTURES / "sample_spock.groovy")
    assert any("SampleSpec" in l for l in _labels(r))


def test_groovy_spock_finds_feature_methods():
    r = extract_groovy(FIXTURES / "sample_spock.groovy")
    feature_labels = [l for l in _labels(r) if l.startswith('"')]
    assert len(feature_labels) >= 2


def test_groovy_spock_finds_method_with_apostrophe():
    r = extract_groovy(FIXTURES / "sample_spock.groovy")
    assert any("it's" in l for l in _labels(r))


def test_groovy_spock_preserves_import_edges():
    r = extract_groovy(FIXTURES / "sample_spock.groovy")
    assert "imports" in _relations(r)


def test_groovy_spock_no_dangling_edges():
    r = extract_groovy(FIXTURES / "sample_spock.groovy")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids


# ── DM (BYOND DreamMaker) ────────────────────────────────────────────────────

@_needs_dm
def test_dm_no_error():
    r = extract_dm(FIXTURES / "sample.dm")
    assert "error" not in r

@_needs_dm
def test_dm_finds_global_proc():
    r = extract_dm(FIXTURES / "sample.dm")
    labels = _labels(r)
    assert any(l == "log_event()" for l in labels)
    assert any(l == "RunTest()" for l in labels)

@_needs_dm
def test_dm_finds_type_definition():
    r = extract_dm(FIXTURES / "sample.dm")
    labels = _labels(r)
    assert "/datum/weapon" in labels
    assert "/datum/weapon/sword" in labels

@_needs_dm
def test_dm_qualifies_proc_with_type_path():
    r = extract_dm(FIXTURES / "sample.dm")
    labels = _labels(r)
    assert "/datum/weapon/attack()" in labels
    assert "/datum/weapon/sword/attack()" in labels

@_needs_dm
def test_dm_finds_path_form_proc_definition():
    r = extract_dm(FIXTURES / "sample.dm")
    assert "/datum/weapon/sword/sharpen()" in _labels(r)

@_needs_dm
def test_dm_emits_include_edge():
    r = extract_dm(FIXTURES / "sample.dm")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    assert import_edges
    assert all(e.get("context") == "import" for e in import_edges)

@_needs_dm
def test_dm_unresolved_include_flagged_external():
    r = extract_dm(FIXTURES / "sample.dm")
    import_edges = _edges_with_relation(r, "imports", "imports_from")
    helpers = [e for e in import_edges if "helpers" in e["target"]]
    assert helpers
    assert all(e.get("external") is True for e in helpers)

@_needs_dm
def test_dm_resolves_in_file_calls():
    r = extract_dm(FIXTURES / "sample.dm")
    calls = _calls(r)
    assert any(callee == "log_event()" for _, callee in calls)
    assert ("/datum/weapon/sword/attack()", "/datum/weapon/sword/sharpen()") in calls

@_needs_dm
def test_dm_ambiguous_member_call_left_unresolved():
    r = extract_dm(FIXTURES / "sample.dm")
    calls = _calls(r)
    runtest_to_attack = [c for s, c in calls
                         if s == "RunTest()" and "attack" in c]
    assert not runtest_to_attack
    assert any(rc["callee"] == "attack" for rc in r.get("raw_calls", []))

@_needs_dm
def test_dm_emits_new_as_instantiates():
    r = extract_dm(FIXTURES / "sample.dm")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    inst = [(node_by_id.get(e["source"]), node_by_id.get(e["target"]))
            for e in r["edges"] if e["relation"] == "instantiates"]
    assert ("RunTest()", "/datum/weapon/sword") in inst

@_needs_dm
def test_dm_call_edges_have_call_context():
    r = extract_dm(FIXTURES / "sample.dm")
    call_edges = _edges_with_relation(r, "calls", "instantiates")
    assert call_edges
    assert all(e.get("context") == "call" for e in call_edges)

@_needs_dm
def test_dm_no_dangling_edges():
    r = extract_dm(FIXTURES / "sample.dm")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids

@_needs_dm
def test_dm_super_call_not_emitted():
    r = extract_dm(FIXTURES / "sample.dm")
    calls = _calls(r)
    assert not any(callee.strip("()") == ".." for _, callee in calls)
    assert not any(rc["callee"] == ".." for rc in r.get("raw_calls", []))


# ── DMI (BYOND icon sheets) ──────────────────────────────────────────────────

def test_dmi_no_error():
    r = extract_dmi(FIXTURES / "sample.dmi")
    assert "error" not in r

def test_dmi_emits_state_nodes():
    r = extract_dmi(FIXTURES / "sample.dmi")
    labels = _labels(r)
    assert any(l == '"mob"' for l in labels)

def test_dmi_state_contained_by_file():
    r = extract_dmi(FIXTURES / "sample.dmi")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    contains = [(node_by_id.get(e["source"]), node_by_id.get(e["target"]))
                for e in r["edges"] if e["relation"] == "contains"]
    assert ("sample.dmi", '"mob"') in contains


# ── DMM (BYOND map files) ────────────────────────────────────────────────────

def test_dmm_no_error():
    r = extract_dmm(FIXTURES / "sample.dmm")
    assert "error" not in r

def test_dmm_extracts_type_paths_as_uses_edges():
    r = extract_dmm(FIXTURES / "sample.dmm")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "uses"}
    assert "turf_closed_wall" in targets
    assert "obj_structure_table" in targets
    assert "obj_item_weapon_sword" in targets

def test_dmm_strips_var_overrides():
    r = extract_dmm(FIXTURES / "sample.dmm")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "uses"}
    assert not any("{" in t for t in targets)
    assert "obj_item_weapon_sword" in targets

def test_dmm_handles_multiline_tile_definition():
    r = extract_dmm(FIXTURES / "sample.dmm")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "uses"}
    assert "area_station_maintenance" in targets

def test_dmm_skips_grid_section():
    r = extract_dmm(FIXTURES / "sample.dmm")
    targets = {e["target"] for e in r["edges"] if e["relation"] == "uses"}
    assert len(targets) == 5


# ── DMF (BYOND interface forms) ──────────────────────────────────────────────

def test_dmf_no_error():
    r = extract_dmf(FIXTURES / "sample.dmf")
    assert "error" not in r

def test_dmf_extracts_windows():
    r = extract_dmf(FIXTURES / "sample.dmf")
    labels = _labels(r)
    assert 'window "mapwindow"' in labels
    assert 'window "infowindow"' in labels

def test_dmf_elem_labels_carry_control_type():
    r = extract_dmf(FIXTURES / "sample.dmf")
    labels = _labels(r)
    assert 'elem "map" [MAP]' in labels

def test_dmf_elem_under_window():
    r = extract_dmf(FIXTURES / "sample.dmf")
    node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
    contains = [(node_by_id.get(e["source"]), node_by_id.get(e["target"]))
                for e in r["edges"] if e["relation"] == "contains"]
    assert ('window "mapwindow"', 'elem "map" [MAP]') in contains

def test_dmf_no_dangling_edges():
    r = extract_dmf(FIXTURES / "sample.dmf")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids
        assert e["target"] in node_ids


# -- .NET project files (.sln, .csproj, .xaml, .razor) ------------------------

def test_sln_no_error():
    r = extract_sln(FIXTURES / "sample.sln")
    assert "error" not in r

def test_sln_finds_projects():
    r = extract_sln(FIXTURES / "sample.sln")
    labels = _labels(r)
    assert any("WebApi" in l for l in labels)
    assert any("Domain" in l for l in labels)

def test_sln_contains_edges():
    r = extract_sln(FIXTURES / "sample.sln")
    assert "contains" in _relations(r)

def test_sln_project_dependency_edges():
    r = extract_sln(FIXTURES / "sample.sln")
    assert "imports" in _relations(r)

def test_csproj_no_error():
    r = extract_csproj(FIXTURES / "sample.csproj")
    assert "error" not in r

def test_csproj_finds_packages():
    r = extract_csproj(FIXTURES / "sample.csproj")
    labels = _labels(r)
    assert any("MediatR" in l for l in labels)
    assert any("FluentValidation" in l for l in labels)

def test_csproj_finds_project_references():
    r = extract_csproj(FIXTURES / "sample.csproj")
    labels = _labels(r)
    assert any("Domain.csproj" in l for l in labels)

def test_csproj_finds_target_framework():
    r = extract_csproj(FIXTURES / "sample.csproj")
    assert any("net8.0" in l for l in _labels(r))

def test_csproj_finds_sdk():
    r = extract_csproj(FIXTURES / "sample.csproj")
    assert any("Microsoft.NET.Sdk.Web" in l for l in _labels(r))

def test_xaml_finds_class_and_event_references():
    r = extract_xaml(FIXTURES / "sample.xaml")
    assert "error" not in r
    assert "MainWindow" in _labels(r)
    assert any(e["relation"] == "references" and e.get("context") == "event" for e in r["edges"])

def test_razor_no_error():
    r = extract_razor(FIXTURES / "sample.razor")
    assert "error" not in r

def test_razor_finds_using_directives():
    r = extract_razor(FIXTURES / "sample.razor")
    assert "imports" in _relations(r)

def test_razor_finds_component_references():
    r = extract_razor(FIXTURES / "sample.razor")
    assert "calls" in _relations(r)

def test_razor_finds_inherits():
    r = extract_razor(FIXTURES / "sample.razor")
    assert "inherits" in _relations(r)

def test_razor_finds_code_block_methods():
    r = extract_razor(FIXTURES / "sample.razor")
    labels = _labels(r)
    assert any("IncrementCount" in l for l in labels)
    assert any("LoadData" in l for l in labels)

def test_razor_no_dangling_edges():
    r = extract_razor(FIXTURES / "sample.razor")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids


# ---------------Salesforce Apex (.cls / .trigger)----------------------

def test_apex_class_extraction():
    r = extract_apex(FIXTURES / "sample.cls")
    labels = _labels(r)
    assert "AccountService" in labels

def test_apex_enum_extraction():
    r = extract_apex(FIXTURES / "sample.cls")
    labels = _labels(r)
    assert "AccountStatus" in labels

def test_apex_interface_extraction():
    r = extract_apex(FIXTURES / "sample.cls")
    labels = _labels(r)
    assert "Notifiable" in labels

def test_apex_interface_extends(tmp_path):
    source = tmp_path / "PaymentProcessor.cls"
    source.write_text(
        "public interface PaymentProcessor extends Processor, Auditable { void process(); }\n"
    )
    result = extract_apex(source)
    inheritance = _edge_labels(result, "extends") | _edge_labels(result, "implements")
    assert ("PaymentProcessor", "Processor") in inheritance
    assert ("PaymentProcessor", "Auditable") in inheritance

def test_apex_method_extraction():
    r = extract_apex(FIXTURES / "sample.cls")
    labels = _labels(r)
    assert any("getAccounts" in l for l in labels)
    assert any("updateAccountsAsync" in l for l in labels)
    assert any("createAccounts" in l for l in labels)
    assert any("deleteOldAccounts" in l for l in labels)

def test_apex_contains_and_method_relations():
    r = extract_apex(FIXTURES / "sample.cls")
    relations = _relations(r)
    assert "contains" in relations
    assert "method" in relations

def test_apex_soql_uses_edge():
    r = extract_apex(FIXTURES / "sample.cls")
    relations = _relations(r)
    assert "uses" in relations
    labels = _labels(r)
    assert "Account" in labels

def test_apex_dml_uses_edge():
    r = extract_apex(FIXTURES / "sample.cls")
    dml_labels = {n["label"] for n in r["nodes"] if n["label"] in ("insert", "update", "delete", "upsert")}
    assert len(dml_labels) > 0

def test_apex_file_node_present():
    r = extract_apex(FIXTURES / "sample.cls")
    labels = _labels(r)
    assert "sample.cls" in labels

def test_apex_trigger_extraction():
    r = extract_apex(FIXTURES / "sample.trigger")
    labels = _labels(r)
    assert "sample.trigger" in labels
    assert "AccountTrigger" in labels

def test_apex_trigger_uses_sobject():
    r = extract_apex(FIXTURES / "sample.trigger")
    relations = _relations(r)
    assert "uses" in relations
    labels = _labels(r)
    assert "Account" in labels

def test_apex_missing_file_returns_empty():
    r = extract_apex(Path("nonexistent.cls"))
    assert r["nodes"] == []
    assert r["edges"] == []

def test_apex_no_dangling_edges():
    for fixture in ("sample.cls", "sample.trigger"):
        r = extract_apex(FIXTURES / fixture)
        node_ids = {n["id"] for n in r["nodes"]}
        for e in r["edges"]:
            assert e["source"] in node_ids, f"dangling source in {fixture}: {e}"
            assert e["target"] in node_ids, f"dangling target in {fixture}: {e}"


# -- SystemVerilog -------------------------------------------------------------

def test_systemverilog_no_error():
    r = extract_verilog(FIXTURES / "sample.sv")
    assert "error" not in r


def test_systemverilog_splits_inherits_and_implements():
    r = extract_verilog(FIXTURES / "sample.sv")
    assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
    assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")


def test_systemverilog_field_parameter_return_and_generic_contexts():
    r = extract_verilog(FIXTURES / "sample.sv")
    assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
    assert ("DataProcessor", "Payload") in _edge_labels(r, "references", "generic_arg")
    assert ("build", "Payload") in _edge_labels(r, "references", "parameter_type")
    assert ("build", "Result") in _edge_labels(r, "references", "return_type")
    assert ("build", "Payload") in _edge_labels(r, "references", "generic_arg")


def test_systemverilog_qualified_field_references():
    """Class properties with leading qualifiers (rand/local/protected/etc.) must
    still emit `references` field edges. The field regex only matched unqualified
    `<type> <name>;` declarations, so `rand Config x;` (three tokens) failed to
    match and its type reference was silently dropped.
    """
    r = extract_verilog(FIXTURES / "sample.sv")
    field_refs = _edge_labels(r, "references", "field")
    assert ("DataProcessor", "Config") in field_refs, "rand-qualified field dropped"
    assert ("DataProcessor", "BaseProcessor") in field_refs, "protected-qualified field dropped"


def test_systemverilog_does_not_emit_type_parameter_refs():
    r = extract_verilog(FIXTURES / "sample.sv")
    assert ("Result", "T") not in _edge_labels(r, "references", "field")


def test_systemverilog_preserves_existing_module_extraction():
    r = extract_verilog(FIXTURES / "sample.sv")
    labels = set(_labels(r))
    assert {"top", "leaf", "add()", "tick"}.issubset(labels)
    assert "imports_from" in _relations(r)
    assert "instantiates" in _relations(r)


def test_systemverilog_missing_file_returns_empty():
    r = extract_verilog(Path("nonexistent.sv"))
    assert r["nodes"] == []
    assert r["edges"] == []


def test_systemverilog_no_dangling_edges():
    r = extract_verilog(FIXTURES / "sample.sv")
    node_ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in node_ids, f"dangling source: {e}"
        assert e["target"] in node_ids, f"dangling target: {e}"


# ── Header/impl class merge + .h routing (#1547 C++, #1556 ObjC/Swift) ─────────
from graphify.extract import (
    extract as _extract_corpus,
    _get_extractor,
    _is_cpp_header,
    _is_objc_header,
)


def _corpus(*relpaths):
    """Run the full extract() pipeline on fixture files (absolute, resolved
    paths so the per-file id-remap behaves like real usage), no shared cache."""
    import tempfile
    paths = [(FIXTURES / rp).resolve() for rp in relpaths]
    with tempfile.TemporaryDirectory() as td:
        return _extract_corpus(paths, cache_root=Path(td))


def _nodes_with_label(r, label):
    return [n for n in r["nodes"] if n["label"] == label]


def _assert_no_dangling(r):
    ids = {n["id"] for n in r["nodes"]}
    for e in r["edges"]:
        assert e["source"] in ids, f"dangling source: {e}"
        assert e["target"] in ids, f"dangling target: {e}"


# --- #1547: C++ paired header/impl --------------------------------------------

def test_cpp_header_routes_to_cpp_extractor():
    """A `.h` with a C++ class must route to extract_cpp, not extract_c (which has
    no class_specifier and would drop the class entirely)."""
    p = (FIXTURES / "cpp_paired" / "Foo.h").resolve()
    assert _get_extractor(p).__name__ == "extract_cpp"
    assert _is_cpp_header(p)


def test_plain_c_header_stays_on_c_extractor():
    """A plain C header (no C++ signal) must keep its extract_c routing."""
    p = (FIXTURES / "cpp_samedir" / "plain.h").resolve()
    assert not _is_cpp_header(p)
    assert _get_extractor(p).__name__ == "extract_c"


def test_cpp_paired_single_class_node():
    """Foo.h (class) + Foo.cpp (Foo::bar def) + Main.cpp must yield exactly ONE
    Foo class node — not a foo_h + foo_cpp pair, and no junk `class` stub."""
    r = _corpus("cpp_paired/Foo.h", "cpp_paired/Foo.cpp", "cpp_paired/Main.cpp")
    foos = _nodes_with_label(r, "Foo")
    assert len(foos) == 1, f"expected one Foo, got {[n['id'] for n in foos]}"
    assert not _nodes_with_label(r, "class"), "no sourceless `class` stub should exist"
    assert not _nodes_with_label(r, "foo_foo")


def test_cpp_paired_method_decl_and_def_are_one_node():
    """`void bar();` in Foo.h and `void Foo::bar() {}` in Foo.cpp must collapse to
    ONE method node owned by the single Foo class."""
    r = _corpus("cpp_paired/Foo.h", "cpp_paired/Foo.cpp", "cpp_paired/Main.cpp")
    foo = _nodes_with_label(r, "Foo")[0]["id"]
    method_targets = {
        e["target"] for e in r["edges"]
        if e["source"] == foo and e["relation"] in ("method", "defines", "contains")
    }
    bar_nodes = [n for n in r["nodes"] if n["id"] in method_targets and n["label"] in ("bar", "Foo::bar()")]
    # There must be exactly one node representing bar (decl and def merged).
    bar_ids = {n["id"] for n in r["nodes"] if n["label"] in ("bar", "Foo::bar()")}
    assert len(bar_ids) == 1, f"bar decl/def should be one node, got {bar_ids}"
    assert bar_nodes, "the merged bar node should be a member of Foo"


def test_cpp_paired_includes_resolve_to_real_header():
    """Foo.cpp and Main.cpp `#include "Foo.h"` must resolve to the real Foo.h file
    node (no dangling import)."""
    r = _corpus("cpp_paired/Foo.h", "cpp_paired/Foo.cpp", "cpp_paired/Main.cpp")
    ids = {n["id"] for n in r["nodes"]}
    foo_h = _nodes_with_label(r, "Foo.h")[0]["id"]
    imports = [e for e in r["edges"] if e["relation"] == "imports"]
    assert len(imports) >= 2
    for e in imports:
        assert e["target"] in ids, f"dangling import target: {e}"
    assert any(e["target"] == foo_h for e in imports), "includes should target Foo.h"


def test_cpp_paired_no_dangling_edges():
    r = _corpus("cpp_paired/Foo.h", "cpp_paired/Foo.cpp", "cpp_paired/Main.cpp")
    _assert_no_dangling(r)


# --- #1556: ObjC paired header/impl + bridging header -------------------------

def test_objc_header_with_import_routes_to_objc():
    """A bridging header that is only `#import "X.h"` (no @interface) must route to
    extract_objc; extract_c parses `#import` as preproc_call and drops the edge."""
    p = (FIXTURES / "objc_mixed" / "Bridging-Header.h").resolve()
    assert _is_objc_header(p)
    assert _get_extractor(p).__name__ == "extract_objc"


def test_objc_paired_single_class_methods_not_duplicated():
    """Widget.h (@interface) + Widget.m (@implementation) -> ONE Widget class node
    with its methods present once each."""
    r = _corpus("objc_mixed/Widget.h", "objc_mixed/Widget.m")
    widgets = _nodes_with_label(r, "Widget")
    assert len(widgets) == 1, f"expected one Widget, got {[n['id'] for n in widgets]}"
    render = _nodes_with_label(r, "-render")
    refresh = _nodes_with_label(r, "-refresh")
    assert len(render) == 1, f"-render duplicated: {render}"
    assert len(refresh) == 1, f"-refresh duplicated: {refresh}"


def test_objc_bridging_header_not_isolated():
    """A bridging header of only `#import "Widget.h"` must produce an imports edge
    to the real Widget.h node (not be an isolated node)."""
    r = _corpus("objc_mixed/Widget.h", "objc_mixed/Widget.m", "objc_mixed/Bridging-Header.h")
    bridge = _nodes_with_label(r, "Bridging-Header.h")[0]["id"]
    widget_h = _nodes_with_label(r, "Widget.h")[0]["id"]
    out = [e for e in r["edges"] if e["source"] == bridge and e["relation"] == "imports"]
    assert out, "bridging header should emit an imports edge"
    assert any(e["target"] == widget_h for e in out), "bridging import should target Widget.h"


def test_objc_paired_no_dangling_edges():
    r = _corpus("objc_mixed/Widget.h", "objc_mixed/Widget.m", "objc_mixed/Bridging-Header.h")
    _assert_no_dangling(r)


# --- #1556: Swift extension folds onto canonical ObjC class -------------------

def test_swift_extension_folds_onto_objc_class():
    """`extension Widget` in Swift over an ObjC `Widget` must fold onto the single
    canonical Widget node, with its members anchored there."""
    r = _corpus("objc_mixed/Widget.h", "objc_mixed/Widget.m", "objc_mixed/WidgetExtras.swift")
    widgets = _nodes_with_label(r, "Widget")
    assert len(widgets) == 1, f"expected one Widget, got {[n['id'] for n in widgets]}"
    wid = widgets[0]["id"]
    method_targets = {e["target"] for e in r["edges"] if e["relation"] == "method" and e["source"] == wid}
    labels = {n["label"] for n in r["nodes"] if n["id"] in method_targets}
    assert any("describe" in l for l in labels), f"Swift extension method should anchor on Widget, got {labels}"
    _assert_no_dangling(r)


# --- god-node guard negatives -------------------------------------------------

def test_decldef_merge_does_not_merge_across_directories():
    """Two unrelated `class Logger` in DIFFERENT directories (each its own .h/.cpp)
    must NOT merge — assert TWO distinct Logger nodes."""
    r = _corpus(
        "cpp_logger/a/Logger.h", "cpp_logger/a/Logger.cpp",
        "cpp_logger/b/Logger.h", "cpp_logger/b/Logger.cpp",
    )
    loggers = _nodes_with_label(r, "Logger")
    assert len(loggers) == 2, f"cross-dir Loggers must stay distinct, got {[n['id'] for n in loggers]}"
    assert len({n["id"] for n in loggers}) == 2


def test_decldef_merge_does_not_merge_same_name_same_dir_distinct_files():
    """Two same-named `class Dup` in the SAME dir but different base stems
    (Alpha.h, Beta.h) must stay distinct (no unique header/impl sibling pair)."""
    r = _corpus("cpp_samedir/Alpha.h", "cpp_samedir/Beta.h")
    dups = _nodes_with_label(r, "Dup")
    assert len(dups) == 2, f"same-dir distinct Dups must stay distinct, got {[n['id'] for n in dups]}"
