"""Tests for KnowledgeGraph.from_source() and heuristic edge construction."""

from __future__ import annotations

from pathlib import Path
from textwrap import dedent

import pytest

from sanicode.graph.builder import KnowledgeGraph

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _write(tmp_path: Path, name: str, source: str) -> Path:
    """Write dedented source to a temp .py file and return its path."""
    p = tmp_path / name
    p.write_text(dedent(source), encoding="utf-8")
    return p


def _kinds(kg: KnowledgeGraph) -> dict[str, int]:
    """Return a mapping of node kind → count for the given graph."""
    counts: dict[str, int] = {}
    for _nid, attrs in kg._graph.nodes(data=True):
        k = attrs.get("kind", "unknown")
        counts[k] = counts.get(k, 0) + 1
    return counts


# ---------------------------------------------------------------------------
# Basic from_source tests
# ---------------------------------------------------------------------------


def test_from_source_single_file(tmp_path: Path) -> None:
    """Entry points and a sink are detected from a single Python file."""
    _write(
        tmp_path,
        "app.py",
        """\
        import os
        def handle_request(request):
            user_input = os.environ.get("USER_INPUT")
            result = eval(user_input)
        """,
    )
    kg = KnowledgeGraph.from_source(tmp_path / "app.py")

    counts = _kinds(kg)
    # request param → http_handler entry, os.environ.get → env_var entry
    assert counts.get("entry_point", 0) >= 2, f"expected ≥2 entry_point nodes, got {counts}"
    # eval → sink
    assert counts.get("sink", 0) >= 1, f"expected ≥1 sink node, got {counts}"
    # There must be at least one edge (heuristic data flow)
    assert kg.edge_count >= 1, "expected at least one data-flow edge"


def test_from_source_directory(tmp_path: Path) -> None:
    """Nodes from all .py files in a directory are collected into one graph."""
    _write(
        tmp_path,
        "handlers.py",
        """\
        def handle_login(request):
            pass
        """,
    )
    _write(
        tmp_path,
        "utils.py",
        """\
        import os
        def load_config():
            return os.environ.get("DB_URL")
        """,
    )
    kg = KnowledgeGraph.from_source(tmp_path)

    # Both files contribute entry-point nodes.
    assert kg.node_count >= 2, f"expected ≥2 nodes from two files, got {kg.node_count}"

    files_seen = {
        attrs["file"]
        for _, attrs in kg._graph.nodes(data=True)
        if attrs.get("file")
    }
    assert len(files_seen) == 2, f"expected nodes from 2 distinct files, got {files_seen}"


def test_from_source_with_sanitizer(tmp_path: Path) -> None:
    """When a sanitizer is present in the same function, edges route through it."""
    src = _write(
        tmp_path,
        "page.py",
        """\
        import html
        def render_page(request):
            name = request.args.get("name")
            safe_name = html.escape(name)
            return render_template("page.html", name=safe_name)
        """,
    )
    kg = KnowledgeGraph.from_source(src)

    counts = _kinds(kg)
    assert counts.get("entry_point", 0) >= 1, "expected entry_point node"
    assert counts.get("sanitizer", 0) >= 1, "expected sanitizer node"
    assert counts.get("sink", 0) >= 1, "expected sink node"

    # The sanitizer node must appear on a path between an entry and a sink,
    # i.e. it must have both an incoming and an outgoing edge.
    san_nodes = kg.nodes_by_kind("sanitizer")
    assert san_nodes, "no sanitizer nodes found"
    for san_id, _ in san_nodes:
        in_deg = kg._graph.in_degree(san_id)
        out_deg = kg._graph.out_degree(san_id)
        assert in_deg >= 1, f"sanitizer {san_id} has no incoming edge (not routed through)"
        assert out_deg >= 1, f"sanitizer {san_id} has no outgoing edge (not routed through)"

    # There must be no direct entry→sink edge that skips the sanitizer.
    entry_ids = {nid for nid, _ in kg.nodes_by_kind("entry_point")}
    sink_ids = {nid for nid, _ in kg.nodes_by_kind("sink")}
    for entry_id in entry_ids:
        for sink_id in sink_ids:
            assert not kg._graph.has_edge(entry_id, sink_id), (
                f"direct edge from {entry_id} to {sink_id} bypasses sanitizer"
            )


