"""
Comprehensive tests for Phase 4 (Reasoning) modules:
  - pulse.graph.activation    (spreading activation via recursive CTE)
  - pulse.graph.causal_model  (DAG enforcement + confounder detection)
  - pulse.graph.counterfactual (Pearl Level 3 twin-network)
  - pulse.graph.analogy       (Structure Mapping Engine)

All tests use in-memory SQLite with the real schema from brain_db.
"""
import sys
import math
import sqlite3
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))


# ---------------------------------------------------------------------------
# Shared fixture: in-memory SQLite database that mirrors the brain_db schema
# ---------------------------------------------------------------------------

def _create_in_memory_db() -> sqlite3.Connection:
    """Create an in-memory SQLite connection with graph_nodes/graph_edges tables."""
    conn = sqlite3.connect(":memory:")
    conn.create_function("SQRT", 1, math.sqrt)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL;")
    conn.execute("PRAGMA synchronous=NORMAL;")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS graph_nodes (
            id TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT 'CONCEPT',
            text TEXT NOT NULL, source TEXT DEFAULT '', confidence REAL DEFAULT 0.5,
            domain TEXT NOT NULL DEFAULT 'general',
            date TEXT DEFAULT '', degree INTEGER DEFAULT 0, embedding BLOB DEFAULT NULL,
            metadata TEXT DEFAULT '{}'
        );
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS graph_edges (
            id INTEGER PRIMARY KEY AUTOINCREMENT, from_node TEXT NOT NULL,
            to_node TEXT NOT NULL, edge_type TEXT NOT NULL,
            confidence REAL DEFAULT 0.5, source_node TEXT DEFAULT '',
            metadata TEXT DEFAULT '{}',
            UNIQUE(from_node, edge_type, to_node)
        );
    """)
    return conn


@pytest.fixture()
def mem_conn():
    """Yield an in-memory DB and patch get_conn everywhere it's imported."""
    conn = _create_in_memory_db()
    with patch("pulse.graph.activation.get_conn", return_value=conn), \
         patch("pulse.graph.causal_model.get_conn", return_value=conn), \
         patch("pulse.graph.counterfactual.get_conn", return_value=conn), \
         patch("pulse.graph.analogy.get_conn", return_value=conn), \
         patch("pulse.core.event_bus.publish", return_value="mock-corr-id"):
        yield conn
    conn.close()


def _add_node(conn, node_id, text="", node_type="CONCEPT", domain="general", confidence=0.5):
    conn.execute(
        "INSERT OR IGNORE INTO graph_nodes (id, type, text, domain, confidence) VALUES (?,?,?,?,?)",
        (node_id, node_type, text or node_id, domain, confidence),
    )
    conn.commit()


def _add_edge(conn, from_n, to_n, edge_type="CAUSES", confidence=0.5):
    conn.execute(
        "INSERT OR IGNORE INTO graph_edges (from_node, to_node, edge_type, confidence) VALUES (?,?,?,?)",
        (from_n, to_n, edge_type, confidence),
    )
    conn.commit()


# =========================================================================
# 1. ACTIVATION TESTS — pulse.graph.activation
# =========================================================================

