"""Tests for graphify.symbol_resolution."""

from __future__ import annotations

from pathlib import Path

from graphify.symbol_resolution import (
    _bash_make_id,
    build_label_index,
    build_python_symbol_index,
    find_unique_python_symbol,
    node_is_resolvable_symbol,
    normalise_callable_label,
    parse_python_import_aliases,
    resolve_bash_source_edges,
    resolve_cross_file_raw_calls,
    resolve_python_import_guided_calls,
)


def test_normalise_callable_label_strips_function_punctuation() -> None:
    assert normalise_callable_label("run()") == "run"
    assert normalise_callable_label(".process()") == "process"
    assert normalise_callable_label("  Execute  ") == "execute"


def test_node_is_resolvable_symbol_skips_rationale_and_doc_tags() -> None:
    assert node_is_resolvable_symbol({"id": "a", "label": "run()", "file_type": "code"}) is True
    assert node_is_resolvable_symbol({"id": "r", "label": "why", "file_type": "rationale"}) is False
    assert (
        node_is_resolvable_symbol({"id": "d", "label": "param x", "file_type": "doc_tag"}) is False
    )


def test_build_label_index_collects_unique_symbols() -> None:
    nodes = [
        {"id": "a_run", "label": "run()", "file_type": "code"},
        {"id": "b_run", "label": "run()", "file_type": "code"},
        {"id": "doc", "label": "run docs", "file_type": "doc_tag"},
    ]
    assert build_label_index(nodes) == {"run": ["a_run", "b_run"]}


def test_resolve_cross_file_raw_calls_emits_unique_unqualified_call() -> None:
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "helper",
                    "is_member_call": False,
                    "source_file": "caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code"},
        {"id": "helper_helper", "label": "helper()", "file_type": "code"},
    ]
    edges = []

    resolved = resolve_cross_file_raw_calls(per_file, nodes, edges)

    assert resolved == [
        {
            "source": "caller_run",
            "target": "helper_helper",
            "relation": "calls",
            "context": "call",
            "confidence": "INFERRED",
            "confidence_score": 0.8,
            "source_file": "caller.py",
            "source_location": "L2",
            "weight": 1.0,
        }
    ]


def test_resolve_cross_file_raw_calls_skips_member_calls() -> None:
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "helper",
                    "is_member_call": True,
                    "source_file": "caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code"},
        {"id": "helper_helper", "label": "helper()", "file_type": "code"},
    ]
    assert resolve_cross_file_raw_calls(per_file, nodes, []) == []


def test_resolve_cross_file_raw_calls_skips_ambiguous_duplicate_labels() -> None:
    """Two genuine NON-test defs of the same name: the god-node guard must still
    hold even with the #1553 tie-breakers, because neither the non-test filter
    nor path proximity yields a unique winner (#543/#1219 stays closed)."""
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "log",
                    "is_member_call": False,
                    "source_file": "pkg/caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": "pkg/caller.py"},
        {"id": "a_log", "label": "log()", "file_type": "code", "source_file": "alpha/a.py"},
        {"id": "b_log", "label": "log()", "file_type": "code", "source_file": "beta/b.py"},
    ]
    assert resolve_cross_file_raw_calls(per_file, nodes, []) == []


def test_resolve_cross_file_raw_calls_real_edge_survives_test_mock() -> None:
    """A real cross-file call must resolve to the SRC definition even when a
    same-named TEST mock exists in the corpus (#1553)."""
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "save",
                    "is_member_call": False,
                    "source_file": "src/caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": "src/caller.py"},
        {"id": "src_save", "label": "save()", "file_type": "code", "source_file": "src/service.py"},
        {"id": "mock_save", "label": "save()", "file_type": "code",
         "source_file": "tests/test_service.py"},
    ]
    resolved = resolve_cross_file_raw_calls(per_file, nodes, [])
    assert [(e["source"], e["target"]) for e in resolved] == [("caller_run", "src_save")]
    assert all(e["target"] != "mock_save" for e in resolved)


