#!/usr/bin/env python3
"""Audit: Spreading activation hub dampening in knowledge graph queries.

Verifies that 1/sqrt(degree) dampening:
  - Suppresses high-degree hub nodes relative to low-degree leaf nodes
  - Scales monotonically with degree (higher degree = less activation)
  - Can filter out extreme hubs entirely via threshold
  - Does not suppress seed nodes (hops=0, dampening not applied to start)
  - Integrates correctly with query_related()
"""
import sys
import math
import sqlite3
from pathlib import Path
from unittest.mock import patch

import pytest

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


# ---------------------------------------------------------------------------
# Fixture: in-memory DB with graph schema + SQRT user function
# ---------------------------------------------------------------------------

def _create_db() -> sqlite3.Connection:
    conn = sqlite3.connect(":memory:")
    conn.create_function("SQRT", 1, math.sqrt)
    conn.row_factory = sqlite3.Row
    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)
        );
    """)
    conn.commit()
    return conn


def _node(conn, nid, text="", ntype="CONCEPT"):
    conn.execute(
        "INSERT OR IGNORE INTO graph_nodes (id, type, text) VALUES (?,?,?)",
        (nid, ntype, text or nid),
    )
    conn.commit()


def _edge(conn, src, dst, edge_type="CAUSES", conf=1.0):
    conn.execute(
        "INSERT OR IGNORE INTO graph_edges (from_node, to_node, edge_type, confidence) "
        "VALUES (?,?,?,?)",
        (src, dst, edge_type, conf),
    )
    conn.commit()


@pytest.fixture()
def db():
    conn = _create_db()
    with patch("pulse.graph.activation.get_conn", return_value=conn):
        yield conn
    conn.close()


@pytest.fixture()
def db_query():
    """Separate fixture that also patches query module's get_conn."""
    conn = _create_db()
    with patch("pulse.graph.activation.get_conn", return_value=conn), \
         patch("pulse.core.brain_db.get_conn", return_value=conn):
        yield conn
    conn.close()


# ---------------------------------------------------------------------------
# Helper: compute expected activation using the same formula as the CTE
# ---------------------------------------------------------------------------

def _expected_activation(seed_activation, edge_conf, decay, degree_of_target):
    """Replicate: activation * edge.confidence * decay * (1/sqrt(max(1, degree)))."""
    return seed_activation * edge_conf * decay * (1.0 / math.sqrt(max(1, degree_of_target)))


def _degree(conn, node_id):
    """Undirected degree: count edges where node appears as from_node OR to_node."""
    row = conn.execute(
        "SELECT COUNT(*) as c FROM graph_edges "
        "WHERE from_node = ? OR to_node = ?",
        (node_id, node_id),
    ).fetchone()
    return row["c"]


# ===========================================================================
# 1. Core hub dampening — leaf vs hub
# ===========================================================================