class TestSpreadingActivation:
    """Tests for spreading_activation()."""

    def test_empty_start_nodes(self, mem_conn):
        from pulse.graph.activation import spreading_activation
        result = spreading_activation([])
        assert result == []

    def test_single_isolated_node(self, mem_conn):
        """A single node with no edges returns just itself."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "A")
        result = spreading_activation(["A"])
        assert len(result) == 1
        assert result[0]["id"] == "A"
        assert result[0]["max_activation"] == 1.0
        assert result[0]["min_hops"] == 0

    def test_linear_chain_decay(self, mem_conn):
        """A->B->C should show decay at each hop."""
        from pulse.graph.activation import spreading_activation
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", confidence=1.0)
        _add_edge(mem_conn, "B", "C", confidence=1.0)

        result = spreading_activation(["A"], decay=0.5, threshold=0.01)
        by_id = {r["id"]: r for r in result}

        assert "A" in by_id
        assert by_id["A"]["max_activation"] == 1.0
        assert "B" in by_id
        # B activation = 1.0 * 1.0(conf) * 0.5(decay) * dampening
        # Dampening = 1/sqrt(degree_of_B).  B has 2 edges (A->B, B->C) so 1/sqrt(2)
        expected_b = 1.0 * 1.0 * 0.5 * (1.0 / math.sqrt(2))
        assert abs(by_id["B"]["max_activation"] - expected_b) < 0.01

    def test_decay_reduces_deeper_scores(self, mem_conn):
        """Nodes further from the start should have lower activation."""
        from pulse.graph.activation import spreading_activation
        nodes = ["N0", "N1", "N2", "N3"]
        for n in nodes:
            _add_node(mem_conn, n)
        for i in range(len(nodes) - 1):
            _add_edge(mem_conn, nodes[i], nodes[i + 1], confidence=1.0)

        result = spreading_activation(["N0"], decay=0.6, threshold=0.001)
        by_id = {r["id"]: r for r in result}

        # Each subsequent node should have strictly lower activation
        prev_act = 2.0  # sentinel
        for n in nodes:
            if n in by_id:
                assert by_id[n]["max_activation"] < prev_act
                prev_act = by_id[n]["max_activation"]

    def test_cycle_prevention(self, mem_conn):
        """A->B->C->A cycle should not loop forever; each node appears once."""
        from pulse.graph.activation import spreading_activation
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", confidence=1.0)
        _add_edge(mem_conn, "B", "C", confidence=1.0)
        _add_edge(mem_conn, "C", "A", confidence=1.0)

        result = spreading_activation(["A"], decay=0.8, threshold=0.01, max_hops=10)
        ids = [r["id"] for r in result]
        # Should not crash, and each id should be unique
        assert len(ids) == len(set(ids))

    def test_max_nodes_limit(self, mem_conn):
        """Result set should not exceed max_nodes."""
        from pulse.graph.activation import spreading_activation
        # Create a fan-out: hub -> 20 children
        _add_node(mem_conn, "hub")
        for i in range(20):
            nid = f"child_{i}"
            _add_node(mem_conn, nid)
            _add_edge(mem_conn, "hub", nid, confidence=1.0)

        result = spreading_activation(["hub"], decay=0.9, threshold=0.001, max_nodes=5)
        assert len(result) <= 5

    def test_max_hops_limit(self, mem_conn):
        """Activation should not traverse beyond max_hops."""
        from pulse.graph.activation import spreading_activation
        # Long chain: 0 -> 1 -> 2 -> ... -> 9
        for i in range(10):
            _add_node(mem_conn, f"L{i}")
        for i in range(9):
            _add_edge(mem_conn, f"L{i}", f"L{i+1}", confidence=1.0)

        result = spreading_activation(["L0"], decay=0.9, threshold=0.0001, max_hops=2, max_nodes=50)
        by_id = {r["id"]: r for r in result}
        # Nodes beyond 2 hops should be absent
        assert "L0" in by_id
        # L3 (3 hops) and beyond should NOT appear
        for i in range(3, 10):
            assert f"L{i}" not in by_id, f"L{i} should be beyond max_hops=2"

    def test_threshold_filters_weak_signals(self, mem_conn):
        """Very low confidence edges should be filtered when activation drops below threshold."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        _add_edge(mem_conn, "X", "Y", confidence=0.001)

        result = spreading_activation(["X"], decay=0.1, threshold=0.5)
        by_id = {r["id"]: r for r in result}
        # Y should be filtered because 1.0 * 0.001 * 0.1 * dampening << 0.5
        assert "Y" not in by_id

    def test_multiple_start_nodes(self, mem_conn):
        """Passing multiple start nodes should activate from all of them."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "S1")
        _add_node(mem_conn, "S2")
        _add_node(mem_conn, "T")
        _add_edge(mem_conn, "S1", "T", confidence=1.0)
        _add_edge(mem_conn, "S2", "T", confidence=1.0)

        result = spreading_activation(["S1", "S2"], decay=0.8, threshold=0.01)
        by_id = {r["id"]: r for r in result}
        assert "S1" in by_id
        assert "S2" in by_id
        assert "T" in by_id

    def test_nonexistent_start_node(self, mem_conn):
        """Starting from a node that doesn't exist should return empty."""
        from pulse.graph.activation import spreading_activation
        result = spreading_activation(["DOESNT_EXIST"])
        assert result == []

    def test_result_enriched_with_node_data(self, mem_conn):
        """Results should include text, type, confidence from graph_nodes."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "R1", text="Robot uprising", node_type="EVENT", confidence=0.9)
        result = spreading_activation(["R1"])
        assert len(result) == 1
        assert result[0]["text"] == "Robot uprising"
        assert result[0]["type"] == "EVENT"


# =========================================================================
# 2. CAUSAL MODEL TESTS — pulse.graph.causal_model
# =========================================================================

class TestEnforceDAG:
    """Tests for enforce_dag()."""

    def test_no_cycle_simple(self, mem_conn):
        """Adding a non-cyclic edge should succeed."""
        from pulse.graph.causal_model import enforce_dag
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        assert enforce_dag("A", "B", "CAUSES") is True

    def test_direct_cycle_detected(self, mem_conn):
        """A->B already exists; adding B->A should raise CausalViolationError."""
        from pulse.graph.causal_model import enforce_dag, CausalViolationError
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES")

        with pytest.raises(CausalViolationError, match="Causal cycle detected"):
            enforce_dag("B", "A", "CAUSES")

    def test_transitive_cycle_detected(self, mem_conn):
        """A->B->C already exists; adding C->A should raise CausalViolationError."""
        from pulse.graph.causal_model import enforce_dag, CausalViolationError
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES")
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES")

        with pytest.raises(CausalViolationError, match="Causal cycle detected"):
            enforce_dag("C", "A", "CAUSES")

    def test_prevents_also_enforced(self, mem_conn):
        """PREVENTS edges should also participate in cycle detection."""
        from pulse.graph.causal_model import enforce_dag, CausalViolationError
        _add_node(mem_conn, "P")
        _add_node(mem_conn, "Q")
        _add_edge(mem_conn, "P", "Q", edge_type="PREVENTS")

        with pytest.raises(CausalViolationError):
            enforce_dag("Q", "P", "PREVENTS")

    def test_non_causal_edge_skips_check(self, mem_conn):
        """Non-causal edge types (e.g., RELATED_TO) bypass DAG enforcement."""
        from pulse.graph.causal_model import enforce_dag
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        _add_edge(mem_conn, "X", "Y", edge_type="CAUSES")

        # RELATED_TO should not trigger cycle check even with existing causal path
        assert enforce_dag("Y", "X", "RELATED_TO") is True

    def test_long_transitive_cycle(self, mem_conn):
        """A->B->C->D->E; adding E->A should fail."""
        from pulse.graph.causal_model import enforce_dag, CausalViolationError
        for n in "ABCDE":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES")
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES")
        _add_edge(mem_conn, "C", "D", edge_type="CAUSES")
        _add_edge(mem_conn, "D", "E", edge_type="CAUSES")

        with pytest.raises(CausalViolationError):
            enforce_dag("E", "A", "CAUSES")

    def test_parallel_paths_no_cycle(self, mem_conn):
        """A->C and A->B->C should not be a cycle; adding A->C is fine."""
        from pulse.graph.causal_model import enforce_dag
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES")
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES")

        # A->C is not a cycle (C cannot reach A)
        assert enforce_dag("A", "C", "CAUSES") is True


class TestAddCausalEdge:
    """Tests for add_causal_edge()."""

    def test_positive_weight_creates_causes(self, mem_conn):
        """Positive causal_weight should create a CAUSES edge."""
        from pulse.graph.causal_model import add_causal_edge
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        add_causal_edge("X", "Y", 0.7)

        row = mem_conn.execute(
            "SELECT * FROM graph_edges WHERE from_node='X' AND to_node='Y'"
        ).fetchone()
        assert row is not None
        assert row["edge_type"] == "CAUSES"
        assert abs(row["confidence"] - 0.7) < 0.001

    def test_negative_weight_creates_prevents(self, mem_conn):
        """Negative causal_weight should create a PREVENTS edge."""
        from pulse.graph.causal_model import add_causal_edge
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        add_causal_edge("X", "Y", -0.4)

        row = mem_conn.execute(
            "SELECT * FROM graph_edges WHERE from_node='X' AND to_node='Y'"
        ).fetchone()
        assert row is not None
        assert row["edge_type"] == "PREVENTS"
        assert abs(row["confidence"] - 0.4) < 0.001

    def test_weight_clamped_to_bounds(self, mem_conn):
        """Weights outside [-1, 1] should be clamped."""
        from pulse.graph.causal_model import add_causal_edge
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        _add_node(mem_conn, "C")
        _add_node(mem_conn, "D")

        add_causal_edge("A", "B", 5.0)  # clamped to 1.0
        row = mem_conn.execute(
            "SELECT confidence FROM graph_edges WHERE from_node='A' AND to_node='B'"
        ).fetchone()
        assert abs(row["confidence"] - 1.0) < 0.001

        add_causal_edge("C", "D", -3.0)  # clamped to -1.0, abs -> 1.0
        row = mem_conn.execute(
            "SELECT confidence FROM graph_edges WHERE from_node='C' AND to_node='D'"
        ).fetchone()
        assert abs(row["confidence"] - 1.0) < 0.001

    def test_cycle_rejected_via_add_causal_edge(self, mem_conn):
        """add_causal_edge should raise CausalViolationError when cycle would form."""
        from pulse.graph.causal_model import add_causal_edge, CausalViolationError
        _add_node(mem_conn, "M")
        _add_node(mem_conn, "N")
        add_causal_edge("M", "N", 0.5)

        with pytest.raises(CausalViolationError):
            add_causal_edge("N", "M", 0.5)

    def test_upsert_takes_higher_confidence(self, mem_conn):
        """ON CONFLICT should keep the higher confidence."""
        from pulse.graph.causal_model import add_causal_edge
        _add_node(mem_conn, "U")
        _add_node(mem_conn, "V")
        add_causal_edge("U", "V", 0.3)
        add_causal_edge("U", "V", 0.9)

        row = mem_conn.execute(
            "SELECT confidence FROM graph_edges WHERE from_node='U' AND to_node='V'"
        ).fetchone()
        assert abs(row["confidence"] - 0.9) < 0.001

    def test_zero_weight_is_causes(self, mem_conn):
        """Zero weight (>= 0) should produce CAUSES edge."""
        from pulse.graph.causal_model import add_causal_edge
        _add_node(mem_conn, "Z1")
        _add_node(mem_conn, "Z2")
        add_causal_edge("Z1", "Z2", 0.0)

        row = mem_conn.execute(
            "SELECT edge_type FROM graph_edges WHERE from_node='Z1' AND to_node='Z2'"
        ).fetchone()
        assert row["edge_type"] == "CAUSES"


class TestDetectConfounders:
    """Tests for detect_confounders()."""

    def test_no_confounders_in_empty_graph(self, mem_conn):
        """Empty graph should produce no confounders."""
        from pulse.graph.causal_model import detect_confounders
        result = detect_confounders()
        assert result == []

    def test_two_shared_causes_not_enough(self, mem_conn):
        """Two nodes sharing only 2 common causes should NOT trigger confounder."""
        from pulse.graph.causal_model import detect_confounders
        for n in ["C1", "C2", "E1", "E2"]:
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "C1", "E1", edge_type="CAUSES")
        _add_edge(mem_conn, "C1", "E2", edge_type="CAUSES")
        _add_edge(mem_conn, "C2", "E1", edge_type="CAUSES")
        _add_edge(mem_conn, "C2", "E2", edge_type="CAUSES")

        result = detect_confounders()
        assert result == []

    def test_three_shared_causes_triggers_confounder(self, mem_conn):
        """Two nodes sharing 3+ common causes should create a latent confounder."""
        from pulse.graph.causal_model import detect_confounders
        # E1, E2 share causes C1, C2, C3
        for n in ["C1", "C2", "C3", "E1", "E2"]:
            _add_node(mem_conn, n)
        for c in ["C1", "C2", "C3"]:
            _add_edge(mem_conn, c, "E1", edge_type="CAUSES")
            _add_edge(mem_conn, c, "E2", edge_type="CAUSES")

        result = detect_confounders()
        assert len(result) == 1
        pair = result[0]
        # The pair ordering is e1.to_node < e2.to_node in the SQL
        assert set(pair) == {"E1", "E2"}

        # Verify latent confounder node was created
        latent = mem_conn.execute(
            "SELECT * FROM graph_nodes WHERE type='LATENT'"
        ).fetchone()
        assert latent is not None
        assert "E1" in latent["id"] and "E2" in latent["id"]

        # Verify confounder edges exist
        edges = mem_conn.execute(
            "SELECT * FROM graph_edges WHERE from_node=?", (latent["id"],)
        ).fetchall()
        assert len(edges) == 2

    def test_idempotent_confounder_detection(self, mem_conn):
        """Running detect_confounders twice should not duplicate the latent node."""
        from pulse.graph.causal_model import detect_confounders
        for n in ["C1", "C2", "C3", "E1", "E2"]:
            _add_node(mem_conn, n)
        for c in ["C1", "C2", "C3"]:
            _add_edge(mem_conn, c, "E1", edge_type="CAUSES")
            _add_edge(mem_conn, c, "E2", edge_type="CAUSES")

        result1 = detect_confounders()
        result2 = detect_confounders()

        # Second run finds the latent already exists, so returns empty
        assert len(result1) == 1
        assert len(result2) == 0

        latent_count = mem_conn.execute(
            "SELECT COUNT(*) as cnt FROM graph_nodes WHERE type='LATENT'"
        ).fetchone()["cnt"]
        assert latent_count == 1

    def test_prevents_edges_also_counted(self, mem_conn):
        """PREVENTS edges should also count toward shared causes."""
        from pulse.graph.causal_model import detect_confounders
        for n in ["P1", "P2", "P3", "T1", "T2"]:
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "P1", "T1", edge_type="CAUSES")
        _add_edge(mem_conn, "P1", "T2", edge_type="CAUSES")
        _add_edge(mem_conn, "P2", "T1", edge_type="PREVENTS")
        _add_edge(mem_conn, "P2", "T2", edge_type="PREVENTS")
        _add_edge(mem_conn, "P3", "T1", edge_type="CAUSES")
        _add_edge(mem_conn, "P3", "T2", edge_type="CAUSES")

        result = detect_confounders()
        assert len(result) == 1
        assert set(result[0]) == {"T1", "T2"}


# =========================================================================
# 3. COUNTERFACTUAL TESTS — pulse.graph.counterfactual
# =========================================================================

class TestBuildShadowGraph:
    """Tests for build_shadow_graph()."""

    def test_empty_graph(self, mem_conn):
        """Nonexistent focal node returns empty graph."""
        from pulse.graph.counterfactual import build_shadow_graph
        G = build_shadow_graph("NOTHING")
        assert len(G.nodes) == 0
        assert len(G.edges) == 0

    def test_simple_chain(self, mem_conn):
        """A->B->C should produce a 3-node DiGraph."""
        from pulse.graph.counterfactual import build_shadow_graph
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES", confidence=0.6)

        G = build_shadow_graph("A", radius=3)
        assert "A" in G.nodes
        assert "B" in G.nodes
        assert "C" in G.nodes
        assert G.has_edge("A", "B")
        assert G.has_edge("B", "C")

    def test_prevents_edges_negative_weight(self, mem_conn):
        """PREVENTS edges should have negative weight in the shadow graph."""
        from pulse.graph.counterfactual import build_shadow_graph
        _add_node(mem_conn, "P")
        _add_node(mem_conn, "Q")
        _add_edge(mem_conn, "P", "Q", edge_type="PREVENTS", confidence=0.7)

        G = build_shadow_graph("P", radius=2)
        assert G.has_edge("P", "Q")
        assert G["P"]["Q"]["weight"] == -0.7

    def test_radius_limits_traversal(self, mem_conn):
        """Nodes beyond the radius should not appear."""
        from pulse.graph.counterfactual import build_shadow_graph
        # Chain: A->B->C->D->E
        for n in "ABCDE":
            _add_node(mem_conn, n)
        for a, b in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")]:
            _add_edge(mem_conn, a, b, edge_type="CAUSES", confidence=1.0)

        G = build_shadow_graph("A", radius=2)
        assert "A" in G.nodes
        assert "B" in G.nodes
        assert "C" in G.nodes
        # D and E are 3+ hops from A
        # (depends on whether the recursive CTE catches them — radius=2 means hops < 2)
        # The CTE starts at hop=0 and recurses while hops < radius, so max reachable hop = radius-1 = 1
        # Actually, focal node is hop 0, neighbors are hop 1, so radius=2 means hops 0 and 1 from focal.
        # But since the query also traverses backwards, nodes at distance <=2 should be included.

    def test_bidirectional_traversal(self, mem_conn):
        """Shadow graph traverses both outgoing and incoming edges."""
        from pulse.graph.counterfactual import build_shadow_graph
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        _add_node(mem_conn, "Z")
        _add_edge(mem_conn, "X", "Y", edge_type="CAUSES", confidence=0.9)
        _add_edge(mem_conn, "Z", "Y", edge_type="CAUSES", confidence=0.8)

        # Focal is Y; should find both X and Z via incoming edges
        G = build_shadow_graph("Y", radius=2)
        assert "X" in G.nodes
        assert "Z" in G.nodes
        assert "Y" in G.nodes

    def test_non_causal_edges_excluded(self, mem_conn):
        """Only CAUSES and PREVENTS edges should be included."""
        from pulse.graph.counterfactual import build_shadow_graph
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        _add_edge(mem_conn, "A", "B", edge_type="RELATED_TO", confidence=0.9)

        G = build_shadow_graph("A", radius=3)
        # A is the focal node so it will be in the CTE, but B won't be reachable
        # because the CTE only follows CAUSES/PREVENTS edges
        assert not G.has_edge("A", "B")


class TestSimulateIntervention:
    """Tests for simulate_intervention()."""

    def test_disconnected_nodes_error(self, mem_conn):
        """Nodes that are disconnected should return an error dict."""
        from pulse.graph.counterfactual import simulate_intervention
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        # No edge between them
        result = simulate_intervention("B", "A", 1.0)
        assert "error" in result

    def test_nodes_not_found_error(self, mem_conn):
        """Nonexistent nodes should return an error."""
        from pulse.graph.counterfactual import simulate_intervention
        result = simulate_intervention("TARGET", "DOES_NOT_EXIST", 1.0)
        assert "error" in result

    def test_simple_causal_chain(self, mem_conn):
        """do(A=1.0) in A->B->C should propagate through the chain."""
        from pulse.graph.counterfactual import simulate_intervention
        for n in "ABC":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES", confidence=0.6)

        result = simulate_intervention("C", "A", 1.0, radius=5)
        assert "error" not in result
        assert result["intervention"] == {"A": 1.0}
        assert result["target_node"] == "C"
        # B = A_value * edge_weight = 1.0 * 0.8 = 0.8
        # C = B_value * edge_weight = 0.8 * 0.6 = 0.48
        assert abs(result["predicted_target_value"] - 0.48) < 0.01

    def test_intervention_severs_incoming_edges(self, mem_conn):
        """do(B=val) should sever all incoming edges to B."""
        from pulse.graph.counterfactual import simulate_intervention
        for n in "ABCD":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=0.9)
        _add_edge(mem_conn, "B", "C", edge_type="CAUSES", confidence=0.5)
        _add_edge(mem_conn, "C", "D", edge_type="CAUSES", confidence=0.5)

        # Intervene on B with value 0.5
        result = simulate_intervention("D", "B", 0.5, radius=5)
        assert "error" not in result
        # B is set to 0.5 (incoming from A is severed)
        # C = 0.5 * 0.5 = 0.25
        # D = 0.25 * 0.5 = 0.125
        assert abs(result["predicted_target_value"] - 0.125) < 0.01

    def test_prevents_edge_negative_propagation(self, mem_conn):
        """PREVENTS edges should propagate negative weight."""
        from pulse.graph.counterfactual import simulate_intervention
        _add_node(mem_conn, "X")
        _add_node(mem_conn, "Y")
        _add_edge(mem_conn, "X", "Y", edge_type="PREVENTS", confidence=0.5)

        result = simulate_intervention("Y", "X", 1.0, radius=3)
        assert "error" not in result
        # Y = X_val * (-0.5) = -0.5
        assert abs(result["predicted_target_value"] - (-0.5)) < 0.01

    def test_value_squashed_to_bounds(self, mem_conn):
        """Cascading high weights should be squashed to [-1, 1]."""
        from pulse.graph.counterfactual import simulate_intervention
        for n in "ABCD":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=1.0)
        _add_edge(mem_conn, "A", "C", edge_type="CAUSES", confidence=1.0)
        # Both B and C cause D with full confidence
        _add_edge(mem_conn, "B", "D", edge_type="CAUSES", confidence=1.0)
        _add_edge(mem_conn, "C", "D", edge_type="CAUSES", confidence=1.0)

        result = simulate_intervention("D", "A", 1.0, radius=5)
        assert "error" not in result
        # D = B*1.0 + C*1.0 = 1.0 + 1.0 = 2.0, squashed to 1.0
        assert result["predicted_target_value"] <= 1.0

    def test_full_cascade_dict_returned(self, mem_conn):
        """The result should include a full_cascade mapping of all node values."""
        from pulse.graph.counterfactual import simulate_intervention
        for n in "AB":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=0.5)

        result = simulate_intervention("B", "A", 1.0, radius=3)
        assert "full_cascade" in result
        assert "A" in result["full_cascade"]
        assert result["full_cascade"]["A"] == 1.0

    def test_no_causal_path_returns_error(self, mem_conn):
        """If there's no directed path from do_variable to target, return error."""
        from pulse.graph.counterfactual import simulate_intervention
        _add_node(mem_conn, "A")
        _add_node(mem_conn, "B")
        _add_node(mem_conn, "C")
        # A->B and C isolated but in same neighborhood via reverse (C->A exists)
        _add_edge(mem_conn, "A", "B", edge_type="CAUSES", confidence=0.5)
        _add_edge(mem_conn, "C", "A", edge_type="CAUSES", confidence=0.5)

        # do(A) trying to reach C: no forward path from A to C
        result = simulate_intervention("C", "A", 1.0, radius=5)
        assert "error" in result


