//! Rich in-memory identity graph model.
//!
//! `IdentityGraph` is the authoritative representation of an identity graph for
//! a single tenant. It contains typed nodes (`NodeData`) with entity metadata,
//! typed edges (`GraphEdge` with `EdgeKind` discriminator), versioned mutation
//! tracking, and lazily-recomputed analytics.
//!
//! All mutation methods are pure (no DB, no async) and fully testable. The
//! `version` field tracks mutation count for analytics staleness detection.
//! Persistence to `graph_mutations` is handled by write-ahead logging in the
//! API handlers (log FIRST, then table writes, in one transaction).

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use petgraph::graph::{Graph, NodeIndex};
use petgraph::visit::EdgeRef;
use petgraph::Undirected;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::algorithms;
use super::{EdgeData, RiskScores, TenantGraph};

// ---------------------------------------------------------------------------
// Node types
// ---------------------------------------------------------------------------

/// Rich node metadata - lightweight, no full canonical_data blob.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeData {
    pub id: Uuid,
    pub entity_type: String,
    pub member_count: u32,
    pub display_name: Option<String>,
    pub is_locked: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

// ---------------------------------------------------------------------------
// Edge types
// ---------------------------------------------------------------------------

/// Typed edge discriminator (replaces stringly-typed edge_type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeKind {
    /// Hard edge: two entities share identity (merge relationship).
    Identity,
    /// Soft edge: related entities (shared fields, explicit link).
    Relationship,
}

/// Edge metadata in the identity graph.
#[derive(Debug, Clone)]
pub struct GraphEdge {
    pub kind: EdgeKind,
    pub weight: f64,
    pub attributes: Option<serde_json::Value>,
    pub created_at: DateTime<Utc>,
}

// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------

/// Pre-computed graph analytics with staleness tracking.
#[derive(Debug, Clone, Default)]
pub struct GraphAnalytics {
    pub pagerank: HashMap<Uuid, f64>,
    pub betweenness: HashMap<Uuid, f64>,
    pub bridge_scores: HashMap<Uuid, f64>,
    pub components: Vec<Vec<Uuid>>,
    pub node_component: HashMap<Uuid, usize>,
    pub risk_scores: HashMap<Uuid, RiskScores>,
    /// Version at which analytics were last computed. Stale when < graph.version.
    pub computed_at_version: u64,
}

// ---------------------------------------------------------------------------
// Mutation log
// ---------------------------------------------------------------------------

/// A single graph mutation entry for the persistence log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphMutation {
    pub version: u64,
    pub mutation_type: MutationType,
    pub created_at: DateTime<Utc>,
}

/// Discriminated mutation types for the persistence log.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MutationType {
    EntityCreated {
        entity_id: Uuid,
        entity_type: String,
        member_count: u32,
    },
    EntitiesMerged {
        winner_id: Uuid,
        loser_id: Uuid,
        merge_type: String,
        confidence: Option<f64>,
        members_moved: u32,
    },
    EntitySplit {
        original_id: Uuid,
        new_entities: Vec<SplitNewNode>,
        remaining_members: u32,
    },
    MemberReassigned {
        external_entity_id: Uuid,
        from_entity_id: Uuid,
        to_entity_id: Uuid,
    },
    EntityDeleted {
        entity_id: Uuid,
    },
    EdgeLinked {
        entity_a_id: Uuid,
        entity_b_id: Uuid,
        weight: f64,
    },
    EdgeUnlinked {
        entity_a_id: Uuid,
        entity_b_id: Uuid,
    },
    BatchReconciled {
        batch_run_id: Uuid,
        entities_created: u32,
        merges_detected: u32,
    },
    EntityUpdated {
        entity_id: Uuid,
        fields_changed: Vec<String>,
    },
}

/// A new entity created during a split operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SplitNewNode {
    pub new_entity_id: Uuid,
    pub member_count: u32,
}

// ---------------------------------------------------------------------------
// IdentityGraph - the authoritative graph structure
// ---------------------------------------------------------------------------

/// The primary in-memory graph structure for a single tenant.
///
/// In Phase A (dual-write), this is kept in sync with DB tables. Mutations
/// update the graph first, then persist to DB. On cold start, the graph is
/// rebuilt from DB tables.
#[derive(Debug)]
pub struct IdentityGraph {
    pub graph: Graph<NodeData, GraphEdge, Undirected>,
    pub node_index: HashMap<Uuid, NodeIndex>,
    /// Monotonically increasing version, incremented per mutation.
    pub version: u64,
    pub analytics: GraphAnalytics,
    pub tenant_id: Uuid,
}

impl IdentityGraph {
    /// Create an empty graph for a tenant.
    pub fn new(tenant_id: Uuid) -> Self {
        Self {
            graph: Graph::new_undirected(),
            node_index: HashMap::new(),
            version: 0,
            analytics: GraphAnalytics::default(),
            tenant_id,
        }
    }

    /// Add an entity node to the graph. Does NOT log (use for DB rebuild).
    /// Returns the petgraph NodeIndex.
    pub fn add_node(&mut self, node: NodeData) -> NodeIndex {
        let id = node.id;
        let idx = self.graph.add_node(node);
        self.node_index.insert(id, idx);
        idx
    }

    /// Add an entity node and increment version (for analytics staleness).
    pub fn add_entity(&mut self, node: NodeData) -> NodeIndex {
        let idx = self.add_node(node);
        self.version += 1;
        idx
    }

    /// Add an edge without logging (use for DB rebuild).
    pub fn add_edge(&mut self, a: Uuid, b: Uuid, edge: GraphEdge) -> bool {
        let a_idx = match self.node_index.get(&a) {
            Some(idx) => *idx,
            None => return false,
        };
        let b_idx = match self.node_index.get(&b) {
            Some(idx) => *idx,
            None => return false,
        };
        self.graph.add_edge(a_idx, b_idx, edge);
        true
    }

    /// Merge two entities. Moves edges from loser to winner, updates member
    /// count, removes loser node. Returns members moved, or None if either
    /// entity not found.
    pub fn merge(
        &mut self,
        winner_id: Uuid,
        loser_id: Uuid,
        _merge_type: &str,
        _confidence: Option<f64>,
    ) -> Option<u32> {
        let winner_idx = *self.node_index.get(&winner_id)?;
        let loser_idx = *self.node_index.get(&loser_id)?;

        // Collect loser's edges before modification
        let loser_edges: Vec<(NodeIndex, GraphEdge)> = self
            .graph
            .edges(loser_idx)
            .map(|e| {
                let other = if e.source() == loser_idx {
                    e.target()
                } else {
                    e.source()
                };
                (other, e.weight().clone())
            })
            .collect();

        // Move edges to winner (skip self-loops winner<->loser)
        for (other_idx, edge_data) in &loser_edges {
            if *other_idx == winner_idx {
                continue;
            }
            if self.graph.find_edge(winner_idx, *other_idx).is_none() {
                self.graph
                    .add_edge(winner_idx, *other_idx, edge_data.clone());
            }
        }

        // Update member counts
        let loser_members = self.graph[loser_idx].member_count;
        self.graph[winner_idx].member_count += loser_members;
        self.graph[winner_idx].updated_at = Utc::now();

        // Remove loser node (handles index swap)
        self.remove_node_internal(loser_id);

        self.version += 1;

        Some(loser_members)
    }