class TestHubDampening:

    def test_leaf_has_higher_activation_than_hub(self, db):
        """A hub node (many edges) must get lower activation than a leaf (1 edge)."""
        from pulse.graph.activation import spreading_activation

        # Seed
        _node(db, "seed")

        # leaf: only 1 edge total (seed -> leaf)
        _node(db, "leaf")
        _edge(db, "seed", "leaf", conf=1.0)

        # hub: many edges (seed -> hub, plus hub -> 9 other nodes)
        _node(db, "hub")
        _edge(db, "seed", "hub", conf=1.0)
        for i in range(9):
            _node(db, f"spoke_{i}")
            _edge(db, "hub", f"spoke_{i}", conf=1.0)

        result = spreading_activation(["seed"], decay=0.8, threshold=0.001, max_hops=1)
        by_id = {r["id"]: r for r in result}

        assert "leaf" in by_id, "leaf should be reachable"
        assert "hub" in by_id, "hub should be reachable"

        leaf_act = by_id["leaf"]["max_activation"]
        hub_act = by_id["hub"]["max_activation"]
        assert hub_act < leaf_act, (
            f"Hub (degree={_degree(db, 'hub')}) activation {hub_act:.4f} should be "
            f"less than leaf (degree={_degree(db, 'leaf')}) activation {leaf_act:.4f}"
        )

    def test_dampening_formula_matches_sqrt_degree(self, db):
        """Verify activation matches 1/sqrt(degree) formula exactly."""
        from pulse.graph.activation import spreading_activation

        _node(db, "S")
        _node(db, "T")
        _edge(db, "S", "T", conf=0.9)
        # Add extra edges on T to give it degree > 1
        for i in range(3):
            _node(db, f"extra_{i}")
            _edge(db, "T", f"extra_{i}", conf=0.5)

        deg_T = _degree(db, "T")  # 1 in + 3 out = 4
        expected = _expected_activation(1.0, 0.9, 0.6, deg_T)

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

        assert "T" in by_id, "T should be activated"
        assert abs(by_id["T"]["max_activation"] - expected) < 0.005, (
            f"Expected activation ~{expected:.4f} for degree={deg_T}, "
            f"got {by_id['T']['max_activation']:.4f}"
        )

    def test_dampening_monotonic_with_degree(self, db):
        """Higher degree nodes should get monotonically less activation from same seed."""
        from pulse.graph.activation import spreading_activation

        _node(db, "root")
        degrees_to_test = [1, 4, 9, 16]

        for d in degrees_to_test:
            target = f"target_d{d}"
            _node(db, target)
            _edge(db, "root", target, conf=1.0)
            # Add d-1 extra edges so total degree = d
            for j in range(d - 1):
                extra = f"extra_d{d}_j{j}"
                _node(db, extra)
                _edge(db, target, extra, conf=0.5)

        result = spreading_activation(["root"], decay=0.9, threshold=0.001, max_hops=1)
        by_id = {r["id"]: r for r in result}

        activations = []
        for d in degrees_to_test:
            nid = f"target_d{d}"
            if nid in by_id:
                activations.append((d, by_id[nid]["max_activation"]))

        # All targets should be found and activations should be strictly decreasing
        assert len(activations) == len(degrees_to_test), (
            f"Expected all {len(degrees_to_test)} degree targets, got {len(activations)}"
        )
        for i in range(len(activations) - 1):
            d_low, act_low = activations[i]
            d_high, act_high = activations[i + 1]
            assert act_high < act_low, (
                f"Degree {d_high} should have less activation than degree {d_low}: "
                f"{act_high:.4f} vs {act_low:.4f}"
            )

    def test_extreme_hub_filtered_by_threshold(self, db):
        """A very high-degree hub should be filtered out when threshold is raised."""
        from pulse.graph.activation import spreading_activation

        _node(db, "source")
        _node(db, "mega_hub")
        _edge(db, "source", "mega_hub", conf=1.0)

        # degree = 1 (in) + 99 (out) = 100
        for i in range(99):
            _node(db, f"spoke_{i}")
            _edge(db, "mega_hub", f"spoke_{i}", conf=0.5)

        # activation = 1.0 * 1.0(conf) * 0.5(decay) * 1/sqrt(100) = 0.05
        # Set threshold just above this: 0.06
        result = spreading_activation(
            ["source"], decay=0.5, threshold=0.06, max_hops=1
        )
        by_id = {r["id"]: r for r in result}

        assert "mega_hub" not in by_id, (
            f"mega_hub (degree=100) should be filtered below threshold=0.06, "
            f"got activation ~{1.0 * 0.5 / math.sqrt(100):.4f}"
        )

    def test_leaf_survives_same_threshold_that_filters_hub(self, db):
        """A low-degree leaf should pass the same threshold that filters a hub."""
        from pulse.graph.activation import spreading_activation

        _node(db, "source")

        # leaf: degree = 1 (only the incoming edge from source)
        _node(db, "leaf")
        _edge(db, "source", "leaf", conf=1.0)

        # hub: degree = 100
        _node(db, "big_hub")
        _edge(db, "source", "big_hub", conf=1.0)
        for i in range(99):
            _node(db, f"h_spoke_{i}")
            _edge(db, "big_hub", f"h_spoke_{i}", conf=0.5)

        # threshold = 0.06: hub activation = 0.5/sqrt(100) = 0.05 (filtered)
        # leaf activation = 0.5/sqrt(1) = 0.50 (survives)
        result = spreading_activation(
            ["source"], decay=0.5, threshold=0.06, max_hops=1
        )
        by_id = {r["id"]: r for r in result}

        assert "leaf" in by_id, "Leaf (degree=1) should survive threshold=0.06"
        assert "big_hub" not in by_id, "Hub (degree=100) should be filtered at threshold=0.06"

    def test_seed_node_activation_is_always_1(self, db):
        """Seed nodes start at activation=1.0 regardless of their degree."""
        from pulse.graph.activation import spreading_activation

        _node(db, "high_degree_seed")
        # Give seed many outgoing edges
        for i in range(20):
            _node(db, f"s_child_{i}")
            _edge(db, "high_degree_seed", f"s_child_{i}", conf=0.5)

        result = spreading_activation(
            ["high_degree_seed"], decay=0.5, threshold=0.001, max_hops=1
        )
        by_id = {r["id"]: r for r in result}

        assert "high_degree_seed" in by_id
        assert by_id["high_degree_seed"]["max_activation"] == 1.0, (
            "Seed node activation must be 1.0 (dampening only applies on traversal)"
        )

    def test_degree_one_node_no_dampening(self, db):
        """A node with degree=1 receives no dampening (1/sqrt(1)=1.0)."""
        from pulse.graph.activation import spreading_activation

        _node(db, "A")
        _node(db, "B")
        _edge(db, "A", "B", conf=0.8)

        result = spreading_activation(["A"], decay=0.7, threshold=0.001, max_hops=1)
        by_id = {r["id"]: r for r in result}

        assert "B" in by_id
        # B degree = 1 (only edge is A->B), so no dampening
        expected = 1.0 * 0.8 * 0.7 * (1.0 / math.sqrt(1))
        assert abs(by_id["B"]["max_activation"] - expected) < 0.005, (
            f"Degree-1 node should have no dampening: expected {expected:.4f}, "
            f"got {by_id['B']['max_activation']:.4f}"
        )

    def test_dampening_applies_at_each_hop(self, db):
        """Dampening accumulates at each hop; multi-hop hub paths decay fast."""
        from pulse.graph.activation import spreading_activation

        # Chain: root -> mid -> leaf
        # mid has degree = 1 (in) + 1 (out) + 8 (extra spokes) = 10
        _node(db, "root")
        _node(db, "mid")
        _node(db, "leaf_end")
        _edge(db, "root", "mid", conf=1.0)
        _edge(db, "mid", "leaf_end", conf=1.0)
        for i in range(8):
            _node(db, f"mid_spoke_{i}")
            _edge(db, "mid", f"mid_spoke_{i}", conf=0.5)

        result = spreading_activation(
            ["root"], decay=0.8, threshold=0.0001, max_hops=3
        )
        by_id = {r["id"]: r for r in result}

        assert "mid" in by_id
        assert "leaf_end" in by_id

        # leaf_end activation should be significantly less than mid
        # because it goes through high-degree mid
        leaf_act = by_id["leaf_end"]["max_activation"]
        mid_act = by_id["mid"]["max_activation"]
        assert leaf_act < mid_act, (
            f"leaf_end activation {leaf_act:.4f} should be less than mid {mid_act:.4f}"
        )