# =========================================================================
# 4. ANALOGY TESTS — pulse.graph.analogy
# =========================================================================

class TestBuildAnalogy:
    """Tests for build_analogy()."""

    def test_empty_graph_no_match(self, mem_conn):
        """Nonexistent nodes should return no match."""
        from pulse.graph.analogy import build_analogy
        result = build_analogy("GHOST_A", "GHOST_B")
        assert result["analogy_score"] == 0.0
        assert result["match"] is False

    def test_one_node_missing(self, mem_conn):
        """If one node exists but the other doesn't, no match."""
        from pulse.graph.analogy import build_analogy
        _add_node(mem_conn, "EXISTS")
        _add_edge(mem_conn, "EXISTS", "EXISTS", edge_type="CAUSES", confidence=0.5)  # self-loop for traversal
        result = build_analogy("EXISTS", "NOPE")
        assert result["match"] is False

    def test_isomorphic_structures_match(self, mem_conn):
        """Two structurally identical subgraphs should match."""
        from pulse.graph.analogy import build_analogy
        # Domain 1: A1 -> B1 -> C1
        _add_node(mem_conn, "A1", domain="physics")
        _add_node(mem_conn, "B1", domain="physics")
        _add_node(mem_conn, "C1", domain="physics")
        _add_edge(mem_conn, "A1", "B1", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "B1", "C1", edge_type="CAUSES", confidence=0.7)

        # Domain 2: A2 -> B2 -> C2 (same topology)
        _add_node(mem_conn, "A2", domain="biology")
        _add_node(mem_conn, "B2", domain="biology")
        _add_node(mem_conn, "C2", domain="biology")
        _add_edge(mem_conn, "A2", "B2", edge_type="CAUSES", confidence=0.6)
        _add_edge(mem_conn, "B2", "C2", edge_type="CAUSES", confidence=0.5)

        result = build_analogy("A1", "A2", radius=3)
        # The match depends on subgraph isomorphism finding; either match or not
        # If topology is identical, subgraph_is_isomorphic should be True
        if result["match"]:
            assert result["analogy_score"] > 0.0
            assert "mapping" in result

    def test_cross_domain_boost(self, mem_conn):
        """Cross-domain analogy should get score=0.8, same-domain gets 0.6."""
        from pulse.graph.analogy import build_analogy

        # Same domain: both 'physics'
        _add_node(mem_conn, "SD1", domain="physics")
        _add_node(mem_conn, "SD2", domain="physics")
        _add_node(mem_conn, "SD3", domain="physics")
        _add_node(mem_conn, "SD4", domain="physics")
        _add_edge(mem_conn, "SD1", "SD2", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "SD3", "SD4", edge_type="CAUSES", confidence=0.8)

        # Since SD1's subgraph is {SD1->SD2} and SD3's is {SD3->SD4}, they are
        # isomorphic (both are single-edge graphs).
        result_same = build_analogy("SD1", "SD3", radius=2)

        # Cross domain
        _add_node(mem_conn, "CD1", domain="physics")
        _add_node(mem_conn, "CD2", domain="physics")
        _add_node(mem_conn, "CD3", domain="biology")
        _add_node(mem_conn, "CD4", domain="biology")
        _add_edge(mem_conn, "CD1", "CD2", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "CD3", "CD4", edge_type="CAUSES", confidence=0.8)

        result_cross = build_analogy("CD1", "CD3", radius=2)

        if result_same["match"] and result_cross["match"]:
            assert result_same["analogy_score"] == 0.6
            assert result_cross["analogy_score"] == 0.8
            assert result_cross["cross_domain"] is True

    def test_non_isomorphic_no_match(self, mem_conn):
        """Structurally different subgraphs should not match."""
        from pulse.graph.analogy import build_analogy
        # Graph 1: A -> B -> C (chain)
        _add_node(mem_conn, "NI_A")
        _add_node(mem_conn, "NI_B")
        _add_node(mem_conn, "NI_C")
        _add_edge(mem_conn, "NI_A", "NI_B", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "NI_B", "NI_C", edge_type="CAUSES", confidence=0.7)

        # Graph 2: D -> E, D -> F (fork, not chain)
        _add_node(mem_conn, "NI_D")
        _add_node(mem_conn, "NI_E")
        _add_node(mem_conn, "NI_F")
        _add_edge(mem_conn, "NI_D", "NI_E", edge_type="CAUSES", confidence=0.8)
        _add_edge(mem_conn, "NI_D", "NI_F", edge_type="CAUSES", confidence=0.7)

        result = build_analogy("NI_A", "NI_D", radius=3)
        # A chain (A->B->C) vs a fork (D->E, D->F): subgraph iso might still match
        # if one is a subgraph of the other. In this case, the chain has 3 nodes 2 edges,
        # the fork has 3 nodes 2 edges. They are NOT isomorphic as directed graphs
        # (chain has max out-degree 1, fork has max out-degree 2).
        # However subgraph_is_isomorphic checks if G_b is a subgraph of G_a.
        # Since both have 3 nodes and 2 edges with different structure, it depends on
        # the edge_match function. Either way, we verify the function runs without error.
        assert isinstance(result["match"], bool)

    def test_edge_match_requires_same_sign(self, mem_conn):
        """Edges must match in sign (CAUSES vs PREVENTS) for analogy."""
        from pulse.graph.analogy import build_analogy
        # Graph 1: A -> B (CAUSES, positive weight)
        _add_node(mem_conn, "EM_A")
        _add_node(mem_conn, "EM_B")
        _add_edge(mem_conn, "EM_A", "EM_B", edge_type="CAUSES", confidence=0.8)

        # Graph 2: C -> D (PREVENTS, negative weight)
        _add_node(mem_conn, "EM_C")
        _add_node(mem_conn, "EM_D")
        _add_edge(mem_conn, "EM_C", "EM_D", edge_type="PREVENTS", confidence=0.8)

        result = build_analogy("EM_A", "EM_C", radius=2)
        # CAUSES (positive) vs PREVENTS (negative) should not match via edge_match
        assert result["match"] is False

    def test_same_sign_edges_match(self, mem_conn):
        """Two single-edge CAUSES graphs should be isomorphic."""
        from pulse.graph.analogy import build_analogy
        _add_node(mem_conn, "SS_A")
        _add_node(mem_conn, "SS_B")
        _add_edge(mem_conn, "SS_A", "SS_B", edge_type="CAUSES", confidence=0.7)

        _add_node(mem_conn, "SS_C")
        _add_node(mem_conn, "SS_D")
        _add_edge(mem_conn, "SS_C", "SS_D", edge_type="CAUSES", confidence=0.5)

        result = build_analogy("SS_A", "SS_C", radius=2)
        assert result["match"] is True
        assert result["analogy_score"] > 0.0

    def test_analogy_creates_edges(self, mem_conn):
        """Successful analogy should insert ANALOGOUS_TO edges."""
        from pulse.graph.analogy import build_analogy
        _add_node(mem_conn, "AE_A")
        _add_node(mem_conn, "AE_B")
        _add_edge(mem_conn, "AE_A", "AE_B", edge_type="CAUSES", confidence=0.5)

        _add_node(mem_conn, "AE_C")
        _add_node(mem_conn, "AE_D")
        _add_edge(mem_conn, "AE_C", "AE_D", edge_type="CAUSES", confidence=0.5)

        result = build_analogy("AE_A", "AE_C", radius=2)
        if result["match"]:
            # Check for bidirectional ANALOGOUS_TO edges
            fwd = mem_conn.execute(
                "SELECT * FROM graph_edges WHERE from_node='AE_A' AND to_node='AE_C' AND edge_type='ANALOGOUS_TO'"
            ).fetchone()
            rev = mem_conn.execute(
                "SELECT * FROM graph_edges WHERE from_node='AE_C' AND to_node='AE_A' AND edge_type='ANALOGOUS_TO'"
            ).fetchone()
            assert fwd is not None
            assert rev is not None

    def test_large_graph_size_guard(self, mem_conn):
        """Graphs with > 100 nodes should short-circuit to no match."""
        from pulse.graph.analogy import build_analogy

        # We can't easily create 100+ reachable nodes in the DB for this test
        # so we mock build_shadow_graph to return a large graph
        import networkx as nx
        big_graph = nx.DiGraph()
        for i in range(101):
            big_graph.add_node(f"N{i}")
        for i in range(100):
            big_graph.add_edge(f"N{i}", f"N{i+1}", weight=0.5)

        with patch("pulse.graph.analogy.build_shadow_graph", return_value=big_graph):
            result = build_analogy("N0", "N50", radius=2)
            assert result["match"] is False
            assert result["analogy_score"] == 0.0