    /// Split members out of an entity. Creates new nodes for each ejected
    /// member. Returns Vec of (member_id, new_canonical_id) pairs.
    ///
    /// The `ejected` slice contains (member_external_entity_id, entity_type).
    pub fn split(
        &mut self,
        original_id: Uuid,
        ejected: &[(Uuid, String)],
    ) -> Option<Vec<(Uuid, Uuid)>> {
        let original_idx = *self.node_index.get(&original_id)?;

        let mut new_entities = Vec::with_capacity(ejected.len());
        let now = Utc::now();

        for (member_id, entity_type) in ejected {
            let new_id = Uuid::new_v4();

            let new_node = NodeData {
                id: new_id,
                entity_type: entity_type.clone(),
                member_count: 1,
                display_name: None,
                is_locked: false,
                created_at: now,
                updated_at: now,
            };
            let _new_idx = self.graph.add_node(new_node);
            self.node_index.insert(new_id, _new_idx);

            // Decrement original's member count
            if self.graph[original_idx].member_count > 0 {
                self.graph[original_idx].member_count -= 1;
            }

            new_entities.push((*member_id, new_id));
        }

        self.graph[original_idx].updated_at = now;

        self.version += 1;

        Some(new_entities)
    }

    /// Reassign a member from one entity to another. Updates member counts.
    /// Returns true if successful.
    pub fn reassign(
        &mut self,
        _external_entity_id: Uuid,
        from_id: Uuid,
        to_id: Uuid,
    ) -> bool {
        let from_idx = match self.node_index.get(&from_id) {
            Some(idx) => *idx,
            None => return false,
        };
        let to_idx = match self.node_index.get(&to_id) {
            Some(idx) => *idx,
            None => return false,
        };

        // Update member counts
        if self.graph[from_idx].member_count > 0 {
            self.graph[from_idx].member_count -= 1;
        }
        self.graph[to_idx].member_count += 1;

        let now = Utc::now();
        self.graph[from_idx].updated_at = now;
        self.graph[to_idx].updated_at = now;

        self.version += 1;

        true
    }

    /// Delete an entity node. Only succeeds if member_count == 0.
    /// Returns the removed NodeData, or None if not found or has members.
    pub fn delete_node(&mut self, entity_id: Uuid) -> Option<NodeData> {
        let idx = *self.node_index.get(&entity_id)?;

        if self.graph[idx].member_count > 0 {
            return None;
        }

        let data = self.remove_node_internal(entity_id)?;

        self.version += 1;

        Some(data)
    }

    /// Create a soft relationship edge between two entities. Returns true if
    /// the edge was created/updated.
    pub fn link(
        &mut self,
        a: Uuid,
        b: Uuid,
        weight: f64,
        attributes: Option<serde_json::Value>,
    ) -> bool {
        let a_idx = match self.node_index.get(&a) {
            Some(idx) => *idx,
            None => return false,
        };
        let b_idx = match self.node_index.get(&b) {
            Some(idx) => *idx,
            None => return false,
        };

        // Upsert: if edge exists, update weight/attributes
        if let Some(edge_idx) = self.graph.find_edge(a_idx, b_idx) {
            self.graph[edge_idx].weight = weight;
            self.graph[edge_idx].attributes = attributes.clone();
        } else {
            self.graph.add_edge(
                a_idx,
                b_idx,
                GraphEdge {
                    kind: EdgeKind::Relationship,
                    weight,
                    attributes: attributes.clone(),
                    created_at: Utc::now(),
                },
            );
        }

        self.version += 1;

        true
    }

    /// Remove a soft relationship edge between two entities.
    /// Returns true if an edge was removed.
    pub fn unlink(&mut self, a: Uuid, b: Uuid) -> bool {
        let a_idx = match self.node_index.get(&a) {
            Some(idx) => *idx,
            None => return false,
        };
        let b_idx = match self.node_index.get(&b) {
            Some(idx) => *idx,
            None => return false,
        };

        if let Some(edge_idx) = self.graph.find_edge(a_idx, b_idx) {
            self.graph.remove_edge(edge_idx);

            self.version += 1;

            true
        } else {
            false
        }
    }

    /// Get a node's data by entity ID.
    pub fn get_node(&self, entity_id: &Uuid) -> Option<&NodeData> {
        self.node_index
            .get(entity_id)
            .and_then(|idx| self.graph.node_weight(*idx))
    }

    /// Check if analytics need recomputing.
    pub fn analytics_stale(&self) -> bool {
        self.analytics.computed_at_version < self.version
    }

    /// Recompute all analytics from the current graph state.
    pub fn recompute_analytics(&mut self) {
        let projected = project_for_algorithms(self);

        let pagerank_idx = algorithms::compute_pagerank(&projected);
        let betweenness_idx = algorithms::compute_betweenness(&projected, 100);
        let bridge_scores_idx = algorithms::compute_bridge_scores(&projected);
        let (components_idx, node_component_idx) =
            algorithms::compute_components(&projected);
        let risk_scores_idx = algorithms::compute_risk_scores(
            &projected,
            &pagerank_idx,
            &components_idx,
            &node_component_idx,
        );

        let idx_to_uuid = |idx: &petgraph::graph::NodeIndex| -> Uuid { projected[*idx] };

        self.analytics = GraphAnalytics {
            pagerank: pagerank_idx
                .iter()
                .map(|(k, v)| (idx_to_uuid(k), *v))
                .collect(),
            betweenness: betweenness_idx
                .iter()
                .map(|(k, v)| (idx_to_uuid(k), *v))
                .collect(),
            bridge_scores: bridge_scores_idx
                .iter()
                .map(|(k, v)| (idx_to_uuid(k), *v))
                .collect(),
            components: components_idx
                .iter()
                .map(|c| c.iter().map(idx_to_uuid).collect())
                .collect(),
            node_component: node_component_idx
                .iter()
                .map(|(k, v)| (idx_to_uuid(k), *v))
                .collect(),
            risk_scores: risk_scores_idx
                .iter()
                .map(|(k, v)| (idx_to_uuid(k), v.clone()))
                .collect(),
            computed_at_version: self.version,
        };
    }

    /// Recompute analytics only if stale.
    pub fn ensure_analytics(&mut self) {
        if self.analytics_stale() {
            self.recompute_analytics();
        }
    }

    /// Internal: remove a node handling petgraph's swap-remove semantics.
    ///
    /// When petgraph removes node at index `i`, the node at the last index
    /// is moved to position `i`. We must update `node_index` accordingly.
    fn remove_node_internal(&mut self, entity_id: Uuid) -> Option<NodeData> {
        let idx = *self.node_index.get(&entity_id)?;
        let last_idx = NodeIndex::new(self.graph.node_count() - 1);

        let data = self.graph.remove_node(idx)?;
        self.node_index.remove(&entity_id);

        // If the removed node was not the last, the last node moved to idx
        if idx != last_idx && idx.index() < self.graph.node_count() {
            if let Some(moved_node) = self.graph.node_weight(idx) {
                self.node_index.insert(moved_node.id, idx);
            }
        }

        Some(data)
    }
}

// ---------------------------------------------------------------------------
// Log replay (Phase C)
// ---------------------------------------------------------------------------