# ===========================================================================
# 2. Edge cases for hub dampening
# ===========================================================================

class TestHubDampeningEdgeCases:

    def test_zero_degree_node_uses_max_1(self, db):
        """MAX(1, degree) prevents division by zero for isolated nodes."""
        from pulse.graph.activation import spreading_activation

        # Add a node as start, and a target with NO edges at all
        _node(db, "start")
        _node(db, "orphan")
        # We need an edge to reach orphan — but let's check a node with only the
        # traversal edge (degree becomes 1 from the edge we add)
        _edge(db, "start", "orphan", conf=1.0)

        # orphan degree = 1 (only edge is start->orphan)
        result = spreading_activation(["start"], decay=0.9, threshold=0.001, max_hops=1)
        by_id = {r["id"]: r for r in result}

        # Should not crash, orphan should appear
        assert "orphan" in by_id

    def test_bidirectional_edges_double_degree(self, db):
        """Bidirectional edges count towards degree twice (once as from, once as to)."""
        from pulse.graph.activation import spreading_activation

        _node(db, "X")
        _node(db, "Y")
        # Two directed edges forming a bidirectional connection
        _edge(db, "X", "Y", edge_type="CAUSES", conf=1.0)
        _edge(db, "Y", "X", edge_type="PREVENTS", conf=1.0)

        deg_Y = _degree(db, "Y")  # Should be 2 (1 in + 1 out)
        assert deg_Y == 2, f"Expected degree 2 for bidirectional Y, got {deg_Y}"

        result = spreading_activation(["X"], decay=0.8, threshold=0.001, max_hops=1)
        by_id = {r["id"]: r for r in result}

        assert "Y" in by_id
        expected = _expected_activation(1.0, 1.0, 0.8, deg_Y)
        assert abs(by_id["Y"]["max_activation"] - expected) < 0.005, (
            f"Expected {expected:.4f} for degree-2 bidirectional node, "
            f"got {by_id['Y']['max_activation']:.4f}"
        )

    def test_hub_at_depth_2_still_dampened(self, db):
        """Hub dampening applies at every hop depth, not just hop 1."""
        from pulse.graph.activation import spreading_activation

        _node(db, "root")
        _node(db, "bridge")
        _node(db, "deep_hub")
        _edge(db, "root", "bridge", conf=1.0)   # bridge: degree=2 (root in, deep_hub out)
        _edge(db, "bridge", "deep_hub", conf=1.0)

        # deep_hub: 1 in + 15 out = 16 degree
        for i in range(15):
            _node(db, f"dh_leaf_{i}")
            _edge(db, "deep_hub", f"dh_leaf_{i}", conf=0.5)

        result = spreading_activation(
            ["root"], decay=0.9, threshold=0.0001, max_hops=3
        )
        by_id = {r["id"]: r for r in result}

        assert "bridge" in by_id
        assert "deep_hub" in by_id

        bridge_deg = _degree(db, "bridge")
        hub_deg = _degree(db, "deep_hub")

        bridge_act = by_id["bridge"]["max_activation"]
        hub_act = by_id["deep_hub"]["max_activation"]

        # Verify hub activation = bridge_activation * conf * decay * 1/sqrt(hub_deg)
        expected_hub = _expected_activation(bridge_act, 1.0, 0.9, hub_deg)
        assert abs(hub_act - expected_hub) < 0.01, (
            f"deep_hub activation: expected ~{expected_hub:.4f}, got {hub_act:.4f} "
            f"(bridge_act={bridge_act:.4f}, hub_deg={hub_deg})"
        )

    def test_multiple_paths_to_hub_takes_max_activation(self, db):
        """If hub reachable via two paths, result should show MAX activation."""
        from pulse.graph.activation import spreading_activation

        _node(db, "seed")
        _node(db, "path_A")
        _node(db, "path_B")
        _node(db, "shared_hub")
        _edge(db, "seed", "path_A", conf=1.0)
        _edge(db, "seed", "path_B", conf=1.0)
        _edge(db, "path_A", "shared_hub", conf=0.9)
        _edge(db, "path_B", "shared_hub", conf=0.5)  # weaker path

        # shared_hub has 2 in + 0 out = degree 2
        result = spreading_activation(
            ["seed"], decay=0.8, threshold=0.001, max_hops=2
        )
        by_id = {r["id"]: r for r in result}

        assert "shared_hub" in by_id
        # Should take MAX of both paths' activation
        deg = _degree(db, "shared_hub")
        # path A: need path_A activation first
        deg_path_A = _degree(db, "path_A")
        path_A_act = _expected_activation(1.0, 1.0, 0.8, deg_path_A)
        via_A = _expected_activation(path_A_act, 0.9, 0.8, deg)

        deg_path_B = _degree(db, "path_B")
        path_B_act = _expected_activation(1.0, 1.0, 0.8, deg_path_B)
        via_B = _expected_activation(path_B_act, 0.5, 0.8, deg)

        expected_max = max(via_A, via_B)
        actual = by_id["shared_hub"]["max_activation"]
        assert abs(actual - expected_max) < 0.01, (
            f"Expected MAX activation ~{expected_max:.4f} via best path, got {actual:.4f}"
        )