def test_from_source_empty_directory(tmp_path: Path) -> None:
    """An empty directory yields an empty graph with no crash."""
    kg = KnowledgeGraph.from_source(tmp_path)
    assert kg.node_count == 0
    assert kg.edge_count == 0


def test_from_source_nonexistent_raises(tmp_path: Path) -> None:
    """Passing a non-existent path raises FileNotFoundError."""
    with pytest.raises(FileNotFoundError):
        KnowledgeGraph.from_source(tmp_path / "does_not_exist.py")


# ---------------------------------------------------------------------------
# nodes_by_kind
# ---------------------------------------------------------------------------


def test_nodes_by_kind(tmp_path: Path) -> None:
    """nodes_by_kind filters strictly by the requested kind."""
    _write(
        tmp_path,
        "app.py",
        """\
        import html, os
        def view(request):
            raw = os.environ.get("X")
            safe = html.escape(raw)
            return render_template("t.html", v=safe)
        """,
    )
    kg = KnowledgeGraph.from_source(tmp_path / "app.py")

    entry_nodes = kg.nodes_by_kind("entry_point")
    sink_nodes = kg.nodes_by_kind("sink")
    san_nodes = kg.nodes_by_kind("sanitizer")

    # Each returned item is a (node_id, attrs) tuple with the right kind.
    for nid, attrs in entry_nodes:
        assert attrs["kind"] == "entry_point", f"unexpected kind for {nid}: {attrs['kind']}"
    for nid, attrs in sink_nodes:
        assert attrs["kind"] == "sink", f"unexpected kind for {nid}: {attrs['kind']}"
    for nid, attrs in san_nodes:
        assert attrs["kind"] == "sanitizer", f"unexpected kind for {nid}: {attrs['kind']}"

    # Non-existent kind returns empty list.
    assert kg.nodes_by_kind("trust_boundary") == []


# ---------------------------------------------------------------------------
# Edge attribute tests
# ---------------------------------------------------------------------------


def test_heuristic_edges_confidence(tmp_path: Path) -> None:
    """Every auto-generated edge carries confidence='heuristic'."""
    _write(
        tmp_path,
        "app.py",
        """\
        import os
        def handle(request):
            data = os.environ.get("KEY")
            eval(data)
        """,
    )
    kg = KnowledgeGraph.from_source(tmp_path / "app.py")

    assert kg.edge_count >= 1, "expected at least one edge"
    for u, v, edge_attrs in kg._graph.edges(data=True):
        assert edge_attrs.get("confidence") == "heuristic", (
            f"edge ({u} → {v}) missing confidence='heuristic': {edge_attrs}"
        )


# ---------------------------------------------------------------------------
# Syntax-error resilience
# ---------------------------------------------------------------------------


def test_from_source_skips_syntax_errors(tmp_path: Path) -> None:
    """A directory scan skips files with syntax errors and processes valid ones."""
    # Invalid Python.
    (tmp_path / "broken.py").write_text("def foo(:\n    pass\n", encoding="utf-8")

    # Valid Python with a detectable entry point.
    _write(
        tmp_path,
        "good.py",
        """\
        def handle_request(request):
            pass
        """,
    )

    with pytest.warns(UserWarning, match="syntax error"):
        kg = KnowledgeGraph.from_source(tmp_path)

    # The valid file's entry point must be present.
    assert kg.node_count >= 1, "expected at least one node from the valid file"
    entry_nodes = kg.nodes_by_kind("entry_point")
    assert entry_nodes, "expected entry_point node from good.py"

    # Confirm the entry point originates from the good file.
    good_file = str(tmp_path / "good.py")
    assert any(
        attrs["file"] == good_file for _, attrs in entry_nodes
    ), f"no entry_point from {good_file}"