impl IdentityGraph {
    /// Apply a mutation from the graph_mutations log during replay.
    ///
    /// Used by `build_from_log()` and the materializer to reconstruct the graph
    /// from the write-ahead log without hitting the source tables.
    pub fn apply_mutation_from_log(
        &mut self,
        version: i64,
        mutation_type: &str,
        payload: &serde_json::Value,
    ) -> Result<(), String> {
        let now = Utc::now();

        match mutation_type {
            "entity_created" => {
                let entity_id = parse_uuid(payload, "entity_id")?;
                let entity_type = payload
                    .get("entity_type")
                    .and_then(|v| v.as_str())
                    .unwrap_or("unknown")
                    .to_string();
                let member_count = payload
                    .get("member_count")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0) as u32;
                self.add_node(NodeData {
                    id: entity_id,
                    entity_type,
                    member_count,
                    display_name: None,
                    is_locked: false,
                    created_at: now,
                    updated_at: now,
                });
            }
            "entities_merged" => {
                let winner_id = parse_uuid(payload, "winner_id")?;
                let loser_id = parse_uuid(payload, "loser_id")?;
                if self.merge(winner_id, loser_id, "replay", None).is_none() {
                    tracing::warn!(
                        version = version,
                        %winner_id, %loser_id,
                        "Log replay: merge failed (missing entity)"
                    );
                }
            }
            "entity_split" => {
                let original_id = parse_uuid(payload, "original_id")?;
                if let Some(new_ents) = payload.get("new_entities").and_then(|v| v.as_array()) {
                    for ne in new_ents {
                        let new_id = parse_uuid(ne, "new_entity_id")?;
                        let mc = ne
                            .get("member_count")
                            .and_then(|v| v.as_u64())
                            .unwrap_or(1) as u32;
                        let entity_type = self
                            .get_node(&original_id)
                            .map(|n| n.entity_type.clone())
                            .unwrap_or_else(|| "unknown".into());
                        self.add_node(NodeData {
                            id: new_id,
                            entity_type,
                            member_count: mc,
                            display_name: None,
                            is_locked: false,
                            created_at: now,
                            updated_at: now,
                        });
                        // Decrement original member count by the number of members moved
                        if let Some(idx) = self.node_index.get(&original_id).copied() {
                            self.graph[idx].member_count =
                                self.graph[idx].member_count.saturating_sub(mc);
                        }
                    }
                }
            }
            "member_reassigned" => {
                let ext_id = parse_uuid(payload, "external_entity_id")?;
                let from_id = parse_uuid(payload, "from_entity_id")?;
                let to_id = parse_uuid(payload, "to_entity_id")?;
                if !self.reassign(ext_id, from_id, to_id) {
                    tracing::warn!(
                        version = version,
                        %from_id, %to_id,
                        "Log replay: reassign failed (missing entity)"
                    );
                }
            }
            "entity_deleted" => {
                let entity_id = parse_uuid(payload, "entity_id")?;
                if self.delete_node(entity_id).is_none() {
                    tracing::warn!(
                        version = version,
                        %entity_id,
                        "Log replay: delete failed (missing entity or has members)"
                    );
                }
            }
            "edge_linked" => {
                let a = parse_uuid(payload, "entity_a_id")?;
                let b = parse_uuid(payload, "entity_b_id")?;
                let weight = payload
                    .get("weight")
                    .and_then(|v| v.as_f64())
                    .unwrap_or(1.0);
                if !self.link(a, b, weight, None) {
                    tracing::warn!(
                        version = version,
                        %a, %b,
                        "Log replay: link failed (missing entity)"
                    );
                }
            }
            "edge_unlinked" => {
                let a = parse_uuid(payload, "entity_a_id")?;
                let b = parse_uuid(payload, "entity_b_id")?;
                if !self.unlink(a, b) {
                    tracing::warn!(
                        version = version,
                        %a, %b,
                        "Log replay: unlink failed (missing entity or edge)"
                    );
                }
            }
            "batch_reconciled" | "entity_updated" => {
                // Informational - no graph structure change needed for replay
            }
            other => {
                return Err(format!("Unknown mutation type: {}", other));
            }
        }

        self.version = version.max(0) as u64;
        Ok(())
    }
}

/// Parse a UUID from a JSON object field.
fn parse_uuid(payload: &serde_json::Value, field: &str) -> Result<Uuid, String> {
    payload
        .get(field)
        .and_then(|v| v.as_str())
        .ok_or_else(|| format!("Missing field: {}", field))
        .and_then(|s| Uuid::parse_str(s).map_err(|e| format!("Invalid UUID for {}: {}", field, e)))
}

// ---------------------------------------------------------------------------
// Projection adapters
// ---------------------------------------------------------------------------

/// Project IdentityGraph into the algorithm-compatible `Graph<Uuid, EdgeData>` form.
///
/// This preserves all existing algorithm code and tests. The algorithms module
/// operates on `Graph<Uuid, EdgeData, Undirected>` - this adapter bridges the gap.
pub fn project_for_algorithms(
    ig: &IdentityGraph,
) -> Graph<Uuid, EdgeData, Undirected> {
    ig.graph.map(
        |_, nd| nd.id,
        |_, ge| EdgeData {
            weight: ge.weight,
            edge_type: match ge.kind {
                EdgeKind::Identity => "identity".into(),
                EdgeKind::Relationship => "relationship".into(),
            },
            attributes: ge.attributes.clone(),
        },
    )
}

