"""
Spreading Activation — brain-like activation through the knowledge graph.

Simulates neural spreading activation: seed nodes fire, activation propagates
through edges with distance-based decay, and multi-path convergence amplifies
signal. This replaces keyword matching with resonance-based memory retrieval.
"""

from __future__ import annotations

import sqlite3
import sys
from collections import defaultdict

LOG = lambda msg: sys.stderr.write(f"[memory-activation] {msg}\n")


class SpreadingActivation:
    """Brain-like spreading activation through the knowledge graph."""

    # Decay factor per hop distance (index = hop count)
    HOP_DECAY: list[float] = [1.0, 0.7, 0.4, 0.2]

    # Minimum activation threshold to keep a node in the result
    ACTIVATION_THRESHOLD: float = 0.3

    # Bonus multiplier when multiple paths converge on the same node
    MULTI_PATH_BONUS: dict[int, float] = {2: 1.2, 3: 1.5, 4: 1.8, 5: 2.0}

    def __init__(self, db: sqlite3.Connection) -> None:
        self.db = db

    def spread(
        self, seed_nodes: list[str], depth: int = 2
    ) -> dict[str, float]:
        """Spreading activation from seed nodes through the graph.

        Args:
            seed_nodes: List of graph_node IDs to start activation from.
            depth: How many hops to spread (1-3). Clamped to valid range.

        Returns:
            Dict of node_id to activation_level, containing only nodes
            whose activation exceeds ACTIVATION_THRESHOLD.
        """
        if not seed_nodes:
            return {}

        depth = max(1, min(depth, len(self.HOP_DECAY) - 1))

        # Build adjacency list once for the entire spread operation
        adjacency = self._build_adjacency()
        if not adjacency:
            LOG("spread: empty graph, no edges found")
            return {}

        # activation_level[node_id] = max activation seen
        activation_level: dict[str, float] = {}
        # path_count[node_id] = number of distinct paths reaching this node
        path_count: dict[str, int] = defaultdict(int)

        # Seed nodes get full activation
        for node_id in seed_nodes:
            activation_level[node_id] = 1.0
            path_count[node_id] = 1

        # BFS-like spreading with decay
        current_frontier: set[str] = set(seed_nodes)

        for hop in range(1, depth + 1):
            decay = self.HOP_DECAY[hop]
            next_frontier: set[str] = set()

            for node_id in current_frontier:
                parent_activation = activation_level.get(node_id, 0.0)
                if parent_activation < self.ACTIVATION_THRESHOLD:
                    continue

                # Spread to all neighbors
                neighbors = adjacency.get(node_id, [])
                for neighbor_id, edge_weight in neighbors:
                    # Skip seeds to avoid trivial cycles back to origin
                    if neighbor_id in seed_nodes and hop > 0:
                        continue

                    # Activation = parent * decay * edge_weight (normalized)
                    new_activation = parent_activation * decay * min(edge_weight, 1.0)

                    if new_activation < self.ACTIVATION_THRESHOLD:
                        continue

                    path_count[neighbor_id] += 1

                    # Keep the maximum activation from any path
                    if neighbor_id not in activation_level or new_activation > activation_level[neighbor_id]:
                        activation_level[neighbor_id] = new_activation
                        next_frontier.add(neighbor_id)

            current_frontier = next_frontier

        # Apply multi-path bonus: nodes reached by multiple paths get amplified
        for node_id, count in path_count.items():
            if count >= 2 and node_id not in seed_nodes:
                clamped = min(count, 5)
                bonus = self.MULTI_PATH_BONUS.get(clamped, 2.0)
                activation_level[node_id] = min(
                    activation_level[node_id] * bonus, 1.0
                )

        # Filter below threshold and exclude seed nodes from output
        result = {
            nid: round(level, 4)
            for nid, level in activation_level.items()
            if level >= self.ACTIVATION_THRESHOLD and nid not in seed_nodes
        }

        LOG(
            f"spread: {len(seed_nodes)} seeds, depth={depth}, "
            f"activated={len(result)} nodes"
        )
        return result

    def find_seed_nodes(self, concept_names: list[str]) -> list[str]:
        """Find graph node IDs matching concept names (case-insensitive).

        Searches graph_nodes by exact name match (case-insensitive) first,
        then falls back to LIKE prefix match for partial names.

        Args:
            concept_names: List of concept name strings to search for.

        Returns:
            List of matching node IDs (deduplicated).
        """
        if not concept_names:
            return []

        found_ids: list[str] = []
        seen: set[str] = set()

        for name in concept_names:
            # Exact match first (case-insensitive)
            row = self.db.execute(
                "SELECT id FROM graph_nodes WHERE LOWER(name) = LOWER(?) AND status = 'active'",
                (name,),
            ).fetchone()

            if row:
                nid = row[0]
                if nid not in seen:
                    found_ids.append(nid)
                    seen.add(nid)
                continue

            # Fallback: prefix LIKE match
            rows = self.db.execute(
                "SELECT id FROM graph_nodes WHERE LOWER(name) LIKE LOWER(?) AND status = 'active' LIMIT 3",
                (f"{name}%",),
            ).fetchall()

            for r in rows:
                nid = r[0]
                if nid not in seen:
                    found_ids.append(nid)
                    seen.add(nid)

        LOG(f"find_seed_nodes: {len(concept_names)} concepts -> {len(found_ids)} seeds")
        return found_ids

    def get_activated_memories(
        self, activation_map: dict[str, float], top_k: int = 20
    ) -> list[tuple[int, float]]:
        """Find knowledge records connected to activated nodes.

        Looks up knowledge_nodes links for all activated graph nodes and
        sums activation scores across all paths to each knowledge record.

        Args:
            activation_map: Dict of node_id to activation_level from spread().
            top_k: Maximum number of knowledge records to return.

        Returns:
            List of (knowledge_id, total_activation_score) sorted by score
            descending. Score is the sum of (activation * link_strength)
            across all connected nodes.
        """
        if not activation_map:
            return []

        # Batch query: get all knowledge links for activated nodes at once
        node_ids = list(activation_map.keys())
        placeholders = ",".join("?" * len(node_ids))

        rows = self.db.execute(
            f"""SELECT kn.knowledge_id, kn.node_id, kn.strength
                FROM knowledge_nodes kn
                JOIN knowledge k ON kn.knowledge_id = k.id
                WHERE kn.node_id IN ({placeholders})
                  AND k.status = 'active'""",
            node_ids,
        ).fetchall()

        # Sum activation * strength for each knowledge record
        memory_scores: dict[int, float] = defaultdict(float)
        for row in rows:
            kid = row[0]
            nid = row[1]
            strength = row[2] if row[2] else 1.0
            activation = activation_map.get(nid, 0.0)
            memory_scores[kid] += activation * strength

        # Sort by score descending, take top_k
        ranked = sorted(memory_scores.items(), key=lambda x: x[1], reverse=True)
        result = [(kid, round(score, 4)) for kid, score in ranked[:top_k]]

        LOG(f"get_activated_memories: {len(activation_map)} nodes -> {len(result)} memories")
        return result

    def _build_adjacency(self) -> dict[str, list[tuple[str, float]]]:
        """Build bidirectional adjacency list from all graph edges.

        Loads all edges in a single query and builds an in-memory adjacency
        list for fast traversal. Each entry maps node_id to a list of
        (neighbor_id, normalized_weight) tuples.

        Returns:
            Dict mapping node_id to list of (neighbor_id, weight) tuples.
            Edges are bidirectional (both directions added).
        """
        adjacency: dict[str, list[tuple[str, float]]] = defaultdict(list)

        rows = self.db.execute(
            "SELECT source_id, target_id, weight FROM graph_edges"
        ).fetchall()

        for row in rows:
            src, tgt, weight = row[0], row[1], row[2] if row[2] else 1.0
            # Normalize weight to [0, 1] range (edges can have weight up to 10.0)
            norm_weight = min(weight / 10.0, 1.0) if weight > 1.0 else weight
            adjacency[src].append((tgt, norm_weight))
            adjacency[tgt].append((src, norm_weight))

        LOG(f"_build_adjacency: {len(rows)} edges, {len(adjacency)} nodes")
        return adjacency