def test_resolve_cross_file_raw_calls_n_mock_scale() -> None:
    """One src def plus many same-named test stubs: exactly one edge to src."""
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "save",
                    "is_member_call": False,
                    "source_file": "src/caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": "src/caller.py"},
        {"id": "src_save", "label": "save()", "file_type": "code", "source_file": "src/service.py"},
        {"id": "m1", "label": "save()", "file_type": "code", "source_file": "tests/foo_test.py"},
        {"id": "m2", "label": "save()", "file_type": "code", "source_file": "spec/bar.Tests.ps1"},
        {"id": "m3", "label": "save()", "file_type": "code", "source_file": "test/baz_test.go"},
        {"id": "m4", "label": "save()", "file_type": "code", "source_file": "__tests__/q.test.js"},
    ]
    resolved = resolve_cross_file_raw_calls(per_file, nodes, [])
    assert [(e["source"], e["target"]) for e in resolved] == [("caller_run", "src_save")]


def test_resolve_cross_file_raw_calls_call_site_is_test_prefers_test_local() -> None:
    """A test file calling save() with both a src def and a test-local def present
    resolves to the test-local def (call-site-is-test symmetry, #1553)."""
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "test_caller",
                    "callee": "save",
                    "is_member_call": False,
                    "source_file": "tests/test_service.py",
                    "source_location": "L5",
                }
            ]
        }
    ]
    nodes = [
        {"id": "test_caller", "label": "test_it()", "file_type": "code",
         "source_file": "tests/test_service.py"},
        {"id": "src_save", "label": "save()", "file_type": "code", "source_file": "src/service.py"},
        {"id": "test_save", "label": "save()", "file_type": "code",
         "source_file": "tests/test_service.py"},
    ]
    resolved = resolve_cross_file_raw_calls(per_file, nodes, [])
    targets = [e["target"] for e in resolved]
    assert targets == ["test_save"]
    assert "src_save" not in targets


def test_resolve_cross_file_raw_calls_skips_existing_pair() -> None:
    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "helper",
                    "is_member_call": False,
                    "source_file": "caller.py",
                    "source_location": "L2",
                }
            ]
        }
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code"},
        {"id": "helper_helper", "label": "helper()", "file_type": "code"},
    ]
    edges = [{"source": "caller_run", "target": "helper_helper", "relation": "calls"}]
    assert resolve_cross_file_raw_calls(per_file, nodes, edges) == []


def test_parse_python_import_aliases_supports_from_import_alias(tmp_path: Path) -> None:
    src = tmp_path / "caller.py"
    src.write_text("from helper import transform as tx\n", encoding="utf-8")

    aliases = parse_python_import_aliases(src)

    assert set(aliases) == {"tx"}
    imported = aliases["tx"]
    assert imported.local_name == "tx"
    assert imported.imported_name == "transform"
    assert imported.module_stem == "helper"
    assert imported.source_location == "L1"


def test_build_python_symbol_index_uses_module_stem_and_label() -> None:
    nodes = [
        {
            "id": "helper_transform",
            "label": "transform()",
            "file_type": "code",
            "source_file": "/repo/helper.py",
        },
        {
            "id": "other_transform",
            "label": "transform()",
            "file_type": "code",
            "source_file": "/repo/other.py",
        },
    ]
    index = build_python_symbol_index(nodes)
    assert index[("helper", "transform")] == ["helper_transform"]
    assert index[("other", "transform")] == ["other_transform"]


def test_find_unique_python_symbol_returns_none_when_ambiguous(tmp_path: Path) -> None:
    src = tmp_path / "caller.py"
    src.write_text("from helper import transform\n", encoding="utf-8")
    imported = parse_python_import_aliases(src)["transform"]
    index = {("helper", "transform"): ["a", "b"]}
    assert find_unique_python_symbol(index, imported) is None


def test_resolve_python_import_guided_calls_emits_extracted_edge(tmp_path: Path) -> None:
    caller = tmp_path / "caller.py"
    helper = tmp_path / "helper.py"
    caller.write_text(
        "from helper import transform as tx\n\ndef run(value):\n    return tx(value)\n",
        encoding="utf-8",
    )
    helper.write_text("def transform(value):\n    return value\n", encoding="utf-8")

    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "tx",
                    "is_member_call": False,
                    "source_file": str(caller),
                    "source_location": "L4",
                }
            ]
        },
        {"raw_calls": []},
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": str(caller)},
        {
            "id": "helper_transform",
            "label": "transform()",
            "file_type": "code",
            "source_file": str(helper),
        },
    ]

    edges = resolve_python_import_guided_calls(per_file, [caller, helper], nodes, [])

    assert edges == [
        {
            "source": "caller_run",
            "target": "helper_transform",
            "relation": "calls",
            "context": "import_guided_call",
            "confidence": "EXTRACTED",
            "confidence_score": 1.0,
            "source_file": str(caller),
            "source_location": "L4",
            "weight": 1.0,
            "metadata": {
                "resolver": "python_import_guided",
                "local_name": "tx",
                "imported_name": "transform",
                "module_stem": "helper",
                "import_source_location": "L1",
            },
        }
    ]