/// Convert an IdentityGraph into the legacy TenantGraph format.
///
/// Used during Phase A to support existing graph_intelligence and relationship
/// handlers without modification. They receive Arc<TenantGraph> just like before.
pub fn to_tenant_graph(ig: &IdentityGraph) -> TenantGraph {
    let projected = project_for_algorithms(ig);

    TenantGraph {
        graph: projected,
        // petgraph::Graph::map preserves node indices, so node_index is valid
        // for both the original and projected graphs.
        node_map: ig.node_index.clone(),
        pagerank: ig.analytics.pagerank.clone(),
        betweenness: ig.analytics.betweenness.clone(),
        bridge_scores: ig.analytics.bridge_scores.clone(),
        components: ig.analytics.components.clone(),
        node_component: ig.analytics.node_component.clone(),
        risk_scores: ig.analytics.risk_scores.clone(),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn make_node(name: &str, members: u32) -> NodeData {
        NodeData {
            id: Uuid::new_v4(),
            entity_type: "person".into(),
            member_count: members,
            display_name: Some(name.into()),
            is_locked: false,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    #[test]
    fn test_new_graph_empty() {
        let ig = IdentityGraph::new(Uuid::nil());
        assert_eq!(ig.graph.node_count(), 0);
        assert_eq!(ig.graph.edge_count(), 0);
        assert_eq!(ig.version, 0);
    }

    #[test]
    fn test_add_entity_increments_version() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let node = make_node("Alice", 3);
        ig.add_entity(node);
        assert_eq!(ig.version, 1);
        assert_eq!(ig.graph.node_count(), 1);
    }

    #[test]
    fn test_add_node_no_version_change() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let node = make_node("Bob", 2);
        ig.add_node(node);
        assert_eq!(ig.version, 0);
        assert_eq!(ig.graph.node_count(), 1);
    }

    #[test]
    fn test_merge_updates_member_count() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let b = make_node("Bob", 2);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let moved = ig.merge(a_id, b_id, "manual", Some(1.0));
        assert_eq!(moved, Some(2));

        // Winner has combined members
        let winner = ig.get_node(&a_id).unwrap();
        assert_eq!(winner.member_count, 5);

        // Loser is removed
        assert!(ig.get_node(&b_id).is_none());
        assert_eq!(ig.graph.node_count(), 1);
    }

    #[test]
    fn test_merge_moves_edges() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let b = make_node("Bob", 2);
        let c = make_node("Charlie", 1);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_node(c);

        // b linked to c
        ig.add_edge(
            b_id,
            c_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.8,
                attributes: None,
                created_at: Utc::now(),
            },
        );

        // Merge b into a
        ig.merge(a_id, b_id, "manual", None);

        // a should now be linked to c (edge moved from b)
        let a_idx = ig.node_index[&a_id];
        let c_idx = ig.node_index[&c_id];
        assert!(ig.graph.find_edge(a_idx, c_idx).is_some());
    }

    #[test]
    fn test_merge_increments_version() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let b = make_node("Bob", 2);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        ig.merge(a_id, b_id, "agent", Some(0.95));

        assert_eq!(ig.version, 1);
        assert_eq!(ig.graph.node_count(), 1);
    }

    #[test]
    fn test_merge_nonexistent_returns_none() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        assert!(ig.merge(a_id, Uuid::new_v4(), "manual", None).is_none());
    }

    #[test]
    fn test_split_creates_new_entities() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 5);
        let a_id = a.id;
        ig.add_node(a);

        let m1 = Uuid::new_v4();
        let m2 = Uuid::new_v4();
        let result = ig
            .split(a_id, &[(m1, "person".into()), (m2, "person".into())])
            .unwrap();

        assert_eq!(result.len(), 2);
        // Original has 5 - 2 = 3 members
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 3);
        // Each new entity has 1 member
        for (_, new_id) in &result {
            assert_eq!(ig.get_node(new_id).unwrap().member_count, 1);
        }
        assert_eq!(ig.graph.node_count(), 3);
    }

    #[test]
    fn test_split_nonexistent_returns_none() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        assert!(ig
            .split(Uuid::new_v4(), &[(Uuid::new_v4(), "person".into())])
            .is_none());
    }

    #[test]
    fn test_reassign_updates_counts() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let b = make_node("Bob", 2);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let ext_id = Uuid::new_v4();
        assert!(ig.reassign(ext_id, a_id, b_id));
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 2);
        assert_eq!(ig.get_node(&b_id).unwrap().member_count, 3);
    }

    #[test]
    fn test_reassign_nonexistent_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        assert!(!ig.reassign(Uuid::new_v4(), a_id, Uuid::new_v4()));
    }

    #[test]
    fn test_delete_empty_entity() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let a_id = a.id;
        ig.add_node(a);

        let deleted = ig.delete_node(a_id);
        assert!(deleted.is_some());
        assert_eq!(ig.graph.node_count(), 0);
    }

    #[test]
    fn test_delete_entity_with_members_fails() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let a_id = a.id;
        ig.add_node(a);

        assert!(ig.delete_node(a_id).is_none());
        assert_eq!(ig.graph.node_count(), 1);
    }

    #[test]
    fn test_link_creates_edge() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        assert!(ig.link(a_id, b_id, 0.75, None));
        assert_eq!(ig.graph.edge_count(), 1);
    }

    #[test]
    fn test_link_upserts_weight() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        ig.link(a_id, b_id, 0.5, None);
        ig.link(a_id, b_id, 0.9, None);

        // Still one edge, but weight updated
        assert_eq!(ig.graph.edge_count(), 1);
        let a_idx = ig.node_index[&a_id];
        let b_idx = ig.node_index[&b_id];
        let edge_idx = ig.graph.find_edge(a_idx, b_idx).unwrap();
        assert!((ig.graph[edge_idx].weight - 0.9).abs() < f64::EPSILON);
    }

    #[test]
    fn test_unlink_removes_edge() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        ig.link(a_id, b_id, 0.75, None);
        assert!(ig.unlink(a_id, b_id));
        assert_eq!(ig.graph.edge_count(), 0);
    }

    #[test]
    fn test_unlink_nonexistent_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        assert!(!ig.unlink(a_id, b_id));
    }

    #[test]
    fn test_analytics_stale_after_mutation() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        assert!(!ig.analytics_stale()); // version 0 == computed_at 0

        let a = make_node("Alice", 1);
        ig.add_entity(a);
        assert!(ig.analytics_stale()); // version 1 > computed_at 0
    }

    #[test]
    fn test_recompute_analytics() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        ig.version = 1;

        ig.recompute_analytics();
        assert!(!ig.analytics_stale());
        assert_eq!(ig.analytics.pagerank.len(), 2);
        assert_eq!(ig.analytics.components.len(), 1);
    }

    #[test]
    fn test_project_for_algorithms() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Identity,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        );

        let projected = project_for_algorithms(&ig);
        assert_eq!(projected.node_count(), 2);
        assert_eq!(projected.edge_count(), 1);

        // Node weights are Uuids
        let n0 = projected[NodeIndex::new(0)];
        assert!(n0 == a_id || n0 == b_id);

        // Edge type mapped correctly
        let edge = projected.edge_weight(petgraph::graph::EdgeIndex::new(0)).unwrap();
        assert_eq!(edge.edge_type, "identity");
    }

    #[test]
    fn test_to_tenant_graph() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.8,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        ig.recompute_analytics();

        let tg = to_tenant_graph(&ig);
        assert_eq!(tg.graph.node_count(), 2);
        assert_eq!(tg.graph.edge_count(), 1);
        assert_eq!(tg.node_map.len(), 2);
        assert!(tg.node_map.contains_key(&a_id));
        assert!(tg.node_map.contains_key(&b_id));
    }

    #[test]
    fn test_remove_node_index_swap() {
        // Test that node removal correctly handles petgraph's swap-remove
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let b = make_node("Bob", 0);
        let c = make_node("Charlie", 0);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        ig.add_node(a); // index 0
        ig.add_node(b); // index 1
        ig.add_node(c); // index 2

        // Remove middle node (b at index 1). Node c (index 2) moves to index 1.
        ig.remove_node_internal(b_id);

        assert_eq!(ig.graph.node_count(), 2);
        assert!(ig.node_index.get(&b_id).is_none());

        // a should still be at index 0
        let a_idx = ig.node_index[&a_id];
        assert_eq!(ig.graph[a_idx].id, a_id);

        // c should now be at whatever index it was moved to
        let c_idx = ig.node_index[&c_id];
        assert_eq!(ig.graph[c_idx].id, c_id);
    }

    #[test]
    fn test_mutation_type_serde_roundtrip() {
        let mt = MutationType::EntitiesMerged {
            winner_id: Uuid::nil(),
            loser_id: Uuid::nil(),
            merge_type: "manual".into(),
            confidence: Some(0.95),
            members_moved: 3,
        };
        let json = serde_json::to_string(&mt).unwrap();
        let back: MutationType = serde_json::from_str(&json).unwrap();
        match back {
            MutationType::EntitiesMerged {
                merge_type,
                members_moved,
                ..
            } => {
                assert_eq!(merge_type, "manual");
                assert_eq!(members_moved, 3);
            }
            _ => panic!("Wrong variant"),
        }
    }

    #[test]
    fn test_edge_kind_serde() {
        let ek = EdgeKind::Identity;
        let json = serde_json::to_string(&ek).unwrap();
        assert_eq!(json, r#""identity""#);
        let back: EdgeKind = serde_json::from_str(&json).unwrap();
        assert_eq!(back, EdgeKind::Identity);
    }

    #[test]
    fn test_graph_mutation_serde() {
        let gm = GraphMutation {
            version: 42,
            mutation_type: MutationType::EntityDeleted {
                entity_id: Uuid::nil(),
            },
            created_at: Utc::now(),
        };
        let json = serde_json::to_value(&gm).unwrap();
        assert_eq!(json["version"], 42);
        assert_eq!(json["mutation_type"]["type"], "entity_deleted");
    }

    // -----------------------------------------------------------------
    // Merge edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_merge_deduplicates_edges() {
        // If winner already has edge to C, merging loser (also linked to C)
        // should NOT create a duplicate edge.
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3); // winner
        let b = make_node("Bob", 2); // loser
        let c = make_node("Charlie", 1);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_node(c);

        // Both a and b linked to c
        ig.add_edge(
            a_id,
            c_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.5,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        ig.add_edge(
            b_id,
            c_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.9,
                attributes: None,
                created_at: Utc::now(),
            },
        );

        ig.merge(a_id, b_id, "manual", None);

        // Should still be exactly 1 edge (a<->c), not 2
        assert_eq!(ig.graph.edge_count(), 1);
        let a_idx = ig.node_index[&a_id];
        let c_idx = ig.node_index[&c_id];
        assert!(ig.graph.find_edge(a_idx, c_idx).is_some());
    }

    #[test]
    fn test_merge_skips_self_loop() {
        // If loser has an edge to winner, it should be skipped (not create self-loop)
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let b = make_node("Bob", 2);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        // Direct edge between winner and loser
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.8,
                attributes: None,
                created_at: Utc::now(),
            },
        );

        ig.merge(a_id, b_id, "manual", None);

        // After merge: only winner remains, no edges (self-loop skipped)
        assert_eq!(ig.graph.node_count(), 1);
        assert_eq!(ig.graph.edge_count(), 0);
    }

    #[test]
    fn test_merge_zero_member_loser() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 5);
        let b = make_node("Bob", 0);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let moved = ig.merge(a_id, b_id, "auto", None);
        assert_eq!(moved, Some(0));
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 5);
    }

    // -----------------------------------------------------------------
    // Split edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_split_underflow_protection() {
        // Splitting more members than member_count should not underflow
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1); // only 1 member
        let a_id = a.id;
        ig.add_node(a);

        let m1 = Uuid::new_v4();
        let m2 = Uuid::new_v4();
        // Split out 2 members from entity with only 1
        let result = ig
            .split(a_id, &[(m1, "person".into()), (m2, "person".into())])
            .unwrap();

        assert_eq!(result.len(), 2);
        // member_count clamped at 0, not wrapped to u32::MAX
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 0);
    }

    #[test]
    fn test_split_returns_correct_result() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 5);
        let a_id = a.id;
        ig.add_node(a);

        let m1 = Uuid::new_v4();
        let result = ig.split(a_id, &[(m1, "person".into())]).unwrap();
        assert_eq!(result.len(), 1);

        let (ejected_member, new_id) = result[0];
        assert_eq!(ejected_member, m1);

        // Version incremented
        assert_eq!(ig.version, 1);

        // Original entity has reduced member count
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 4);

        // New entity exists with 1 member
        let new_node = ig.get_node(&new_id).unwrap();
        assert_eq!(new_node.member_count, 1);
        assert_eq!(new_node.entity_type, "person");
    }

    #[test]
    fn test_split_empty_ejected_list() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 5);
        let a_id = a.id;
        ig.add_node(a);

        let result = ig.split(a_id, &[]).unwrap();
        assert!(result.is_empty());
        // Version still incremented, remaining is 5
        assert_eq!(ig.version, 1);
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 5);
    }

    // -----------------------------------------------------------------
    // Link/Unlink edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_link_nonexistent_a_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let b = make_node("Bob", 1);
        let b_id = b.id;
        ig.add_node(b);

        assert!(!ig.link(Uuid::new_v4(), b_id, 0.5, None));
        assert_eq!(ig.graph.edge_count(), 0);
        // Version should NOT increment on failure
        assert_eq!(ig.version, 0);
    }

    #[test]
    fn test_link_nonexistent_b_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        assert!(!ig.link(a_id, Uuid::new_v4(), 0.5, None));
        assert_eq!(ig.version, 0);
    }

    #[test]
    fn test_link_with_attributes() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let attrs = serde_json::json!({"relationship": "employer", "since": "2024"});
        assert!(ig.link(a_id, b_id, 0.9, Some(attrs.clone())));

        let a_idx = ig.node_index[&a_id];
        let b_idx = ig.node_index[&b_id];
        let edge_idx = ig.graph.find_edge(a_idx, b_idx).unwrap();
        let edge = &ig.graph[edge_idx];
        assert_eq!(edge.kind, EdgeKind::Relationship);
        assert_eq!(edge.attributes.as_ref().unwrap()["relationship"], "employer");
    }

    #[test]
    fn test_link_upsert_updates_attributes() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        ig.link(a_id, b_id, 0.5, Some(serde_json::json!({"old": true})));
        ig.link(a_id, b_id, 0.9, Some(serde_json::json!({"new": true})));

        let a_idx = ig.node_index[&a_id];
        let b_idx = ig.node_index[&b_id];
        let edge_idx = ig.graph.find_edge(a_idx, b_idx).unwrap();
        let edge = &ig.graph[edge_idx];
        // Attributes should be replaced, not merged
        assert!(edge.attributes.as_ref().unwrap().get("new").is_some());
        assert!(edge.attributes.as_ref().unwrap().get("old").is_none());
    }

    #[test]
    fn test_unlink_nonexistent_entity_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        // b doesn't exist
        assert!(!ig.unlink(a_id, Uuid::new_v4()));
        assert_eq!(ig.version, 0);
    }

    // -----------------------------------------------------------------
    // add_edge edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_add_edge_nonexistent_a_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let b = make_node("Bob", 1);
        let b_id = b.id;
        ig.add_node(b);

        assert!(!ig.add_edge(
            Uuid::new_v4(),
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        ));
    }

    #[test]
    fn test_add_edge_nonexistent_b_returns_false() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        assert!(!ig.add_edge(
            a_id,
            Uuid::new_v4(),
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        ));
    }

    // -----------------------------------------------------------------
    // Delete edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_delete_nonexistent_returns_none() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        assert!(ig.delete_node(Uuid::new_v4()).is_none());
        assert_eq!(ig.version, 0);
    }

    #[test]
    fn test_delete_removes_all_edges() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.5,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        assert_eq!(ig.graph.edge_count(), 1);

        ig.delete_node(a_id);
        assert_eq!(ig.graph.edge_count(), 0);
        assert_eq!(ig.graph.node_count(), 1);
    }

    // -----------------------------------------------------------------
    // remove_node_internal edge cases
    // -----------------------------------------------------------------

    #[test]
    fn test_remove_last_node_no_swap() {
        // When removing the only node, no swap needed
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let a_id = a.id;
        ig.add_node(a);

        let removed = ig.remove_node_internal(a_id);
        assert!(removed.is_some());
        assert_eq!(removed.unwrap().id, a_id);
        assert_eq!(ig.graph.node_count(), 0);
        assert!(ig.node_index.is_empty());
    }

    #[test]
    fn test_remove_tail_node_no_swap_needed() {
        // Remove the last-indexed node - no swap happens
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let b = make_node("Bob", 0);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a); // index 0
        ig.add_node(b); // index 1

        // Remove b (last index). No swap needed.
        ig.remove_node_internal(b_id);
        assert_eq!(ig.graph.node_count(), 1);
        assert!(ig.node_index.get(&b_id).is_none());
        let a_idx = ig.node_index[&a_id];
        assert_eq!(ig.graph[a_idx].id, a_id);
    }

    #[test]
    fn test_remove_node_internal_nonexistent() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        assert!(ig.remove_node_internal(Uuid::new_v4()).is_none());
    }

    // -----------------------------------------------------------------
    // ensure_analytics
    // -----------------------------------------------------------------

    #[test]
    fn test_ensure_analytics_skips_when_fresh() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        ig.add_node(a);
        ig.recompute_analytics();

        // analytics.computed_at_version == 0, version == 0 => fresh
        assert!(!ig.analytics_stale());
        ig.ensure_analytics(); // should be a no-op
        assert!(!ig.analytics_stale());
    }

    // -----------------------------------------------------------------
    // Version monotonicity across multiple operations
    // -----------------------------------------------------------------

    #[test]
    fn test_version_monotonic_across_operations() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 5);
        let b = make_node("Bob", 3);
        let c = make_node("Charlie", 1);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_node(c);

        // Sequence: add_entity -> link -> merge -> unlink
        let d = make_node("Diana", 2);
        let _d_id = d.id;
        ig.add_entity(d); // version 1
        assert_eq!(ig.version, 1);

        ig.link(a_id, b_id, 0.5, None); // version 2
        assert_eq!(ig.version, 2);

        ig.merge(b_id, c_id, "auto", Some(0.9)); // version 3
        assert_eq!(ig.version, 3);

        ig.unlink(a_id, b_id); // version 4
        assert_eq!(ig.version, 4);

        // All 4 operations incremented version correctly
        assert_eq!(ig.version, 4);
    }

    // -----------------------------------------------------------------
    // Serde roundtrip coverage for all MutationType variants
    // -----------------------------------------------------------------

    #[test]
    fn test_mutation_type_serde_entity_created() {
        let mt = MutationType::EntityCreated {
            entity_id: Uuid::nil(),
            entity_type: "company".into(),
            member_count: 7,
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "entity_created");
        assert_eq!(json["entity_type"], "company");
        assert_eq!(json["member_count"], 7);
        let back: MutationType = serde_json::from_value(json).unwrap();
        match back {
            MutationType::EntityCreated { entity_type, member_count, .. } => {
                assert_eq!(entity_type, "company");
                assert_eq!(member_count, 7);
            }
            _ => panic!("Wrong variant"),
        }
    }

    #[test]
    fn test_mutation_type_serde_entity_split() {
        let mt = MutationType::EntitySplit {
            original_id: Uuid::nil(),
            new_entities: vec![
                SplitNewNode {
                    new_entity_id: Uuid::nil(),
                    member_count: 1,
                },
            ],
            remaining_members: 3,
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "entity_split");
        assert_eq!(json["remaining_members"], 3);
        assert_eq!(json["new_entities"].as_array().unwrap().len(), 1);
        let back: MutationType = serde_json::from_value(json).unwrap();
        match back {
            MutationType::EntitySplit { remaining_members, new_entities, .. } => {
                assert_eq!(remaining_members, 3);
                assert_eq!(new_entities.len(), 1);
            }
            _ => panic!("Wrong variant"),
        }
    }

    #[test]
    fn test_mutation_type_serde_member_reassigned() {
        let mt = MutationType::MemberReassigned {
            external_entity_id: Uuid::nil(),
            from_entity_id: Uuid::nil(),
            to_entity_id: Uuid::nil(),
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "member_reassigned");
        let back: MutationType = serde_json::from_value(json).unwrap();
        assert!(matches!(back, MutationType::MemberReassigned { .. }));
    }

    #[test]
    fn test_mutation_type_serde_edge_linked() {
        let mt = MutationType::EdgeLinked {
            entity_a_id: Uuid::nil(),
            entity_b_id: Uuid::nil(),
            weight: 0.75,
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "edge_linked");
        assert!((json["weight"].as_f64().unwrap() - 0.75).abs() < f64::EPSILON);
        let back: MutationType = serde_json::from_value(json).unwrap();
        match back {
            MutationType::EdgeLinked { weight, .. } => {
                assert!((weight - 0.75).abs() < f64::EPSILON);
            }
            _ => panic!("Wrong variant"),
        }
    }

    #[test]
    fn test_mutation_type_serde_edge_unlinked() {
        let mt = MutationType::EdgeUnlinked {
            entity_a_id: Uuid::nil(),
            entity_b_id: Uuid::nil(),
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "edge_unlinked");
        let back: MutationType = serde_json::from_value(json).unwrap();
        assert!(matches!(back, MutationType::EdgeUnlinked { .. }));
    }

    #[test]
    fn test_mutation_type_serde_batch_reconciled() {
        let mt = MutationType::BatchReconciled {
            batch_run_id: Uuid::nil(),
            entities_created: 100,
            merges_detected: 25,
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "batch_reconciled");
        assert_eq!(json["entities_created"], 100);
        assert_eq!(json["merges_detected"], 25);
        let back: MutationType = serde_json::from_value(json).unwrap();
        match back {
            MutationType::BatchReconciled { entities_created, merges_detected, .. } => {
                assert_eq!(entities_created, 100);
                assert_eq!(merges_detected, 25);
            }
            _ => panic!("Wrong variant"),
        }
    }

    #[test]
    fn test_mutation_type_serde_entity_updated() {
        let mt = MutationType::EntityUpdated {
            entity_id: Uuid::nil(),
            fields_changed: vec!["email".into(), "phone".into()],
        };
        let json = serde_json::to_value(&mt).unwrap();
        assert_eq!(json["type"], "entity_updated");
        let fields = json["fields_changed"].as_array().unwrap();
        assert_eq!(fields.len(), 2);
        assert_eq!(fields[0], "email");
        let back: MutationType = serde_json::from_value(json).unwrap();
        match back {
            MutationType::EntityUpdated { fields_changed, .. } => {
                assert_eq!(fields_changed, vec!["email", "phone"]);
            }
            _ => panic!("Wrong variant"),
        }
    }

    // -----------------------------------------------------------------
    // Projection: to_tenant_graph with empty graph
    // -----------------------------------------------------------------

    #[test]
    fn test_to_tenant_graph_empty() {
        let ig = IdentityGraph::new(Uuid::nil());
        let tg = to_tenant_graph(&ig);
        assert_eq!(tg.graph.node_count(), 0);
        assert_eq!(tg.graph.edge_count(), 0);
        assert!(tg.node_map.is_empty());
        assert!(tg.pagerank.is_empty());
        assert!(tg.components.is_empty());
    }

    // -----------------------------------------------------------------
    // EdgeKind serde: relationship variant
    // -----------------------------------------------------------------

    #[test]
    fn test_edge_kind_serde_relationship() {
        let ek = EdgeKind::Relationship;
        let json = serde_json::to_string(&ek).unwrap();
        assert_eq!(json, r#""relationship""#);
        let back: EdgeKind = serde_json::from_str(&json).unwrap();
        assert_eq!(back, EdgeKind::Relationship);
    }

    // -----------------------------------------------------------------
    // get_node on empty graph
    // -----------------------------------------------------------------

    #[test]
    fn test_get_node_empty_graph() {
        let ig = IdentityGraph::new(Uuid::nil());
        assert!(ig.get_node(&Uuid::new_v4()).is_none());
    }

    #[test]
    fn test_get_node_returns_correct_data() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let a_id = a.id;
        ig.add_node(a);

        let node = ig.get_node(&a_id).unwrap();
        assert_eq!(node.id, a_id);
        assert_eq!(node.entity_type, "person");
        assert_eq!(node.member_count, 3);
        assert_eq!(node.display_name.as_deref(), Some("Alice"));
        assert!(!node.is_locked);
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - entity_created
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_entity_created() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let id = Uuid::new_v4();
        let payload = serde_json::json!({
            "entity_id": id.to_string(),
            "entity_type": "person",
            "member_count": 3
        });

        ig.apply_mutation_from_log(1, "entity_created", &payload).unwrap();

        assert_eq!(ig.graph.node_count(), 1);
        let node = ig.get_node(&id).unwrap();
        assert_eq!(node.entity_type, "person");
        assert_eq!(node.member_count, 3);
        assert_eq!(ig.version, 1);
    }

    #[test]
    fn test_apply_log_entity_created_defaults() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let id = Uuid::new_v4();
        // Missing entity_type and member_count should default
        let payload = serde_json::json!({
            "entity_id": id.to_string()
        });

        ig.apply_mutation_from_log(1, "entity_created", &payload).unwrap();

        let node = ig.get_node(&id).unwrap();
        assert_eq!(node.entity_type, "unknown");
        assert_eq!(node.member_count, 0);
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - entities_merged
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_entities_merged() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let winner = make_node("Alice", 3);
        let loser = make_node("Bob", 2);
        let w_id = winner.id;
        let l_id = loser.id;
        ig.add_node(winner);
        ig.add_node(loser);

        let payload = serde_json::json!({
            "winner_id": w_id.to_string(),
            "loser_id": l_id.to_string()
        });

        ig.apply_mutation_from_log(5, "entities_merged", &payload).unwrap();

        assert_eq!(ig.graph.node_count(), 1);
        assert_eq!(ig.get_node(&w_id).unwrap().member_count, 5);
        assert!(ig.get_node(&l_id).is_none());
        assert_eq!(ig.version, 5);
    }

    #[test]
    fn test_apply_log_merge_missing_entity_warns_not_errors() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({
            "winner_id": Uuid::new_v4().to_string(),
            "loser_id": Uuid::new_v4().to_string()
        });

        // Should succeed (warn, not error) when entities missing
        let result = ig.apply_mutation_from_log(1, "entities_merged", &payload);
        assert!(result.is_ok());
        assert_eq!(ig.graph.node_count(), 0);
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - entity_split
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_entity_split() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let original = make_node("Original", 5);
        let orig_id = original.id;
        ig.add_node(original);

        let new1 = Uuid::new_v4();
        let new2 = Uuid::new_v4();
        let payload = serde_json::json!({
            "original_id": orig_id.to_string(),
            "new_entities": [
                {"new_entity_id": new1.to_string(), "member_count": 1},
                {"new_entity_id": new2.to_string(), "member_count": 2}
            ],
            "remaining_members": 2
        });

        ig.apply_mutation_from_log(3, "entity_split", &payload).unwrap();

        assert_eq!(ig.graph.node_count(), 3);
        assert_eq!(ig.get_node(&orig_id).unwrap().member_count, 2); // 5 - 1 - 2
        assert_eq!(ig.get_node(&new1).unwrap().member_count, 1);
        assert_eq!(ig.get_node(&new2).unwrap().member_count, 2);
        // New entities inherit entity_type from original
        assert_eq!(ig.get_node(&new1).unwrap().entity_type, "person");
    }

    #[test]
    fn test_apply_log_split_no_new_entities_field() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let original = make_node("Orig", 5);
        let orig_id = original.id;
        ig.add_node(original);

        // Missing new_entities field - should be a no-op on graph structure
        let payload = serde_json::json!({
            "original_id": orig_id.to_string()
        });

        ig.apply_mutation_from_log(1, "entity_split", &payload).unwrap();
        assert_eq!(ig.graph.node_count(), 1);
        assert_eq!(ig.get_node(&orig_id).unwrap().member_count, 5);
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - member_reassigned
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_member_reassigned() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let from = make_node("From", 4);
        let to = make_node("To", 2);
        let from_id = from.id;
        let to_id = to.id;
        ig.add_node(from);
        ig.add_node(to);

        let ext_id = Uuid::new_v4();
        let payload = serde_json::json!({
            "external_entity_id": ext_id.to_string(),
            "from_entity_id": from_id.to_string(),
            "to_entity_id": to_id.to_string()
        });

        ig.apply_mutation_from_log(2, "member_reassigned", &payload).unwrap();

        assert_eq!(ig.get_node(&from_id).unwrap().member_count, 3);
        assert_eq!(ig.get_node(&to_id).unwrap().member_count, 3);
        assert_eq!(ig.version, 2);
    }

    #[test]
    fn test_apply_log_reassign_missing_entity_warns_not_errors() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({
            "external_entity_id": Uuid::new_v4().to_string(),
            "from_entity_id": Uuid::new_v4().to_string(),
            "to_entity_id": Uuid::new_v4().to_string()
        });

        let result = ig.apply_mutation_from_log(1, "member_reassigned", &payload);
        assert!(result.is_ok());
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - entity_deleted
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_entity_deleted() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 0);
        let a_id = a.id;
        ig.add_node(a);

        let payload = serde_json::json!({
            "entity_id": a_id.to_string()
        });

        ig.apply_mutation_from_log(4, "entity_deleted", &payload).unwrap();

        assert_eq!(ig.graph.node_count(), 0);
        assert!(ig.get_node(&a_id).is_none());
        assert_eq!(ig.version, 4);
    }

    #[test]
    fn test_apply_log_delete_entity_with_members_warns() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3); // has members - delete will fail
        let a_id = a.id;
        ig.add_node(a);

        let payload = serde_json::json!({
            "entity_id": a_id.to_string()
        });

        // Should succeed (warn) - entity not deleted because member_count > 0
        let result = ig.apply_mutation_from_log(1, "entity_deleted", &payload);
        assert!(result.is_ok());
        assert_eq!(ig.graph.node_count(), 1); // still present
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - edge_linked
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_edge_linked() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let payload = serde_json::json!({
            "entity_a_id": a_id.to_string(),
            "entity_b_id": b_id.to_string(),
            "weight": 0.75
        });

        ig.apply_mutation_from_log(1, "edge_linked", &payload).unwrap();

        assert_eq!(ig.graph.edge_count(), 1);
    }

    #[test]
    fn test_apply_log_edge_linked_default_weight() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        // Missing weight field should default to 1.0
        let payload = serde_json::json!({
            "entity_a_id": a_id.to_string(),
            "entity_b_id": b_id.to_string()
        });

        ig.apply_mutation_from_log(1, "edge_linked", &payload).unwrap();

        let a_idx = ig.node_index[&a_id];
        let b_idx = ig.node_index[&b_id];
        let edge_idx = ig.graph.find_edge(a_idx, b_idx).unwrap();
        assert!((ig.graph[edge_idx].weight - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_apply_log_link_missing_entity_warns_not_errors() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({
            "entity_a_id": Uuid::new_v4().to_string(),
            "entity_b_id": Uuid::new_v4().to_string()
        });

        let result = ig.apply_mutation_from_log(1, "edge_linked", &payload);
        assert!(result.is_ok());
        assert_eq!(ig.graph.edge_count(), 0);
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - edge_unlinked
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_edge_unlinked() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.link(a_id, b_id, 0.8, None);
        assert_eq!(ig.graph.edge_count(), 1);

        let payload = serde_json::json!({
            "entity_a_id": a_id.to_string(),
            "entity_b_id": b_id.to_string()
        });

        ig.apply_mutation_from_log(1, "edge_unlinked", &payload).unwrap();

        assert_eq!(ig.graph.edge_count(), 0);
    }

    #[test]
    fn test_apply_log_unlink_missing_edge_warns_not_errors() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);

        let payload = serde_json::json!({
            "entity_a_id": a_id.to_string(),
            "entity_b_id": b_id.to_string()
        });

        // No edge exists - should warn, not error
        let result = ig.apply_mutation_from_log(1, "edge_unlinked", &payload);
        assert!(result.is_ok());
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - informational and unknown types
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_batch_reconciled_noop() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({"batch_id": "abc"});

        ig.apply_mutation_from_log(1, "batch_reconciled", &payload).unwrap();

        assert_eq!(ig.graph.node_count(), 0);
        assert_eq!(ig.version, 1); // version still advances
    }

    #[test]
    fn test_apply_log_entity_updated_noop() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({"entity_id": Uuid::new_v4().to_string()});

        ig.apply_mutation_from_log(2, "entity_updated", &payload).unwrap();

        assert_eq!(ig.version, 2);
    }

    #[test]
    fn test_apply_log_unknown_type_returns_error() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({});

        let result = ig.apply_mutation_from_log(1, "totally_unknown", &payload);

        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Unknown mutation type"));
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - payload validation errors
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_missing_uuid_field() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        // entity_created requires entity_id
        let payload = serde_json::json!({"entity_type": "person"});

        let result = ig.apply_mutation_from_log(1, "entity_created", &payload);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Missing field: entity_id"));
    }

    #[test]
    fn test_apply_log_invalid_uuid_format() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({"entity_id": "not-a-uuid"});

        let result = ig.apply_mutation_from_log(1, "entity_created", &payload);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Invalid UUID for entity_id"));
    }

    #[test]
    fn test_apply_log_merge_missing_loser_id() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({
            "winner_id": Uuid::new_v4().to_string()
            // loser_id missing
        });

        let result = ig.apply_mutation_from_log(1, "entities_merged", &payload);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Missing field: loser_id"));
    }

    #[test]
    fn test_apply_log_reassign_missing_field() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let payload = serde_json::json!({
            "external_entity_id": Uuid::new_v4().to_string(),
            "from_entity_id": Uuid::new_v4().to_string()
            // to_entity_id missing
        });

        let result = ig.apply_mutation_from_log(1, "member_reassigned", &payload);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Missing field: to_entity_id"));
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - version handling
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_version_updates_to_max() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let id = Uuid::new_v4();
        let payload = serde_json::json!({"entity_id": id.to_string()});

        ig.apply_mutation_from_log(10, "entity_created", &payload).unwrap();
        assert_eq!(ig.version, 10);

        // Apply higher version
        let id2 = Uuid::new_v4();
        let payload2 = serde_json::json!({"entity_id": id2.to_string()});
        ig.apply_mutation_from_log(20, "entity_created", &payload2).unwrap();
        assert_eq!(ig.version, 20);
    }

    #[test]
    fn test_apply_log_negative_version_clamped_to_zero() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let id = Uuid::new_v4();
        let payload = serde_json::json!({"entity_id": id.to_string()});

        ig.apply_mutation_from_log(-5, "entity_created", &payload).unwrap();
        assert_eq!(ig.version, 0); // negative clamped to 0 via .max(0)
    }

    // -----------------------------------------------------------------
    // apply_mutation_from_log - complex multi-step replay
    // -----------------------------------------------------------------

    #[test]
    fn test_apply_log_full_lifecycle_replay() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let e1 = Uuid::new_v4();
        let e2 = Uuid::new_v4();
        let e3 = Uuid::new_v4();

        // Step 1: Create 3 entities
        for (v, id, mc) in [(1, e1, 3), (2, e2, 2), (3, e3, 1)] {
            ig.apply_mutation_from_log(v, "entity_created", &serde_json::json!({
                "entity_id": id.to_string(),
                "entity_type": "person",
                "member_count": mc
            })).unwrap();
        }
        assert_eq!(ig.graph.node_count(), 3);

        // Step 2: Link e1 and e2
        ig.apply_mutation_from_log(4, "edge_linked", &serde_json::json!({
            "entity_a_id": e1.to_string(),
            "entity_b_id": e2.to_string(),
            "weight": 0.9
        })).unwrap();
        assert_eq!(ig.graph.edge_count(), 1);

        // Step 3: Merge e2 into e1
        ig.apply_mutation_from_log(5, "entities_merged", &serde_json::json!({
            "winner_id": e1.to_string(),
            "loser_id": e2.to_string()
        })).unwrap();
        assert_eq!(ig.graph.node_count(), 2);
        assert_eq!(ig.get_node(&e1).unwrap().member_count, 5);

        // Step 4: Delete e3 (has 1 member, delete will warn but not crash)
        ig.apply_mutation_from_log(6, "entity_deleted", &serde_json::json!({
            "entity_id": e3.to_string()
        })).unwrap();
        // e3 has members so delete_node fails, but apply_log just warns
        assert_eq!(ig.graph.node_count(), 2);

        assert_eq!(ig.version, 6);
    }

    // -----------------------------------------------------------------
    // merge: self-merge edge case
    // -----------------------------------------------------------------

    #[test]
    fn test_merge_same_entity_returns_none() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let a_id = a.id;
        ig.add_node(a);

        // Merging with self - loser lookup will fail after removal attempt
        let result = ig.merge(a_id, a_id, "manual", None);
        // The loser is the same node as winner, but merge removes loser first
        // so either it returns None (loser not found after winner takes it)
        // or Some(count). Current implementation: loser removed, so winner
        // node at that index might have shifted. This tests the actual behavior.
        // The result depends on petgraph swap-remove semantics.
        // Key assertion: no panic, graph is still in a valid state
        assert!(ig.graph.node_count() <= 1);
    }

    // -----------------------------------------------------------------
    // reassign: self-reassign edge case
    // -----------------------------------------------------------------

    #[test]
    fn test_reassign_same_entity_is_noop() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 3);
        let a_id = a.id;
        ig.add_node(a);

        let ext = Uuid::new_v4();
        let result = ig.reassign(ext, a_id, a_id);
        // Should succeed and counts unchanged (decremented then incremented)
        assert!(result);
        assert_eq!(ig.get_node(&a_id).unwrap().member_count, 3);
    }

    // -----------------------------------------------------------------
    // link: self-loop edge case
    // -----------------------------------------------------------------

    #[test]
    fn test_link_self_loop() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);

        // petgraph allows self-loops in undirected graphs
        let result = ig.link(a_id, a_id, 0.5, None);
        assert!(result);
    }

    // -----------------------------------------------------------------
    // ensure_analytics: stale recomputation path
    // -----------------------------------------------------------------

    #[test]
    fn test_ensure_analytics_recomputes_when_stale() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let a_id = a.id;
        let b_id = b.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        ig.version = 5; // analytics at 0, version at 5 = stale
        assert!(ig.analytics_stale());
        assert!(ig.analytics.pagerank.is_empty());

        ig.ensure_analytics();

        assert!(!ig.analytics_stale());
        assert_eq!(ig.analytics.pagerank.len(), 2);
        assert_eq!(ig.analytics.components.len(), 1);
    }

    // -----------------------------------------------------------------
    // recompute_analytics: empty graph and disconnected components
    // -----------------------------------------------------------------

    #[test]
    fn test_recompute_analytics_empty_graph() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        ig.version = 1;
        ig.recompute_analytics();

        assert!(!ig.analytics_stale());
        assert!(ig.analytics.pagerank.is_empty());
        assert!(ig.analytics.components.is_empty());
        assert!(ig.analytics.risk_scores.is_empty());
    }

    #[test]
    fn test_recompute_analytics_disconnected_components() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let c = make_node("Charlie", 1);
        let d = make_node("Diana", 1);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        let d_id = d.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_node(c);
        ig.add_node(d);

        // Two disconnected pairs: (a, b) and (c, d)
        let edge = GraphEdge {
            kind: EdgeKind::Relationship,
            weight: 1.0,
            attributes: None,
            created_at: Utc::now(),
        };
        ig.add_edge(a_id, b_id, edge.clone());
        ig.add_edge(c_id, d_id, edge);
        ig.version = 1;

        ig.recompute_analytics();

        assert_eq!(ig.analytics.components.len(), 2);
        assert_eq!(ig.analytics.node_component.len(), 4);
        // Nodes in the same component should map to the same index
        let comp_a = ig.analytics.node_component[&a_id];
        let comp_b = ig.analytics.node_component[&b_id];
        assert_eq!(comp_a, comp_b);

        let comp_c = ig.analytics.node_component[&c_id];
        let comp_d = ig.analytics.node_component[&d_id];
        assert_eq!(comp_c, comp_d);
        assert_ne!(comp_a, comp_c);
    }

    #[test]
    fn test_recompute_analytics_single_isolated_node() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let a_id = a.id;
        ig.add_node(a);
        ig.version = 1;

        ig.recompute_analytics();

        assert_eq!(ig.analytics.components.len(), 1);
        assert_eq!(ig.analytics.components[0].len(), 1);
        assert!(ig.analytics.risk_scores.contains_key(&a_id));
    }

    // -----------------------------------------------------------------
    // project_for_algorithms: edge type mapping
    // -----------------------------------------------------------------

    #[test]
    fn test_project_for_algorithms_both_edge_types() {
        let mut ig = IdentityGraph::new(Uuid::nil());
        let a = make_node("Alice", 1);
        let b = make_node("Bob", 1);
        let c = make_node("Charlie", 1);
        let a_id = a.id;
        let b_id = b.id;
        let c_id = c.id;
        ig.add_node(a);
        ig.add_node(b);
        ig.add_node(c);
        ig.add_edge(
            a_id,
            b_id,
            GraphEdge {
                kind: EdgeKind::Identity,
                weight: 1.0,
                attributes: None,
                created_at: Utc::now(),
            },
        );
        ig.add_edge(
            b_id,
            c_id,
            GraphEdge {
                kind: EdgeKind::Relationship,
                weight: 0.5,
                attributes: Some(serde_json::json!({"note": "partner"})),
                created_at: Utc::now(),
            },
        );

        let projected = project_for_algorithms(&ig);
        assert_eq!(projected.node_count(), 3);
        assert_eq!(projected.edge_count(), 2);

        // Verify edge types preserved
        let mut types: Vec<String> = projected
            .edge_indices()
            .map(|e| projected[e].edge_type.clone())
            .collect();
        types.sort();
        assert_eq!(types, vec!["identity", "relationship"]);
    }

    #[test]
    fn test_project_for_algorithms_empty_graph() {
        let ig = IdentityGraph::new(Uuid::nil());
        let projected = project_for_algorithms(&ig);
        assert_eq!(projected.node_count(), 0);
        assert_eq!(projected.edge_count(), 0);
    }
}