# =========================================================================
# 5. INTEGRATION / CROSS-MODULE TESTS
# =========================================================================

class TestCrossModuleIntegration:
    """Tests combining multiple Phase 4 modules."""

    def test_causal_edge_then_counterfactual(self, mem_conn):
        """Add causal edges, then run counterfactual intervention."""
        from pulse.graph.causal_model import add_causal_edge
        from pulse.graph.counterfactual import simulate_intervention

        _add_node(mem_conn, "Rain")
        _add_node(mem_conn, "Wet")
        _add_node(mem_conn, "Slip")

        add_causal_edge("Rain", "Wet", 0.9)
        add_causal_edge("Wet", "Slip", 0.7)

        result = simulate_intervention("Slip", "Rain", 1.0, radius=5)
        assert "error" not in result
        # Slip = Wet * 0.7, Wet = Rain * 0.9 = 0.9, Slip = 0.63
        assert abs(result["predicted_target_value"] - 0.63) < 0.01

    def test_activation_respects_causal_edges(self, mem_conn):
        """Spreading activation should traverse edges added by add_causal_edge."""
        from pulse.graph.causal_model import add_causal_edge
        from pulse.graph.activation import spreading_activation

        _add_node(mem_conn, "Cause")
        _add_node(mem_conn, "Effect")
        add_causal_edge("Cause", "Effect", 0.8)

        result = spreading_activation(["Cause"], decay=0.9, threshold=0.01)
        by_id = {r["id"]: r for r in result}
        assert "Effect" in by_id

    def test_confounder_then_counterfactual(self, mem_conn):
        """After confounder detection, the latent node should be usable in counterfactual."""
        from pulse.graph.causal_model import add_causal_edge, detect_confounders
        from pulse.graph.counterfactual import build_shadow_graph

        for n in ["C1", "C2", "C3", "E1", "E2"]:
            _add_node(mem_conn, n)
        for c in ["C1", "C2", "C3"]:
            add_causal_edge(c, "E1", 0.5)
            add_causal_edge(c, "E2", 0.5)

        pairs = detect_confounders()
        assert len(pairs) == 1

        # The latent confounder should appear in the shadow graph of E1
        G = build_shadow_graph("E1", radius=3)
        latent_nodes = [n for n in G.nodes if n.startswith("LATENT")]
        assert len(latent_nodes) >= 1