# ═══════════════════════════════════════════════════════════════════════════════
# ── Bash source edges resolver tests ──────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════


def test_bash_call_resolver_emits_source_edges(tmp_path: Path) -> None:
    a_sh = tmp_path / "a.sh"
    b_sh = tmp_path / "b.sh"
    a_sh.write_text("#!/usr/bin/env bash\nsource ./b.sh\n")
    b_sh.write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")

    per_file = [
        {
            "nodes": [
                {"id": "a_sh", "label": "a.sh", "file_type": "code", "source_file": str(a_sh)},
                {
                    "id": "a_entry",
                    "label": "a.sh script",
                    "file_type": "code",
                    "source_file": str(a_sh),
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [
                {"source_file": str(a_sh), "target_path": str(b_sh), "source_location": "L2"}
            ],
        },
        {
            "nodes": [
                {"id": "b_sh", "label": "b.sh", "file_type": "code", "source_file": str(b_sh)},
                {
                    "id": "b_func",
                    "label": "b_func()",
                    "file_type": "code",
                    "source_file": str(b_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]

    edges = resolve_bash_source_edges(per_file, [a_sh, b_sh], tmp_path)

    imports = [e for e in edges if e["relation"] == "imports_from"]
    assert len(imports) == 1
    assert imports[0]["confidence"] == "EXTRACTED"


def test_bash_call_resolver_emits_call_edges_from_sourced_files(tmp_path: Path) -> None:
    a_sh = tmp_path / "a.sh"
    b_sh = tmp_path / "b.sh"
    a_sh.write_text("#!/usr/bin/env bash\nsource ./b.sh\nmain() { b_func; }\n")
    b_sh.write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")

    per_file = [
        {
            "nodes": [
                {"id": "a_sh", "label": "a.sh", "file_type": "code", "source_file": str(a_sh)},
                {
                    "id": "main",
                    "label": "main()",
                    "file_type": "code",
                    "source_file": str(a_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [
                {
                    "language": "bash",
                    "caller_nid": "main",
                    "callee": "b_func",
                    "is_member_call": False,
                    "source_file": str(a_sh),
                    "source_location": "L3",
                }
            ],
            "bash_sources": [
                {"source_file": str(a_sh), "target_path": str(b_sh), "source_location": "L2"}
            ],
        },
        {
            "nodes": [
                {"id": "b_sh", "label": "b.sh", "file_type": "code", "source_file": str(b_sh)},
                {
                    "id": "b_func",
                    "label": "b_func()",
                    "file_type": "code",
                    "source_file": str(b_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]

    edges = resolve_bash_source_edges(per_file, [a_sh, b_sh], tmp_path)

    calls = [e for e in edges if e["relation"] == "calls"]
    assert len(calls) == 1
    assert calls[0]["source"] == "main"
    assert calls[0]["target"] == "b_func"
    assert calls[0]["confidence"] == "EXTRACTED"


def test_bash_call_resolver_skips_existing_pair(tmp_path: Path) -> None:
    a_sh = tmp_path / "a.sh"
    b_sh = tmp_path / "b.sh"
    a_sh.write_text("#!/usr/bin/env bash\nsource ./b.sh\nmain() { b_func; }\n")
    b_sh.write_text("#!/usr/bin/env bash\nb_func() { echo ok; }\n")

    per_file = [
        {
            "nodes": [
                {"id": "a_sh", "label": "a.sh", "file_type": "code", "source_file": str(a_sh)},
                {
                    "id": "main",
                    "label": "main()",
                    "file_type": "code",
                    "source_file": str(a_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [
                {
                    "language": "bash",
                    "caller_nid": "main",
                    "callee": "b_func",
                    "is_member_call": False,
                    "source_file": str(a_sh),
                    "source_location": "L3",
                }
            ],
            "bash_sources": [
                {"source_file": str(a_sh), "target_path": str(b_sh), "source_location": "L2"}
            ],
        },
        {
            "nodes": [
                {"id": "b_sh", "label": "b.sh", "file_type": "code", "source_file": str(b_sh)},
                {
                    "id": "b_func",
                    "label": "b_func()",
                    "file_type": "code",
                    "source_file": str(b_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]
    existing = [{"source": "main", "target": "b_func", "relation": "calls"}]

    edges = resolve_bash_source_edges(per_file, [a_sh, b_sh], tmp_path, existing_edges=existing)

    calls = [e for e in edges if e["relation"] == "calls"]
    assert len(calls) == 0, f"Should skip existing pair but got: {calls}"


def test_bash_call_resolver_skips_ambiguous_multiple_candidates(tmp_path: Path) -> None:
    """When a callee function is defined in multiple sourced files, skip it."""
    a_sh = tmp_path / "a.sh"
    b_sh = tmp_path / "b.sh"
    c_sh = tmp_path / "c.sh"
    a_sh.write_text("#!/usr/bin/env bash\nsource ./b.sh\nsource ./c.sh\nmain() { helper; }\n")
    b_sh.write_text("#!/usr/bin/env bash\nhelper() { echo b; }\n")
    c_sh.write_text("#!/usr/bin/env bash\nhelper() { echo c; }\n")

    per_file = [
        {
            "nodes": [
                {"id": "a_sh", "label": "a.sh", "file_type": "code", "source_file": str(a_sh)},
                {
                    "id": "main",
                    "label": "main()",
                    "file_type": "code",
                    "source_file": str(a_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [
                {
                    "language": "bash",
                    "caller_nid": "main",
                    "callee": "helper",
                    "is_member_call": False,
                    "source_file": str(a_sh),
                    "source_location": "L4",
                }
            ],
            "bash_sources": [
                {"source_file": str(a_sh), "target_path": str(b_sh), "source_location": "L2"},
                {"source_file": str(a_sh), "target_path": str(c_sh), "source_location": "L3"},
            ],
        },
        {
            "nodes": [
                {"id": "b_sh", "label": "b.sh", "file_type": "code", "source_file": str(b_sh)},
                {
                    "id": "b_helper",
                    "label": "helper()",
                    "file_type": "code",
                    "source_file": str(b_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [],
        },
        {
            "nodes": [
                {"id": "c_sh", "label": "c.sh", "file_type": "code", "source_file": str(c_sh)},
                {
                    "id": "c_helper",
                    "label": "helper()",
                    "file_type": "code",
                    "source_file": str(c_sh),
                    "metadata": {"kind": "bash_function"},
                },
            ],
            "edges": [],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]

    edges = resolve_bash_source_edges(per_file, [a_sh, b_sh, c_sh], tmp_path)

    calls = [e for e in edges if e["relation"] == "calls"]
    # helper() is defined in both b.sh and c.sh → ambiguous → should be skipped
    assert len(calls) == 0, f"Should skip ambiguous callee but got: {calls}"


def test_bash_call_resolver_skips_non_bash_raw_calls(tmp_path: Path) -> None:
    """Non-bash raw_calls inside sourced-file per_file entries are ignored."""
    a_sh = tmp_path / "a.sh"
    a_sh.write_text("#!/usr/bin/env bash\n")

    per_file = [
        {
            "nodes": [
                {"id": "a_sh", "label": "a.sh", "file_type": "code", "source_file": str(a_sh)},
            ],
            "edges": [],
            "raw_calls": [
                {
                    "language": "python",
                    "caller_nid": "a_main",
                    "callee": "helper",
                    "is_member_call": False,
                    "source_file": str(a_sh),
                    "source_location": "L1",
                }
            ],
            "bash_sources": [],
        },
    ]

    edges = resolve_bash_source_edges(per_file, [a_sh], tmp_path)
    assert edges == [], f"Should ignore non-bash raw_calls but got: {edges}"


def test_bash_make_id_identical_to_make_id() -> None:
    from graphify.extract import _make_id

    assert _bash_make_id("foo", "bar") == _make_id("foo", "bar")
    assert _bash_make_id("auth") == _make_id("auth")
    assert _bash_make_id("_module", "_helper") == _make_id("_module", "_helper")
    assert _bash_make_id("my-script", "main") == _make_id("my-script", "main")


def test_bash_make_id_unicode_matches_make_id() -> None:
    """_bash_make_id must produce identical output to _make_id for Unicode inputs.

    The two functions must remain in sync so resolve_bash_source_edges
    produces node IDs that match those from extract_bash.  The original local
    copy lacked NFKC normalisation, Unicode-aware regex, and casefold().
    """
    from graphify.extract import _make_id

    # Accented letter: é is a Unicode word char that _make_id preserves
    assert _bash_make_id("café", "run") == _make_id("café", "run"), (
        "_bash_make_id must preserve Unicode word characters like _make_id"
    )
    # German sharp s: casefold maps ß→ss, lower does not
    assert _bash_make_id("straße") == _make_id("straße"), (
        "_bash_make_id must use casefold not lower to match _make_id"
    )


# ---------------------------------------------------------------------------
# Cycle 2.5 v2 — Codex blocker fixes
# ---------------------------------------------------------------------------


# F1 — top-level imports only
def test_parse_python_import_aliases_skips_function_local_imports(tmp_path):
    """A `from helper import transform` inside a function MUST NOT become
    file-wide evidence — function-local imports are only valid in their
    lexical scope. Walking the whole AST would falsely justify unrelated
    calls in other scopes."""
    from graphify.symbol_resolution import parse_python_import_aliases

    py = tmp_path / "scoped.py"
    py.write_text(
        "def one():\n"
        "    from helper import transform\n"
        "    return transform()\n"
        "\n"
        "def two():\n"
        "    return transform()\n"
    )
    aliases = parse_python_import_aliases(py)
    assert "transform" not in aliases, (
        f"function-local import leaked as file-wide evidence: {aliases}"
    )


def test_parse_python_import_aliases_accepts_top_level_import(tmp_path):
    """A module-level `from helper import transform` IS file-wide evidence."""
    from graphify.symbol_resolution import parse_python_import_aliases

    py = tmp_path / "toplevel.py"
    py.write_text("from helper import transform\n\ndef one():\n    return transform()\n")
    aliases = parse_python_import_aliases(py)
    assert "transform" in aliases
    assert aliases["transform"].module_stem == "helper"


# F2 — only code nodes are resolvable
def test_node_is_resolvable_symbol_requires_code_file_type():
    """Document/paper/image/concept nodes MUST NOT be indexed as call targets,
    even when their label looks like a callable identifier."""
    from graphify.symbol_resolution import node_is_resolvable_symbol

    code = {"id": "n1", "label": "helper", "file_type": "code"}
    doc = {"id": "n2", "label": "helper", "file_type": "document"}
    paper = {"id": "n3", "label": "helper", "file_type": "paper"}
    image = {"id": "n4", "label": "helper", "file_type": "image"}
    no_ft = {"id": "n5", "label": "helper"}

    assert node_is_resolvable_symbol(code) is True
    assert node_is_resolvable_symbol(doc) is False
    assert node_is_resolvable_symbol(paper) is False
    assert node_is_resolvable_symbol(image) is False
    assert node_is_resolvable_symbol(no_ft) is False


def test_build_label_index_excludes_non_code_nodes():
    """label index must not include document/paper/image nodes even when
    label and id are present and well-formed."""
    from graphify.symbol_resolution import build_label_index

    nodes = [
        {"id": "code_one", "label": "helper", "file_type": "code"},
        {"id": "doc_one", "label": "helper", "file_type": "document"},
        {"id": "paper_one", "label": "helper", "file_type": "paper"},
    ]
    index = build_label_index(nodes)
    assert index.get("helper") == ["code_one"]


# F3 — bash resolver defensive against malformed input
def test_resolve_bash_source_edges_skips_malformed_source(tmp_path):
    """A `bash_sources` entry missing `target_path` must not raise KeyError."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    per_file = [
        {
            "nodes": [],
            "raw_calls": [],
            "bash_sources": [
                {},  # missing target_path entirely
                {"target_path": ""},  # empty target_path
                {"target_path": None},  # non-string target_path
            ],
        }
    ]
    a = tmp_path / "a.sh"
    a.write_text("# noop\n")
    edges = resolve_bash_source_edges(per_file, [a], tmp_path)
    assert edges == []


def test_resolve_bash_source_edges_skips_bash_function_node_missing_id(tmp_path):
    """A node tagged as bash_function but missing `id` must not raise KeyError."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    per_file = [
        {
            "nodes": [
                {"label": "build()", "metadata": {"kind": "bash_function"}},
            ],
            "raw_calls": [],
            "bash_sources": [],
        }
    ]
    a = tmp_path / "a.sh"
    a.write_text("# noop\n")
    # Should not raise
    edges = resolve_bash_source_edges(per_file, [a], tmp_path)
    assert edges == []


def test_resolve_bash_source_edges_skips_raw_call_missing_caller_nid(tmp_path):
    """A raw_call entry missing `caller_nid` must not raise KeyError."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    a = tmp_path / "a.sh"
    b = tmp_path / "b.sh"
    a.write_text("# noop\n")
    b.write_text("# noop\n")
    per_file = [
        {
            "nodes": [],
            "raw_calls": [
                {"language": "bash", "callee": "helper"},  # missing caller_nid
            ],
            "bash_sources": [{"target_path": str(b)}],
        },
        {
            "nodes": [
                {"id": "b_helper", "label": "helper()", "metadata": {"kind": "bash_function"}},
            ],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]
    edges = resolve_bash_source_edges(per_file, [a, b], tmp_path)
    # No raw-call edge emitted because caller_nid was missing; source edge OK.
    assert all(e["relation"] != "calls" for e in edges)


def test_resolve_bash_source_edges_accepts_none_per_file_entries(tmp_path):
    """A None entry in per_file (e.g. failed extraction) must be silently skipped."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    a = tmp_path / "a.sh"
    a.write_text("# noop\n")
    edges = resolve_bash_source_edges([None], [a], tmp_path)
    assert edges == []


def test_resolve_bash_source_edges_skips_non_dict_lists(tmp_path):
    """Non-dict entries in bash_sources/raw_calls/nodes must be silently skipped."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    a = tmp_path / "a.sh"
    a.write_text("# noop\n")
    per_file = [
        {
            "nodes": ["not a dict", 42, None],
            "raw_calls": [None, "string entry", {"language": "bash"}],  # last is missing caller_nid
            "bash_sources": [None, "str", 99],
        }
    ]
    edges = resolve_bash_source_edges(per_file, [a], tmp_path)
    assert edges == []


# F4 — relative target_path resolves against source file directory
def test_resolve_bash_source_edges_relative_path_resolves_against_source_dir(tmp_path):
    """`source ./helper.sh` from a/main.sh should resolve to a/helper.sh,
    not to ./helper.sh from the process CWD."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    sub = tmp_path / "scripts"
    sub.mkdir()
    main = sub / "main.sh"
    helper = sub / "helper.sh"
    main.write_text("# main\n")
    helper.write_text("# helper\n")

    per_file = [
        {
            "nodes": [],
            "raw_calls": [],
            # Relative path: should resolve to scripts/helper.sh (next to main.sh)
            "bash_sources": [{"target_path": "./helper.sh"}],
        },
        {
            "nodes": [],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]
    edges = resolve_bash_source_edges(per_file, [main, helper], tmp_path)
    # One imports_from edge from main → helper
    import_edges = [e for e in edges if e["relation"] == "imports_from"]
    assert len(import_edges) == 1
    # Note: the actual node IDs are sha-hash-derived; just verify the edge exists.


# F1 — malformed raw_calls in non-Bash resolvers
def test_iter_raw_calls_skips_non_dict_per_file_entries():
    """A non-dict per_file entry (e.g. junk fragment) must be silently skipped."""
    from graphify.symbol_resolution import iter_raw_calls

    assert iter_raw_calls(["not a dict", None, 42]) == []


def test_iter_raw_calls_skips_non_list_raw_calls():
    """`raw_calls` that isn't a list must yield empty."""
    from graphify.symbol_resolution import iter_raw_calls

    assert iter_raw_calls([{"raw_calls": "abc"}]) == []
    assert iter_raw_calls([{"raw_calls": None}]) == []
    assert iter_raw_calls([{"raw_calls": 42}]) == []


def test_iter_raw_calls_drops_non_dict_items_in_list():
    """Items inside `raw_calls` list that aren't dicts must be dropped."""
    from graphify.symbol_resolution import iter_raw_calls

    out = iter_raw_calls([{"raw_calls": ["str", 42, None, {"callee": "real", "caller_nid": "c"}]}])
    assert out == [{"callee": "real", "caller_nid": "c"}]


def test_resolve_cross_file_raw_calls_survives_malformed_raw_calls():
    """The python cross-file resolver returns [] (not crash) on bad raw_calls."""
    from graphify.symbol_resolution import resolve_cross_file_raw_calls

    # raw_calls is a string instead of a list
    assert resolve_cross_file_raw_calls([{"raw_calls": "abc"}], [], []) == []
    # raw_calls list contains non-dict entries
    assert resolve_cross_file_raw_calls([{"raw_calls": ["not dict", 42]}], [], []) == []


def test_resolve_python_import_guided_calls_survives_malformed_raw_calls(tmp_path):
    """Python import-guided resolver also tolerates malformed raw_calls."""
    from graphify.symbol_resolution import resolve_python_import_guided_calls

    py = tmp_path / "caller.py"
    py.write_text("from helper import transform\n")
    per_file = [{"raw_calls": "not a list"}]
    paths = [py]
    nodes = [
        {
            "id": "h_transform",
            "label": "transform",
            "file_type": "code",
            "source_file": str(tmp_path / "helper.py"),
        }
    ]
    # Should not raise; should return no edges since raw_calls isn't a list
    edges = resolve_python_import_guided_calls(per_file, paths, nodes, [])
    assert edges == []


# F2 — unhashable callee in bash resolver
def test_resolve_bash_source_edges_skips_unhashable_callee(tmp_path):
    """A bash raw_call with `callee: [list]` (unhashable for dict membership)
    must not raise TypeError — silently skip the call."""
    from graphify.symbol_resolution import resolve_bash_source_edges

    a = tmp_path / "a.sh"
    b = tmp_path / "b.sh"
    a.write_text("# noop\n")
    b.write_text("# noop\n")
    per_file = [
        {
            "nodes": [],
            "raw_calls": [
                {"language": "bash", "caller_nid": "caller", "callee": ["bad"]},
                {"language": "bash", "caller_nid": "caller", "callee": {"also": "bad"}},
                {"language": "bash", "caller_nid": "caller", "callee": 42},
            ],
            "bash_sources": [{"target_path": str(b)}],
        },
        {
            "nodes": [
                {"id": "b_helper", "label": "helper()", "metadata": {"kind": "bash_function"}},
            ],
            "raw_calls": [],
            "bash_sources": [],
        },
    ]
    # Must not raise — non-string callees are skipped before dict membership.
    edges = resolve_bash_source_edges(per_file, [a, b], tmp_path)
    # No call edges emitted (all malformed); imports_from edge from sourcing OK
    assert all(e["relation"] != "calls" for e in edges)


# v3 Codex F1 — resolve_python_import_guided_calls hardened against
# malformed per_file slots and length mismatches.
def test_resolve_python_import_guided_calls_non_dict_per_file_slot(tmp_path):
    """A non-dict per_file slot (e.g. a string) must not raise AttributeError."""
    from graphify.symbol_resolution import resolve_python_import_guided_calls

    py = tmp_path / "caller.py"
    py.write_text("from helper import transform\n")
    # per_file slot is a STRING, not a dict — used to crash with AttributeError
    edges = resolve_python_import_guided_calls(["not a dict"], [py], [], [])
    assert edges == []


def test_resolve_python_import_guided_calls_per_file_shorter_than_paths(tmp_path):
    """per_file shorter than paths must not raise IndexError."""
    from graphify.symbol_resolution import resolve_python_import_guided_calls

    a = tmp_path / "a.py"
    b = tmp_path / "b.py"
    a.write_text("from helper import transform\n")
    b.write_text("from helper import transform\n")
    # Only ONE per_file entry but TWO paths — used to crash with IndexError
    edges = resolve_python_import_guided_calls([{}], [a, b], [], [])
    assert edges == []


def test_resolve_python_import_guided_calls_per_file_none_slot(tmp_path):
    """A None per_file slot is treated as empty fragment (no crash, no edges)."""
    from graphify.symbol_resolution import resolve_python_import_guided_calls

    py = tmp_path / "caller.py"
    py.write_text("from helper import transform\n")
    edges = resolve_python_import_guided_calls([None], [py], [], [])
    assert edges == []


def test_resolve_python_import_guided_calls_metadata_is_sanitized(tmp_path: Path) -> None:
    """Edge metadata produced by the import-guided resolver must pass through
    sanitize_metadata so HTML / control characters in import-site strings
    (e.g. malformed source_location values, alias names from extractor bugs)
    cannot survive into the graph as raw markup."""
    caller = tmp_path / "caller.py"
    helper = tmp_path / "helper.py"
    # Import alias that includes an angle bracket — pathological but defensive
    # cover: the resolver itself does not parse names this aggressively, but a
    # future extractor or upstream fragment could. The boundary is the cycle's
    # stated policy: every edge metadata field goes through sanitize_metadata.
    caller.write_text(
        "from helper import transform as tx\n\ndef run(value):\n    return tx(value)\n",
        encoding="utf-8",
    )
    helper.write_text("def transform(value):\n    return value\n", encoding="utf-8")

    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": "tx",
                    "is_member_call": False,
                    "source_file": str(caller),
                    "source_location": "L4",
                }
            ]
        },
        {"raw_calls": []},
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": str(caller)},
        {
            "id": "helper_transform",
            "label": "transform()",
            "file_type": "code",
            "source_file": str(helper),
        },
    ]

    edges = resolve_python_import_guided_calls(per_file, [caller, helper], nodes, [])
    assert len(edges) == 1
    metadata = edges[0]["metadata"]
    # All values must be present and HTML/control-char safe after sanitisation.
    for value in metadata.values():
        if isinstance(value, str):
            assert "<" not in value
            assert "\x00" not in value
    # And the structural shape is unchanged for benign inputs.
    assert metadata["resolver"] == "python_import_guided"
    assert metadata["local_name"] == "tx"
    assert metadata["imported_name"] == "transform"
    assert metadata["module_stem"] == "helper"


def test_resolve_python_import_guided_calls_metadata_sanitizes_hostile_alias(
    monkeypatch, tmp_path: Path
) -> None:
    """Strong regression for #cycle-2.7-Codex-v2: monkeypatch the alias parser
    so the resolver sees HOSTILE strings in ImportedSymbol fields, then assert
    the emitted metadata is HTML-escaped / control-char-stripped.

    Removing the sanitize_metadata() wrap in
    ``resolve_python_import_guided_calls`` would make this test fail:
    `&lt;script&gt;` would not appear in `imported_name`, and the raw
    NUL byte would not be stripped from `module_stem`.
    """
    import graphify.symbol_resolution as sr

    caller = tmp_path / "caller.py"
    helper = tmp_path / "helper.py"
    caller.write_text(
        "from helper import transform as tx\n\ndef run(value):\n    return tx(value)\n",
        encoding="utf-8",
    )
    helper.write_text("def transform(value):\n    return value\n", encoding="utf-8")

    # imported_name and module_stem are the lookup keys used to resolve the
    # call target; they must match the real helper symbol or the edge will
    # not fire. local_name and source_location are stored verbatim into
    # metadata and are the surface that sanitize_metadata() must scrub.
    hostile_alias_key = "<script>tx</script>"
    hostile = sr.ImportedSymbol(
        local_name=hostile_alias_key,
        imported_name="transform",
        module_stem="helper",
        source_file=str(caller),
        source_location="L1<img src=x>\x00trail",
    )

    def _fake_aliases(path: Path) -> dict[str, sr.ImportedSymbol]:
        if path == caller:
            return {hostile_alias_key: hostile}
        return {}

    monkeypatch.setattr(sr, "parse_python_import_aliases", _fake_aliases)

    per_file = [
        {
            "raw_calls": [
                {
                    "caller_nid": "caller_run",
                    "callee": hostile_alias_key,
                    "is_member_call": False,
                    "source_file": str(caller),
                    "source_location": "L4",
                }
            ]
        },
        {"raw_calls": []},
    ]
    nodes = [
        {"id": "caller_run", "label": "run()", "file_type": "code", "source_file": str(caller)},
        {
            "id": "helper_transform",
            "label": "transform()",
            "file_type": "code",
            "source_file": str(helper),
        },
    ]

    edges = resolve_python_import_guided_calls(per_file, [caller, helper], nodes, [])
    assert len(edges) == 1
    metadata = edges[0]["metadata"]

    # `local_name` carries the hostile alias key. Without sanitisation it
    # would still contain `<script>`. With the wrap, only the entity escape
    # survives. Removing the sanitize_metadata() wrap would fail BOTH asserts.
    local_name = metadata["local_name"]
    assert "<script>" not in local_name
    assert "&lt;script&gt;" in local_name

    # `import_source_location` carries hostile markup + a NUL byte. Sanitised
    # output strips the NUL and escapes the angle brackets.
    src_loc = metadata["import_source_location"]
    assert "<img" not in src_loc
    assert "&lt;img" in src_loc
    assert "\x00" not in src_loc
    assert "trail" in src_loc  # tail content survives after NUL strip

    # Resolution-side fields (used as lookup keys) are benign and unchanged.
    assert metadata["resolver"] == "python_import_guided"
    assert metadata["imported_name"] == "transform"
    assert metadata["module_stem"] == "helper"