# ===========================================================================
# 3. Integration: query_related() hub suppression
# ===========================================================================

class TestQueryRelatedHubSuppression:

    def test_query_related_returns_leaf_over_hub(self, db_query):
        """query_related should favor leaf nodes over hub nodes via dampening."""
        from pulse.graph.activation import spreading_activation
        from pulse.core.brain_db import get_conn

        conn = db_query

        # Seed concept
        _node(conn, "python_error", text="python error")

        # Leaf: tightly related concept
        _node(conn, "traceback", text="traceback")
        _edge(conn, "python_error", "traceback", conf=1.0)

        # Hub: a generic "error" concept connected to many things
        _node(conn, "generic_error", text="error")
        _edge(conn, "python_error", "generic_error", conf=1.0)
        for i in range(19):
            _node(conn, f"error_type_{i}", text=f"error type {i}")
            _edge(conn, "generic_error", f"error_type_{i}", conf=0.5)

        result = spreading_activation(
            ["python_error"], decay=0.7, threshold=0.01, max_hops=1,
            max_nodes=10
        )
        by_id = {r["id"]: r for r in result}

        if "traceback" in by_id and "generic_error" in by_id:
            assert by_id["traceback"]["max_activation"] > by_id["generic_error"]["max_activation"], (
                f"Leaf 'traceback' (act={by_id['traceback']['max_activation']:.4f}) should "
                f"beat hub 'generic_error' (act={by_id['generic_error']['max_activation']:.4f})"
            )


if __name__ == "__main__":
    import pytest
    pytest.main([__file__, "-v"])