# =========================================================================
# 6. ERROR HANDLING & EDGE CASES
# =========================================================================

class TestErrorHandling:
    """Edge cases and error handling across all modules."""

    def test_activation_with_self_loop(self, mem_conn):
        """A node with a self-loop should not cause infinite recursion."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "LOOP")
        _add_edge(mem_conn, "LOOP", "LOOP", confidence=1.0)

        # The INSTR path check should prevent revisiting
        result = spreading_activation(["LOOP"], decay=0.9, threshold=0.01, max_hops=10)
        assert len(result) >= 1
        # LOOP should appear exactly once
        loop_entries = [r for r in result if r["id"] == "LOOP"]
        assert len(loop_entries) == 1

    def test_causal_edge_self_loop_rejected(self, mem_conn):
        """Adding a causal self-loop A->A should be caught as a cycle.

        The CTE base case starts at to_node and the final SELECT checks for
        from_node.  When from_node == to_node the base case immediately
        satisfies ``WHERE node_id = ?``, so enforce_dag correctly raises.
        """
        from pulse.graph.causal_model import enforce_dag, CausalViolationError
        _add_node(mem_conn, "SELF")

        with pytest.raises(CausalViolationError, match="Causal cycle detected"):
            enforce_dag("SELF", "SELF", "CAUSES")

    def test_counterfactual_cycle_in_shadow_graph(self, mem_conn):
        """Verify cycle handling in simulate_intervention if shadow graph has a cycle."""
        from pulse.graph.counterfactual import simulate_intervention
        import networkx as nx

        # Mock build_shadow_graph to return a graph with a cycle
        G = nx.DiGraph()
        G.add_edge("A", "B", weight=0.5)
        G.add_edge("B", "A", weight=0.5)

        with patch("pulse.graph.counterfactual.build_shadow_graph", return_value=G):
            result = simulate_intervention("B", "A", 1.0)
            # After severing incoming to A (B->A removed), sub_G is A->B which is a DAG
            # So this should actually work
            assert "error" not in result or "predicted_target_value" in result

    def test_analogy_with_isolated_nodes(self, mem_conn):
        """Isolated nodes (no edges) produce empty shadow graphs."""
        from pulse.graph.analogy import build_analogy
        _add_node(mem_conn, "ISO_A")
        _add_node(mem_conn, "ISO_B")
        # No edges, so shadow graphs will be empty
        result = build_analogy("ISO_A", "ISO_B")
        assert result["match"] is False

    def test_activation_default_parameters(self, mem_conn):
        """Verify defaults work: decay=0.6, threshold=0.05, max_hops=6, max_nodes=50."""
        from pulse.graph.activation import spreading_activation
        _add_node(mem_conn, "D")
        result = spreading_activation(["D"])
        assert len(result) == 1

    def test_diamond_graph_activation(self, mem_conn):
        """Diamond: A->B, A->C, B->D, C->D. D should be reachable from A."""
        from pulse.graph.activation import spreading_activation
        for n in "ABCD":
            _add_node(mem_conn, n)
        _add_edge(mem_conn, "A", "B", confidence=1.0)
        _add_edge(mem_conn, "A", "C", confidence=1.0)
        _add_edge(mem_conn, "B", "D", confidence=1.0)
        _add_edge(mem_conn, "C", "D", confidence=1.0)

        result = spreading_activation(["A"], decay=0.8, threshold=0.001, max_hops=4)
        by_id = {r["id"]: r for r in result}
        assert "D" in by_id
        # D should have max_activation from the strongest path
        assert by_id["D"]["max_activation"] > 0

    def test_counterfactual_multiple_parents(self, mem_conn):
        """Intervention should correctly sum contributions from multiple parents."""
        from pulse.graph.counterfactual import simulate_intervention
        _add_node(mem_conn, "P1")
        _add_node(mem_conn, "P2")
        _add_node(mem_conn, "CHILD")
        _add_edge(mem_conn, "P1", "CHILD", edge_type="CAUSES", confidence=0.5)
        _add_edge(mem_conn, "P2", "CHILD", edge_type="CAUSES", confidence=0.3)
        _add_edge(mem_conn, "P1", "P2", edge_type="CAUSES", confidence=1.0)

        # do(P1=1.0): P2 = 1.0*1.0 = 1.0, CHILD = P1*0.5 + P2*0.3 = 0.5 + 0.3 = 0.8
        result = simulate_intervention("CHILD", "P1", 1.0, radius=5)
        assert "error" not in result
        assert abs(result["predicted_target_value"] - 0.8) < 0.01
