//! Integration tests for GraphRegistry against the real database.
//!
//! These tests require a running PostgreSQL database accessible via
//! DATABASE_URL or the default test connection string.
//!
//! Run with: DATABASE_URL="postgres://kanoniv_app:...@localhost:5433/kanoniv" cargo test -p cannon-common --test graph_registry_test

use cannon_common::graph::materializer::Materializer;
use cannon_common::graph::model::{to_tenant_graph, EdgeKind, IdentityGraph, NodeData};
use cannon_common::graph::registry::GraphRegistry;
use chrono::Utc;
use sqlx::postgres::PgPoolOptions;
use sqlx::Row;
use uuid::Uuid;

/// Get a database connection pool, or skip the test if no DB is available.
async fn get_pool() -> sqlx::PgPool {
    let url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv_app:MC2lTtOPyKn2fK6tgZkeMPGD_app@localhost:5433/kanoniv".to_string()
    });

    PgPoolOptions::new()
        .max_connections(5)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .connect(&url)
        .await
        .expect("Failed to connect to test database. Is the SSH tunnel open?")
}

/// Set up a fresh tenant with test data for graph building.
/// Returns (tenant_id, entity_ids).
async fn setup_tenant_with_entities(
    _pool: &sqlx::PgPool,
) -> (Uuid, Vec<Uuid>) {
    let tenant_id = Uuid::new_v4();
    let slug = format!("test-graph-{}", &tenant_id.to_string()[..8]);

    // Use admin connection for setup (bypass RLS)
    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    let admin_pool = PgPoolOptions::new()
        .max_connections(2)
        .connect(&admin_url)
        .await
        .expect("Failed to connect as admin");

    // Create tenant
    sqlx::query(
        "INSERT INTO tenants (id, name, slug, tier) VALUES ($1, $2, $3, 'standard')",
    )
    .bind(tenant_id)
    .bind(format!("Test Graph Tenant {}", &tenant_id.to_string()[..8]))
    .bind(&slug)
    .execute(&admin_pool)
    .await
    .expect("Failed to create test tenant");

    // Create canonical entities
    let entity_a = Uuid::new_v4();
    let entity_b = Uuid::new_v4();
    let entity_c = Uuid::new_v4();

    for (id, etype) in [
        (entity_a, "person"),
        (entity_b, "person"),
        (entity_c, "company"),
    ] {
        sqlx::query(
            "INSERT INTO canonical_entities (id, tenant_id, entity_type, canonical_data, field_provenance, confidence_score, is_locked) \
             VALUES ($1, $2, $3, $4, $5, $6, $7)",
        )
        .bind(id)
        .bind(tenant_id)
        .bind(etype)
        .bind(serde_json::json!({"full_name": format!("Entity {}", &id.to_string()[..8])}))
        .bind(serde_json::json!({}))
        .bind(0.95_f64)
        .bind(false)
        .execute(&admin_pool)
        .await
        .expect("Failed to create canonical entity");
    }

    // Create some identity_links (external_entities -> canonical_entities)
    let source_id = Uuid::new_v4();
    sqlx::query(
        "INSERT INTO data_sources (id, tenant_id, name, source_type) VALUES ($1, $2, 'test-source', 'api')",
    )
    .bind(source_id)
    .bind(tenant_id)
    .execute(&admin_pool)
    .await
    .expect("Failed to create data source");

    for i in 0..3 {
        let ext_id = Uuid::new_v4();
        sqlx::query(
            "INSERT INTO external_entities (id, tenant_id, data_source_id, external_id, entity_type, raw_data) \
             VALUES ($1, $2, $3, $4, $5, $6)",
        )
        .bind(ext_id)
        .bind(tenant_id)
        .bind(source_id)
        .bind(format!("ext-{}", i))
        .bind("person")
        .bind(serde_json::json!({"email": format!("test{}@example.com", i)}))
        .execute(&admin_pool)
        .await
        .expect("Failed to create external entity");

        // Link to entity_a (so entity_a has 3 members)
        sqlx::query(
            "INSERT INTO identity_links (tenant_id, canonical_entity_id, external_entity_id, link_type, confidence, matched_on) \
             VALUES ($1, $2, $3, 'auto', 0.95, '[]'::jsonb)",
        )
        .bind(tenant_id)
        .bind(entity_a)
        .bind(ext_id)
        .execute(&admin_pool)
        .await
        .expect("Failed to create identity link");
    }

    // Create a relationship edge between entity_a and entity_b
    sqlx::query(
        "INSERT INTO relationship_edges \
            (tenant_id, entity_a_id, entity_b_id, canonical_a_id, canonical_b_id, weight, attributes) \
         VALUES ($1, $2, $3, $2, $3, $4, $5)",
    )
    .bind(tenant_id)
    .bind(if entity_a < entity_b { entity_a } else { entity_b })
    .bind(if entity_a < entity_b { entity_b } else { entity_a })
    .bind(0.85_f64)
    .bind(serde_json::json!({"shared_email": true}))
    .execute(&admin_pool)
    .await
    .expect("Failed to create relationship edge");

    // Create an entity merge: entity_b absorbed entity_c
    sqlx::query(
        "INSERT INTO entity_merges \
            (tenant_id, winner_entity_id, loser_entity_id, merge_type, confidence) \
         VALUES ($1, $2, $3, 'auto', 0.95)",
    )
    .bind(tenant_id)
    .bind(entity_b)
    .bind(entity_c)
    .execute(&admin_pool)
    .await
    .expect("Failed to create entity merge");

    admin_pool.close().await;

    (tenant_id, vec![entity_a, entity_b, entity_c])
}

/// Clean up test tenant data. Best-effort.
async fn cleanup_tenant(_pool: &sqlx::PgPool, tenant_id: Uuid) {
    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    if let Ok(admin_pool) = PgPoolOptions::new()
        .max_connections(1)
        .connect(&admin_url)
        .await
    {
        // CASCADE deletes all child tables
        let _ = sqlx::query("DELETE FROM tenants WHERE id = $1")
            .bind(tenant_id)
            .execute(&admin_pool)
            .await;
        admin_pool.close().await;
    }
}

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

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_get_or_load_builds_graph_from_db() {
    let pool = get_pool().await;
    let (tenant_id, entity_ids) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(5);
    let ig_lock = registry
        .get_or_load(tenant_id, &pool)
        .await
        .expect("get_or_load should succeed");

    let ig = ig_lock.read().await;

    // Should have loaded all 3 entities
    assert!(
        ig.graph.node_count() >= 3,
        "Expected at least 3 nodes, got {}",
        ig.graph.node_count()
    );

    // entity_a should be present with member_count=3
    let entity_a = &entity_ids[0];
    let node_a = ig.get_node(entity_a);
    assert!(node_a.is_some(), "entity_a should be in graph");
    assert_eq!(
        node_a.unwrap().member_count, 3,
        "entity_a should have 3 members"
    );
    assert_eq!(node_a.unwrap().entity_type, "person");

    // entity_c should be present as company type
    let entity_c = &entity_ids[2];
    let node_c = ig.get_node(entity_c);
    assert!(node_c.is_some(), "entity_c should be in graph");
    assert_eq!(node_c.unwrap().entity_type, "company");

    // Should have at least 1 relationship edge + 1 identity edge
    assert!(
        ig.graph.edge_count() >= 2,
        "Expected at least 2 edges (1 relationship + 1 identity), got {}",
        ig.graph.edge_count()
    );

    // Analytics should be computed eagerly
    assert!(
        !ig.analytics.pagerank.is_empty(),
        "PageRank should be computed"
    );
    assert!(
        !ig.analytics.components.is_empty(),
        "Components should be computed"
    );

    // display_name should be extracted from canonical_data.full_name
    assert!(
        node_a.unwrap().display_name.is_some(),
        "display_name should be extracted from canonical_data"
    );

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_second_load_returns_cached() {
    let pool = get_pool().await;
    let (tenant_id, _) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(5);

    // First load - from DB
    let ig1 = registry
        .get_or_load(tenant_id, &pool)
        .await
        .expect("first load should succeed");

    // Second load - should return same Arc (fast path)
    let ig2 = registry
        .get_or_load(tenant_id, &pool)
        .await
        .expect("second load should succeed");

    // Both should point to the same underlying RwLock (same Arc)
    assert!(
        std::sync::Arc::ptr_eq(&ig1, &ig2),
        "Second load should return cached graph, not rebuild"
    );

    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_invalidate_forces_rebuild() {
    let pool = get_pool().await;
    let (tenant_id, _) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(5);

    // Load graph
    let ig1 = registry
        .get_or_load(tenant_id, &pool)
        .await
        .expect("load should succeed");

    // Invalidate
    registry.invalidate(&tenant_id).await;

    // Next load should create a NEW Arc (rebuilt from DB)
    let ig2 = registry
        .get_or_load(tenant_id, &pool)
        .await
        .expect("reload should succeed");

    assert!(
        !std::sync::Arc::ptr_eq(&ig1, &ig2),
        "After invalidation, should get a new graph instance"
    );

    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_lru_eviction() {
    let pool = get_pool().await;

    // Create 3 tenants but registry max_resident=2
    let (t1, _) = setup_tenant_with_entities(&pool).await;
    let (t2, _) = setup_tenant_with_entities(&pool).await;
    let (t3, _) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(2);

    // Load t1, t2 (fills capacity)
    let _ig1 = registry.get_or_load(t1, &pool).await.unwrap();
    let _ig2 = registry.get_or_load(t2, &pool).await.unwrap();

    // Load t3 - should evict t1 (least recently used)
    let _ig3 = registry.get_or_load(t3, &pool).await.unwrap();

    // Loading t1 again should succeed (rebuilt from DB) but be a new Arc
    let ig1_reload = registry.get_or_load(t1, &pool).await.unwrap();
    assert!(
        !std::sync::Arc::ptr_eq(&_ig1, &ig1_reload),
        "t1 should have been evicted and rebuilt"
    );

    // t2 should still be cached (it was accessed after t1)
    let ig2_check = registry.get_or_load(t2, &pool).await.unwrap();
    // Note: t2 might or might not be the same Arc depending on eviction order,
    // but the graph should contain valid data
    let ig2_read = ig2_check.read().await;
    assert!(ig2_read.graph.node_count() >= 3);
    drop(ig2_read);

    cleanup_tenant(&pool, t1).await;
    cleanup_tenant(&pool, t2).await;
    cleanup_tenant(&pool, t3).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_to_tenant_graph_projection() {
    let pool = get_pool().await;
    let (tenant_id, entity_ids) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(5);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    let ig = ig_lock.read().await;
    let tg = to_tenant_graph(&ig);

    // TenantGraph should have the same node count
    assert_eq!(
        tg.graph.node_count(),
        ig.graph.node_count(),
        "TenantGraph node count should match IdentityGraph"
    );
    assert_eq!(
        tg.graph.edge_count(),
        ig.graph.edge_count(),
        "TenantGraph edge count should match IdentityGraph"
    );

    // node_map should contain our entities
    let entity_a = &entity_ids[0];
    assert!(
        tg.node_map.contains_key(entity_a),
        "TenantGraph node_map should contain entity_a"
    );

    // Node weights should be Uuids in the projected graph
    let a_idx = tg.node_map[entity_a];
    assert_eq!(tg.graph[a_idx], *entity_a);

    // Analytics should be populated
    assert!(
        !tg.pagerank.is_empty(),
        "TenantGraph pagerank should be populated"
    );

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_graph_mutations_in_memory() {
    let pool = get_pool().await;
    let (tenant_id, entity_ids) = setup_tenant_with_entities(&pool).await;

    let registry = GraphRegistry::new(5);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    // Perform an in-memory merge
    {
        let mut ig = ig_lock.write().await;
        let entity_a = entity_ids[0];
        let entity_b = entity_ids[1];

        let moved = ig.merge(entity_a, entity_b, "test", Some(0.99));
        assert!(moved.is_some(), "Merge should succeed on loaded graph");

        // Winner should have combined members
        let node_a = ig.get_node(&entity_a).unwrap();
        assert!(node_a.member_count >= 3, "Winner should have at least 3 members (had 3 from DB)");

        // Loser should be removed
        assert!(
            ig.get_node(&entity_b).is_none(),
            "Loser should be removed after merge"
        );

        // Version should have incremented from the merge
        assert!(ig.version > 0, "Version should increment after merge");
    }

    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_empty_tenant_builds_empty_graph() {
    let pool = get_pool().await;
    let tenant_id = Uuid::new_v4();

    // Create tenant with no entities
    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    let admin_pool = PgPoolOptions::new()
        .max_connections(1)
        .connect(&admin_url)
        .await
        .unwrap();
    sqlx::query("INSERT INTO tenants (id, name, slug, tier) VALUES ($1, $2, $3, 'standard')")
        .bind(tenant_id)
        .bind(format!("Empty Graph Tenant {}", &tenant_id.to_string()[..8]))
        .bind(format!("empty-graph-{}", &tenant_id.to_string()[..8]))
        .execute(&admin_pool)
        .await
        .unwrap();
    admin_pool.close().await;

    let registry = GraphRegistry::new(5);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig = ig_lock.read().await;

    assert_eq!(ig.graph.node_count(), 0, "Empty tenant should have 0 nodes");
    assert_eq!(ig.graph.edge_count(), 0, "Empty tenant should have 0 edges");
    assert_eq!(ig.version, 0, "Fresh graph should be version 0");

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_relationship_edge_dedup() {
    // Tests that duplicate relationship edges between the same pair are merged (max weight)
    let pool = get_pool().await;
    let tenant_id = Uuid::new_v4();
    let slug = format!("dedup-{}", &tenant_id.to_string()[..8]);

    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    let admin_pool = PgPoolOptions::new()
        .max_connections(2)
        .connect(&admin_url)
        .await
        .unwrap();

    sqlx::query("INSERT INTO tenants (id, name, slug, tier) VALUES ($1, $2, $3, 'standard')")
        .bind(tenant_id)
        .bind("Dedup Test")
        .bind(&slug)
        .execute(&admin_pool)
        .await
        .unwrap();

    let e1 = Uuid::new_v4();
    let e2 = Uuid::new_v4();
    let (small, big) = if e1 < e2 { (e1, e2) } else { (e2, e1) };

    for id in [e1, e2] {
        sqlx::query(
            "INSERT INTO canonical_entities (id, tenant_id, entity_type, canonical_data, field_provenance, confidence_score, is_locked) \
             VALUES ($1, $2, 'person', '{}', '{}', 0.9, false)",
        )
        .bind(id)
        .bind(tenant_id)
        .execute(&admin_pool)
        .await
        .unwrap();
    }

    // Insert two edges between the same pair with different weights
    // The relationship_edges table has a unique constraint on (tenant_id, entity_a_id, entity_b_id)
    // so we can only insert one. But the code merges by canonical_a/b pair, so let's just
    // verify the single edge loads correctly.
    sqlx::query(
        "INSERT INTO relationship_edges (tenant_id, entity_a_id, entity_b_id, canonical_a_id, canonical_b_id, weight, attributes) \
         VALUES ($1, $2, $3, $2, $3, $4, $5)",
    )
    .bind(tenant_id)
    .bind(small)
    .bind(big)
    .bind(0.75_f64)
    .bind(serde_json::json!({"shared": "email"}))
    .execute(&admin_pool)
    .await
    .unwrap();

    admin_pool.close().await;

    let registry = GraphRegistry::new(5);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig = ig_lock.read().await;

    assert_eq!(ig.graph.node_count(), 2);
    assert_eq!(ig.graph.edge_count(), 1);

    // Check the edge has correct weight and kind
    let idx1 = ig.node_index[&e1];
    let idx2 = ig.node_index[&e2];
    let edge_idx = ig.graph.find_edge(idx1, idx2).expect("Edge should exist");
    let edge = &ig.graph[edge_idx];
    assert_eq!(edge.kind, EdgeKind::Relationship);
    assert!((edge.weight - 0.75).abs() < f64::EPSILON);
    assert!(edge.attributes.is_some());

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_graph_mutations_table_write() {
    // Test that we can write to the graph_mutations table.
    // First ensure the table exists (apply migration if needed).
    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    let admin_pool = PgPoolOptions::new()
        .max_connections(2)
        .connect(&admin_url)
        .await
        .expect("Need admin connection to ensure migration is applied");

    // Apply entity_events constraint migration
    let _ = sqlx::query(
        "ALTER TABLE entity_events DROP CONSTRAINT IF EXISTS entity_events_event_type_check"
    )
    .execute(&admin_pool)
    .await;
    let _ = sqlx::query(
        "ALTER TABLE entity_events ADD CONSTRAINT entity_events_event_type_check \
         CHECK (event_type IN (\
            'entity.created', 'entity.updated', 'entity.merged', \
            'entity.split', 'entity.deleted', 'entity.reassigned'\
         ))"
    )
    .execute(&admin_pool)
    .await;

    // Apply graph_mutations table migration idempotently
    sqlx::query(
        "CREATE TABLE IF NOT EXISTS graph_mutations (\
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(), \
            tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, \
            version BIGINT NOT NULL, \
            mutation_type TEXT NOT NULL, \
            payload JSONB NOT NULL, \
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
            CONSTRAINT unique_tenant_version UNIQUE (tenant_id, version)\
        )"
    )
    .execute(&admin_pool)
    .await
    .expect("Failed to create graph_mutations table");

    sqlx::query(
        "CREATE INDEX IF NOT EXISTS idx_graph_mutations_tenant_ver \
         ON graph_mutations(tenant_id, version DESC)"
    )
    .execute(&admin_pool)
    .await
    .expect("Failed to create index");

    sqlx::query("ALTER TABLE graph_mutations ENABLE ROW LEVEL SECURITY")
        .execute(&admin_pool).await.unwrap();
    sqlx::query("ALTER TABLE graph_mutations FORCE ROW LEVEL SECURITY")
        .execute(&admin_pool).await.unwrap();

    // Idempotent policy
    let has_policy: bool = sqlx::query_scalar(
        "SELECT EXISTS(SELECT 1 FROM pg_policies WHERE tablename='graph_mutations' AND policyname='tenant_isolation')"
    )
    .fetch_one(&admin_pool)
    .await
    .unwrap();
    if !has_policy {
        sqlx::query("CREATE POLICY tenant_isolation ON graph_mutations USING (tenant_id = safe_tenant_id())")
            .execute(&admin_pool).await.unwrap();
    }

    sqlx::query("GRANT SELECT, INSERT ON graph_mutations TO kanoniv_app")
        .execute(&admin_pool).await.unwrap();

    admin_pool.close().await;

    let pool = get_pool().await;
    let tenant_id = Uuid::new_v4();

    let admin_url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    let admin_pool = PgPoolOptions::new()
        .max_connections(1)
        .connect(&admin_url)
        .await
        .unwrap();

    sqlx::query("INSERT INTO tenants (id, name, slug, tier) VALUES ($1, $2, $3, 'standard')")
        .bind(tenant_id)
        .bind("Mutations Log Test")
        .bind(format!("mut-log-{}", &tenant_id.to_string()[..8]))
        .execute(&admin_pool)
        .await
        .unwrap();
    admin_pool.close().await;

    // Set tenant context on the app pool connection
    let mut conn = pool.acquire().await.unwrap();
    let tid = tenant_id.to_string();
    sqlx::query("SELECT set_config('request.tenant_id', $1, false)")
        .bind(&tid)
        .fetch_one(&mut *conn)
        .await
        .unwrap();

    // Write a mutation
    let payload = serde_json::json!({
        "type": "entity_created",
        "entity_id": Uuid::new_v4().to_string(),
        "entity_type": "person",
        "member_count": 1
    });

    sqlx::query(
        "INSERT INTO graph_mutations (tenant_id, version, mutation_type, payload) \
         VALUES ($1, $2, $3, $4) \
         ON CONFLICT (tenant_id, version) DO NOTHING",
    )
    .bind(tenant_id)
    .bind(1_i64)
    .bind("entity_created")
    .bind(&payload)
    .execute(&mut *conn)
    .await
    .expect("Should be able to write to graph_mutations table");

    // Read it back
    let row = sqlx::query(
        "SELECT version, mutation_type, payload FROM graph_mutations \
         WHERE tenant_id = $1 AND version = $2",
    )
    .bind(tenant_id)
    .bind(1_i64)
    .fetch_one(&mut *conn)
    .await
    .expect("Should be able to read from graph_mutations table");

    let version: i64 = row.get("version");
    let mutation_type: String = row.get("mutation_type");
    let stored_payload: serde_json::Value = row.get("payload");

    assert_eq!(version, 1);
    assert_eq!(mutation_type, "entity_created");
    assert_eq!(stored_payload["entity_type"], "person");

    // Write a second mutation (version 2)
    let payload2 = serde_json::json!({
        "type": "entities_merged",
        "winner_id": Uuid::new_v4().to_string(),
        "loser_id": Uuid::new_v4().to_string(),
    });
    sqlx::query(
        "INSERT INTO graph_mutations (tenant_id, version, mutation_type, payload) \
         VALUES ($1, $2, $3, $4) \
         ON CONFLICT (tenant_id, version) DO NOTHING",
    )
    .bind(tenant_id)
    .bind(2_i64)
    .bind("entities_merged")
    .bind(&payload2)
    .execute(&mut *conn)
    .await
    .unwrap();

    // Verify unique constraint: re-inserting version 1 does nothing (ON CONFLICT DO NOTHING)
    let result = sqlx::query(
        "INSERT INTO graph_mutations (tenant_id, version, mutation_type, payload) \
         VALUES ($1, $2, $3, $4) \
         ON CONFLICT (tenant_id, version) DO NOTHING",
    )
    .bind(tenant_id)
    .bind(1_i64)
    .bind("should_not_overwrite")
    .bind(serde_json::json!({}))
    .execute(&mut *conn)
    .await
    .unwrap();

    assert_eq!(
        result.rows_affected(),
        0,
        "Duplicate version should be silently ignored"
    );

    // Original data still intact
    let check_row = sqlx::query(
        "SELECT mutation_type FROM graph_mutations WHERE tenant_id = $1 AND version = 1",
    )
    .bind(tenant_id)
    .fetch_one(&mut *conn)
    .await
    .unwrap();
    let check_type: String = check_row.get("mutation_type");
    assert_eq!(check_type, "entity_created", "Original mutation should not be overwritten");

    // Count mutations for this tenant
    let count: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM graph_mutations WHERE tenant_id = $1",
    )
    .bind(tenant_id)
    .fetch_one(&mut *conn)
    .await
    .unwrap();
    assert_eq!(count, 2);

    drop(conn);

    // Cleanup
    let admin_pool2 = PgPoolOptions::new()
        .max_connections(1)
        .connect(
            &std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
                "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
            }),
        )
        .await
        .unwrap();
    let _ = sqlx::query("DELETE FROM graph_mutations WHERE tenant_id = $1")
        .bind(tenant_id)
        .execute(&admin_pool2)
        .await;
    let _ = sqlx::query("DELETE FROM tenants WHERE id = $1")
        .bind(tenant_id)
        .execute(&admin_pool2)
        .await;
    admin_pool2.close().await;
}

// ---------------------------------------------------------------------------
// Shared helpers for new tests
// ---------------------------------------------------------------------------

async fn get_admin_pool() -> sqlx::PgPool {
    let url = std::env::var("ADMIN_DATABASE_URL").unwrap_or_else(|_| {
        "postgres://kanoniv:KanonivAdm1n2026@localhost:5433/kanoniv".to_string()
    });
    PgPoolOptions::new()
        .max_connections(2)
        .acquire_timeout(std::time::Duration::from_secs(5))
        .connect(&url)
        .await
        .expect("Failed to connect as admin")
}

/// Create a minimal tenant (admin pool bypasses RLS).
async fn create_tenant(admin_pool: &sqlx::PgPool) -> Uuid {
    let tenant_id = Uuid::new_v4();
    let slug = format!("t-{}", &tenant_id.to_string()[..8]);
    sqlx::query(
        "INSERT INTO tenants (id, name, slug, tier) VALUES ($1, $2, $3, 'standard')",
    )
    .bind(tenant_id)
    .bind(format!("Test {}", &tenant_id.to_string()[..8]))
    .bind(&slug)
    .execute(admin_pool)
    .await
    .expect("Failed to create tenant");
    tenant_id
}

/// Set up a rich fixture: 5 canonical entities, 10 external entities, 5 identity links,
/// 2 relationship edges, 1 entity merge, and 1 data source.
/// Returns (tenant_id, entity_ids_vec, source_id).
async fn create_rich_fixture(
    admin_pool: &sqlx::PgPool,
) -> (Uuid, Vec<Uuid>, Uuid) {
    let tenant_id = create_tenant(admin_pool).await;
    let source_id = Uuid::new_v4();
    sqlx::query(
        "INSERT INTO data_sources (id, tenant_id, name, source_type) VALUES ($1, $2, 'fixture-src', 'api')",
    )
    .bind(source_id)
    .bind(tenant_id)
    .execute(admin_pool)
    .await
    .unwrap();

    // 5 canonical entities: 3 person, 2 company
    let mut entities = Vec::new();
    for (i, etype) in ["person", "person", "person", "company", "company"]
        .iter()
        .enumerate()
    {
        let eid = Uuid::new_v4();
        entities.push(eid);
        sqlx::query(
            "INSERT INTO canonical_entities (id, tenant_id, entity_type, canonical_data, field_provenance, confidence_score, is_locked) \
             VALUES ($1, $2, $3, $4, '{}', 0.90, false)",
        )
        .bind(eid)
        .bind(tenant_id)
        .bind(*etype)
        .bind(serde_json::json!({"full_name": format!("Entity-{}", i)}))
        .execute(admin_pool)
        .await
        .unwrap();
    }

    // 10 external entities linked to first 5 canonicals (2 each)
    let mut ext_ids = Vec::new();
    for i in 0..10 {
        let ext_id = Uuid::new_v4();
        ext_ids.push(ext_id);
        let canonical_idx = i / 2; // 0,0,1,1,2,2,3,3,4,4
        sqlx::query(
            "INSERT INTO external_entities (id, tenant_id, data_source_id, external_id, entity_type, raw_data) \
             VALUES ($1, $2, $3, $4, $5, $6)",
        )
        .bind(ext_id)
        .bind(tenant_id)
        .bind(source_id)
        .bind(format!("ext-{:04}", i))
        .bind(if canonical_idx < 3 { "person" } else { "company" })
        .bind(serde_json::json!({"email": format!("u{}@test.com", i)}))
        .execute(admin_pool)
        .await
        .unwrap();

        // Identity link
        sqlx::query(
            "INSERT INTO identity_links (tenant_id, canonical_entity_id, external_entity_id, link_type, confidence, matched_on) \
             VALUES ($1, $2, $3, 'auto', 0.92, '[]'::jsonb)",
        )
        .bind(tenant_id)
        .bind(entities[canonical_idx])
        .bind(ext_id)
        .execute(admin_pool)
        .await
        .unwrap();
    }

    // 2 relationship edges (ensure entity_a_id < entity_b_id per CHECK constraint)
    for (a_idx, b_idx, weight) in [(0, 1, 0.80_f64), (2, 3, 0.65_f64)] {
        let a = entities[a_idx];
        let b = entities[b_idx];
        let (small, big) = if a < b { (a, b) } else { (b, a) };
        sqlx::query(
            "INSERT INTO relationship_edges (tenant_id, entity_a_id, entity_b_id, canonical_a_id, canonical_b_id, weight, attributes) \
             VALUES ($1, $2, $3, $2, $3, $4, $5)",
        )
        .bind(tenant_id)
        .bind(small)
        .bind(big)
        .bind(weight)
        .bind(serde_json::json!({"shared": "email"}))
        .execute(admin_pool)
        .await
        .unwrap();
    }

    // 1 entity merge: entity[1] absorbed entity[2]
    sqlx::query(
        "INSERT INTO entity_merges (tenant_id, winner_entity_id, loser_entity_id, merge_type, confidence) \
         VALUES ($1, $2, $3, 'auto', 0.95)",
    )
    .bind(tenant_id)
    .bind(entities[1])
    .bind(entities[2])
    .execute(admin_pool)
    .await
    .unwrap();

    (tenant_id, entities, source_id)
}

/// Write a graph mutation to the log table using admin pool.
async fn write_mutation_admin(
    admin_pool: &sqlx::PgPool,
    tenant_id: Uuid,
    version: i64,
    mutation_type: &str,
    payload: &serde_json::Value,
) {
    sqlx::query(
        "INSERT INTO graph_mutations (tenant_id, version, mutation_type, payload) \
         VALUES ($1, $2, $3, $4)",
    )
    .bind(tenant_id)
    .bind(version)
    .bind(mutation_type)
    .bind(payload)
    .execute(admin_pool)
    .await
    .unwrap();
}

/// Set tenant context on a connection (app pool).
async fn set_tenant(conn: &mut sqlx::PgConnection, tenant_id: Uuid) {
    let tid = tenant_id.to_string();
    sqlx::query("SELECT set_config('request.tenant_id', $1, false)")
        .bind(&tid)
        .fetch_one(&mut *conn)
        .await
        .unwrap();
}

// ===========================================================================
// A. Full mutation cycle tests (in-memory graph operations)
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_merge_full_cycle() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    {
        let mut ig = ig_lock.write().await;
        let winner = entities[0];
        let loser = entities[1];
        let winner_before = ig.get_node(&winner).unwrap().member_count;
        let loser_before = ig.get_node(&loser).unwrap().member_count;

        let moved = ig.merge(winner, loser, "manual", Some(0.99));
        assert!(moved.is_some(), "Merge should succeed");
        assert_eq!(moved.unwrap(), loser_before);

        // Winner has combined members
        assert_eq!(
            ig.get_node(&winner).unwrap().member_count,
            winner_before + loser_before
        );

        // Loser removed
        assert!(ig.get_node(&loser).is_none());

        // Version incremented
        assert!(ig.version > 0);
    }

    drop(ig_lock);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_split_full_cycle() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    {
        let mut ig = ig_lock.write().await;
        let original = entities[0];
        let original_members = ig.get_node(&original).unwrap().member_count;

        let member1 = Uuid::new_v4();
        let member2 = Uuid::new_v4();
        let result = ig
            .split(original, &[(member1, "person".into()), (member2, "person".into())])
            .unwrap();

        assert_eq!(result.len(), 2, "Split should create 2 new entities");

        // Original's member count decreased by 2
        assert_eq!(
            ig.get_node(&original).unwrap().member_count,
            original_members - 2
        );

        // New entities exist with 1 member each
        for (_, new_id) in &result {
            let new_node = ig.get_node(new_id).unwrap();
            assert_eq!(new_node.member_count, 1);
            assert_eq!(new_node.entity_type, "person");
        }
    }

    drop(ig_lock);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_reassign_full_cycle() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    {
        let mut ig = ig_lock.write().await;
        let from = entities[0];
        let to = entities[1];
        let ext_id = Uuid::new_v4(); // symbolic external entity id

        let from_before = ig.get_node(&from).unwrap().member_count;
        let to_before = ig.get_node(&to).unwrap().member_count;

        let ok = ig.reassign(ext_id, from, to);
        assert!(ok, "Reassign should succeed");

        assert_eq!(ig.get_node(&from).unwrap().member_count, from_before - 1);
        assert_eq!(ig.get_node(&to).unwrap().member_count, to_before + 1);
    }

    drop(ig_lock);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_delete_full_cycle() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let node = NodeData {
        id: Uuid::new_v4(),
        entity_type: "person".into(),
        member_count: 0, // must be 0 to delete
        display_name: Some("Deletable".into()),
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    };
    let nid = node.id;
    ig.add_entity(node);
    assert_eq!(ig.graph.node_count(), 1);

    let removed = ig.delete_node(nid);
    assert!(removed.is_some());
    assert_eq!(removed.unwrap().id, nid);
    assert_eq!(ig.graph.node_count(), 0);
    assert!(ig.get_node(&nid).is_none());
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_delete_with_members_fails() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let node = NodeData {
        id: Uuid::new_v4(),
        entity_type: "person".into(),
        member_count: 3,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    };
    let nid = node.id;
    ig.add_entity(node);

    // Should fail since member_count > 0
    assert!(ig.delete_node(nid).is_none());
    assert!(ig.get_node(&nid).is_some());
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_link_full_cycle() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    {
        let mut ig = ig_lock.write().await;
        let a = entities[0];
        let b = entities[4]; // company
        let edges_before = ig.graph.edge_count();

        let ok = ig.link(a, b, 0.72, Some(serde_json::json!({"reason": "test"})));
        assert!(ok, "Link should succeed");
        assert_eq!(ig.graph.edge_count(), edges_before + 1);

        // Verify edge data
        let a_idx = ig.node_index[&a];
        let b_idx = ig.node_index[&b];
        let edge_idx = ig.graph.find_edge(a_idx, b_idx).unwrap();
        assert_eq!(ig.graph[edge_idx].kind, EdgeKind::Relationship);
        assert!((ig.graph[edge_idx].weight - 0.72).abs() < f64::EPSILON);
    }

    drop(ig_lock);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_unlink_full_cycle() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();

    {
        let mut ig = ig_lock.write().await;
        let a = entities[0];
        let b = entities[4];

        // First link them
        ig.link(a, b, 0.5, None);
        let edges_after_link = ig.graph.edge_count();

        // Then unlink
        let ok = ig.unlink(a, b);
        assert!(ok, "Unlink should succeed");
        assert_eq!(ig.graph.edge_count(), edges_after_link - 1);

        // Verify edge gone
        let a_idx = ig.node_index[&a];
        let b_idx = ig.node_index[&b];
        assert!(ig.graph.find_edge(a_idx, b_idx).is_none());
    }

    drop(ig_lock);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

// ===========================================================================
// B. Write-ahead log durability tests
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_log_version_monotonic() {
    // Write 5 mutations to graph_mutations, verify versions are 1..5
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    for v in 1..=5 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "person",
            "member_count": 1
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    // Read back via app pool
    let mut conn = pool.acquire().await.unwrap();
    set_tenant(&mut conn, tenant_id).await;

    let rows = sqlx::query(
        "SELECT version FROM graph_mutations WHERE tenant_id = $1 ORDER BY version ASC",
    )
    .bind(tenant_id)
    .fetch_all(&mut *conn)
    .await
    .unwrap();

    assert_eq!(rows.len(), 5);
    for (i, row) in rows.iter().enumerate() {
        let v: i64 = row.get("version");
        assert_eq!(v, (i + 1) as i64, "Versions should be monotonic 1..5");
    }

    drop(conn);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_log_unique_constraint_prevents_duplicates() {
    // Verify that inserting duplicate (tenant_id, version) fails
    let admin_pool = get_admin_pool().await;
    let pool = get_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    let payload = serde_json::json!({
        "type": "entity_created",
        "entity_id": Uuid::new_v4().to_string(),
        "entity_type": "person",
        "member_count": 1
    });
    write_mutation_admin(&admin_pool, tenant_id, 1, "entity_created", &payload).await;

    // Attempt duplicate via admin pool (no ON CONFLICT)
    let result = sqlx::query(
        "INSERT INTO graph_mutations (tenant_id, version, mutation_type, payload) \
         VALUES ($1, $2, $3, $4)",
    )
    .bind(tenant_id)
    .bind(1_i64)
    .bind("should_fail")
    .bind(serde_json::json!({}))
    .execute(&admin_pool)
    .await;

    assert!(result.is_err(), "Duplicate version should violate unique constraint");

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_log_replay_matches_db_state() {
    // Write mutations to log, build graph from log replay, build from DB,
    // compare node and edge counts.
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    // Write some mutations to the log that reflect the fixture state
    for (i, eid) in entities.iter().enumerate() {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": eid.to_string(),
            "entity_type": if i < 3 { "person" } else { "company" },
            "member_count": 2
        });
        write_mutation_admin(
            &admin_pool,
            tenant_id,
            (i + 1) as i64,
            "entity_created",
            &payload,
        )
        .await;
    }

    // Add link mutations
    let link_payload = serde_json::json!({
        "type": "edge_linked",
        "entity_a_id": entities[0].to_string(),
        "entity_b_id": entities[1].to_string(),
        "weight": 0.80
    });
    write_mutation_admin(&admin_pool, tenant_id, 6, "edge_linked", &link_payload).await;

    // Build from log
    let ig_from_log = GraphRegistry::build_from_log(&pool, tenant_id)
        .await
        .unwrap();

    // Log replay should have 5 entities + 1 edge
    assert_eq!(ig_from_log.graph.node_count(), 5);
    assert_eq!(ig_from_log.graph.edge_count(), 1);
    assert_eq!(ig_from_log.version, 6);

    // Build from DB (the "normal" path)
    let registry = GraphRegistry::new(10);
    let ig_db_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig_db = ig_db_lock.read().await;

    // DB path should have 5 entities too (plus possible merge edges)
    assert_eq!(ig_db.graph.node_count(), 5);

    // Log replay analytics computed
    assert!(
        !ig_from_log.analytics.pagerank.is_empty(),
        "Log replay should compute analytics"
    );

    drop(ig_db);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

// ===========================================================================
// C. RLS tenant isolation tests
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_graph_mutations_rls_isolation() {
    // T1 writes mutations, T2 cannot see them
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let t1 = create_tenant(&admin_pool).await;
    let t2 = create_tenant(&admin_pool).await;

    // T1 writes 3 mutations
    for v in 1..=3 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "person",
            "member_count": 1
        });
        write_mutation_admin(&admin_pool, t1, v, "entity_created", &payload).await;
    }

    // T2 context: should see 0 mutations
    let mut conn2 = pool.acquire().await.unwrap();
    set_tenant(&mut conn2, t2).await;

    let count: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM graph_mutations WHERE tenant_id = $1",
    )
    .bind(t1) // query for T1's data, but with T2 context
    .fetch_one(&mut *conn2)
    .await
    .unwrap();
    assert_eq!(count, 0, "T2 should not see T1's mutations via RLS");

    // T1 context: should see 3
    drop(conn2);
    let mut conn1 = pool.acquire().await.unwrap();
    set_tenant(&mut conn1, t1).await;

    let count_t1: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM graph_mutations WHERE tenant_id = $1",
    )
    .bind(t1)
    .fetch_one(&mut *conn1)
    .await
    .unwrap();
    assert_eq!(count_t1, 3, "T1 should see its own 3 mutations");

    drop(conn1);
    cleanup_tenant(&pool, t1).await;
    cleanup_tenant(&pool, t2).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_registry_builds_only_tenant_data() {
    // Create 2 tenants with different entity counts, verify each graph only has its own data
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (t1, t1_entities, _) = create_rich_fixture(&admin_pool).await;
    let t2 = create_tenant(&admin_pool).await;

    // T2 gets just 2 entities
    for i in 0..2 {
        let eid = Uuid::new_v4();
        sqlx::query(
            "INSERT INTO canonical_entities (id, tenant_id, entity_type, canonical_data, field_provenance, confidence_score, is_locked) \
             VALUES ($1, $2, 'person', $3, '{}', 0.85, false)",
        )
        .bind(eid)
        .bind(t2)
        .bind(serde_json::json!({"full_name": format!("T2-Entity-{}", i)}))
        .execute(&admin_pool)
        .await
        .unwrap();
    }

    let registry = GraphRegistry::new(10);

    // T1 should have 5 entities
    let ig1_lock = registry.get_or_load(t1, &pool).await.unwrap();
    let ig1 = ig1_lock.read().await;
    assert_eq!(
        ig1.graph.node_count(),
        t1_entities.len(),
        "T1 should have exactly 5 entities"
    );
    drop(ig1);

    // T2 should have 2 entities
    let ig2_lock = registry.get_or_load(t2, &pool).await.unwrap();
    let ig2 = ig2_lock.read().await;
    assert_eq!(ig2.graph.node_count(), 2, "T2 should have exactly 2 entities");
    drop(ig2);

    cleanup_tenant(&pool, t1).await;
    cleanup_tenant(&pool, t2).await;
    admin_pool.close().await;
}

// ===========================================================================
// D. apply_mutation_from_log tests (purely in-memory)
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_entity_created() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let eid = Uuid::new_v4();
    let payload = serde_json::json!({
        "type": "entity_created",
        "entity_id": eid.to_string(),
        "entity_type": "company",
        "member_count": 5
    });

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

    assert_eq!(ig.graph.node_count(), 1);
    let node = ig.get_node(&eid).unwrap();
    assert_eq!(node.entity_type, "company");
    assert_eq!(node.member_count, 5);
    assert_eq!(ig.version, 1);
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_merge() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let a = Uuid::new_v4();
    let b = Uuid::new_v4();

    // Create two entities first
    ig.add_node(NodeData {
        id: a,
        entity_type: "person".into(),
        member_count: 3,
        display_name: Some("Alice".into()),
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });
    ig.add_node(NodeData {
        id: b,
        entity_type: "person".into(),
        member_count: 2,
        display_name: Some("Bob".into()),
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });

    let payload = serde_json::json!({
        "type": "entities_merged",
        "winner_id": a.to_string(),
        "loser_id": b.to_string(),
        "merge_type": "auto",
        "members_moved": 2
    });

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

    assert_eq!(ig.graph.node_count(), 1);
    assert!(ig.get_node(&a).is_some());
    assert!(ig.get_node(&b).is_none());
    assert_eq!(ig.get_node(&a).unwrap().member_count, 5);
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_split() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let original = Uuid::new_v4();
    ig.add_node(NodeData {
        id: original,
        entity_type: "person".into(),
        member_count: 5,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });

    let new1 = Uuid::new_v4();
    let new2 = Uuid::new_v4();
    let payload = serde_json::json!({
        "type": "entity_split",
        "original_id": original.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(1, "entity_split", &payload).unwrap();

    assert_eq!(ig.graph.node_count(), 3);
    assert_eq!(ig.get_node(&original).unwrap().member_count, 2); // 5 - 1 - 2 = 2 (decremented by each new entity's member_count)
    assert_eq!(ig.get_node(&new1).unwrap().member_count, 1);
    assert_eq!(ig.get_node(&new2).unwrap().member_count, 2);
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_reassign() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let from = Uuid::new_v4();
    let to = Uuid::new_v4();
    let ext = Uuid::new_v4();

    ig.add_node(NodeData {
        id: from,
        entity_type: "person".into(),
        member_count: 4,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });
    ig.add_node(NodeData {
        id: to,
        entity_type: "person".into(),
        member_count: 1,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });

    let payload = serde_json::json!({
        "type": "member_reassigned",
        "external_entity_id": ext.to_string(),
        "from_entity_id": from.to_string(),
        "to_entity_id": to.to_string(),
    });

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

    assert_eq!(ig.get_node(&from).unwrap().member_count, 3);
    assert_eq!(ig.get_node(&to).unwrap().member_count, 2);
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_link_unlink() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let a = Uuid::new_v4();
    let b = Uuid::new_v4();

    ig.add_node(NodeData {
        id: a,
        entity_type: "person".into(),
        member_count: 1,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });
    ig.add_node(NodeData {
        id: b,
        entity_type: "company".into(),
        member_count: 1,
        display_name: None,
        is_locked: false,
        created_at: Utc::now(),
        updated_at: Utc::now(),
    });

    // Link
    let link_payload = serde_json::json!({
        "type": "edge_linked",
        "entity_a_id": a.to_string(),
        "entity_b_id": b.to_string(),
        "weight": 0.88
    });
    ig.apply_mutation_from_log(1, "edge_linked", &link_payload).unwrap();
    assert_eq!(ig.graph.edge_count(), 1);

    // Unlink
    let unlink_payload = serde_json::json!({
        "type": "edge_unlinked",
        "entity_a_id": a.to_string(),
        "entity_b_id": b.to_string(),
    });
    ig.apply_mutation_from_log(2, "edge_unlinked", &unlink_payload).unwrap();
    assert_eq!(ig.graph.edge_count(), 0);
    assert_eq!(ig.version, 2);
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_unknown_type_returns_error() {
    let mut ig = IdentityGraph::new(Uuid::nil());
    let result = ig.apply_mutation_from_log(1, "nonexistent_type", &serde_json::json!({}));
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("Unknown mutation type"));
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_apply_mutation_informational_types() {
    let mut ig = IdentityGraph::new(Uuid::nil());

    // batch_reconciled and entity_updated should be no-ops
    ig.apply_mutation_from_log(
        1,
        "batch_reconciled",
        &serde_json::json!({"batch_run_id": Uuid::new_v4().to_string()}),
    )
    .unwrap();
    assert_eq!(ig.graph.node_count(), 0);
    assert_eq!(ig.version, 1);

    ig.apply_mutation_from_log(
        2,
        "entity_updated",
        &serde_json::json!({"entity_id": Uuid::new_v4().to_string(), "fields_changed": ["name"]}),
    )
    .unwrap();
    assert_eq!(ig.version, 2);
}

// ===========================================================================
// E. Graph rebuild consistency tests
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_graph_rebuild_after_merge_in_db() {
    // Merge two entities in DB, invalidate graph, rebuild, verify winner has merged members
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);

    // First load
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig = ig_lock.read().await;
    let initial_node_count = ig.graph.node_count();
    drop(ig);
    drop(ig_lock);

    // Add another merge in DB (entity[3] into entity[0])
    sqlx::query(
        "INSERT INTO entity_merges (tenant_id, winner_entity_id, loser_entity_id, merge_type, confidence) \
         VALUES ($1, $2, $3, 'manual', 0.98)",
    )
    .bind(tenant_id)
    .bind(entities[0])
    .bind(entities[3])
    .execute(&admin_pool)
    .await
    .unwrap();

    // Invalidate and rebuild
    registry.invalidate(&tenant_id).await;
    let ig_lock2 = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig2 = ig_lock2.read().await;

    // Should have the new merge edge (identity edge between 0 and 3)
    assert!(
        ig2.graph.edge_count() > 0,
        "Rebuilt graph should have edges including new merge"
    );
    // Node count should be same (entity_merges adds identity edges, doesn't remove nodes in DB view)
    assert_eq!(ig2.graph.node_count(), initial_node_count);

    drop(ig2);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_graph_rebuild_preserves_analytics() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig = ig_lock.read().await;

    // Analytics should be eagerly computed
    assert!(!ig.analytics.pagerank.is_empty(), "PageRank should be computed");
    assert!(!ig.analytics.components.is_empty(), "Components should be computed");

    // Each entity in the graph should have a pagerank score
    for eid in &entities {
        if ig.get_node(eid).is_some() {
            assert!(
                ig.analytics.pagerank.contains_key(eid),
                "Entity {} should have pagerank",
                eid
            );
        }
    }

    // Risk scores computed for all nodes
    assert_eq!(
        ig.analytics.risk_scores.len(),
        ig.graph.node_count(),
        "Every node should have risk scores"
    );

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_graph_version_syncs_from_mutations_log() {
    // If graph_mutations has entries, the graph version should sync to MAX(version)
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let (tenant_id, entities, _) = create_rich_fixture(&admin_pool).await;

    // Write 3 mutations to the log
    for v in 1..=3 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": entities[v as usize - 1].to_string(),
            "entity_type": "person",
            "member_count": 2
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    let registry = GraphRegistry::new(10);
    let ig_lock = registry.get_or_load(tenant_id, &pool).await.unwrap();
    let ig = ig_lock.read().await;

    assert_eq!(ig.version, 3, "Graph version should sync to MAX(version) from log");

    drop(ig);
    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

// ===========================================================================
// F. Materializer tests
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_materializer_processes_mutations() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    // Write some mutations to the log
    for v in 1..=3 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "person",
            "member_count": 1
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    let mut materializer = Materializer::new(pool.clone());
    let count = materializer.materialize(tenant_id).await.unwrap();

    assert_eq!(count, 3, "Should process 3 mutations");
    assert_eq!(materializer.last_version(&tenant_id), 3);

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_materializer_idempotent() {
    // Running materializer twice on same mutations should process 0 on second run
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    for v in 1..=2 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "person",
            "member_count": 1
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    let mut materializer = Materializer::new(pool.clone());

    let count1 = materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(count1, 2, "First run should process 2");

    let count2 = materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(count2, 0, "Second run should process 0 (idempotent)");

    assert_eq!(materializer.last_version(&tenant_id), 2);

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_materializer_incremental() {
    // Process some mutations, add more, process again - should only process new ones
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    // Initial batch
    for v in 1..=2 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "person",
            "member_count": 1
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    let mut materializer = Materializer::new(pool.clone());
    let count1 = materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(count1, 2);

    // Add 3 more mutations
    for v in 3..=5 {
        let payload = serde_json::json!({
            "type": "entity_created",
            "entity_id": Uuid::new_v4().to_string(),
            "entity_type": "company",
            "member_count": v
        });
        write_mutation_admin(&admin_pool, tenant_id, v, "entity_created", &payload).await;
    }

    let count2 = materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(count2, 3, "Should only process the 3 new mutations");
    assert_eq!(materializer.last_version(&tenant_id), 5);

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_materializer_reset() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    let payload = serde_json::json!({
        "type": "entity_created",
        "entity_id": Uuid::new_v4().to_string(),
        "entity_type": "person",
        "member_count": 1
    });
    write_mutation_admin(&admin_pool, tenant_id, 1, "entity_created", &payload).await;

    let mut materializer = Materializer::new(pool.clone());
    materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(materializer.last_version(&tenant_id), 1);

    // Reset clears the position
    materializer.reset(&tenant_id);
    assert_eq!(materializer.last_version(&tenant_id), 0);

    // Re-materializing replays everything
    let count = materializer.materialize(tenant_id).await.unwrap();
    assert_eq!(count, 1, "After reset, should replay all mutations");

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

// ===========================================================================
// G. build_from_log tests
// ===========================================================================

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_build_from_log_empty() {
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    let ig = GraphRegistry::build_from_log(&pool, tenant_id).await.unwrap();
    assert_eq!(ig.graph.node_count(), 0);
    assert_eq!(ig.graph.edge_count(), 0);
    assert_eq!(ig.version, 0);

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_build_from_log_complex_sequence() {
    // Replay: create 3 entities, link 2, merge 2, verify final state
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    let e1 = Uuid::new_v4();
    let e2 = Uuid::new_v4();
    let e3 = Uuid::new_v4();

    // v1: create e1
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        1,
        "entity_created",
        &serde_json::json!({
            "type": "entity_created",
            "entity_id": e1.to_string(),
            "entity_type": "person",
            "member_count": 3
        }),
    )
    .await;

    // v2: create e2
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        2,
        "entity_created",
        &serde_json::json!({
            "type": "entity_created",
            "entity_id": e2.to_string(),
            "entity_type": "person",
            "member_count": 2
        }),
    )
    .await;

    // v3: create e3
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        3,
        "entity_created",
        &serde_json::json!({
            "type": "entity_created",
            "entity_id": e3.to_string(),
            "entity_type": "company",
            "member_count": 1
        }),
    )
    .await;

    // v4: link e1 <-> e3
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        4,
        "edge_linked",
        &serde_json::json!({
            "type": "edge_linked",
            "entity_a_id": e1.to_string(),
            "entity_b_id": e3.to_string(),
            "weight": 0.75
        }),
    )
    .await;

    // v5: merge e2 into e1
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        5,
        "entities_merged",
        &serde_json::json!({
            "type": "entities_merged",
            "winner_id": e1.to_string(),
            "loser_id": e2.to_string(),
            "merge_type": "auto",
            "members_moved": 2
        }),
    )
    .await;

    let ig = GraphRegistry::build_from_log(&pool, tenant_id).await.unwrap();

    // After merge: e1 (winner) and e3 remain. e2 is gone.
    assert_eq!(ig.graph.node_count(), 2, "Should have 2 nodes after merge");
    assert!(ig.get_node(&e1).is_some(), "Winner e1 should exist");
    assert!(ig.get_node(&e2).is_none(), "Loser e2 should be merged away");
    assert!(ig.get_node(&e3).is_some(), "e3 should still exist");

    // e1 should have combined members: 3 + 2 = 5
    assert_eq!(ig.get_node(&e1).unwrap().member_count, 5);

    // Edge between e1 and e3 should survive the merge
    let e1_idx = ig.node_index[&e1];
    let e3_idx = ig.node_index[&e3];
    assert!(ig.graph.find_edge(e1_idx, e3_idx).is_some(), "Edge e1<->e3 should survive merge");

    assert_eq!(ig.version, 5);

    // Analytics computed
    assert!(!ig.analytics.pagerank.is_empty());

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}

#[tokio::test]
#[ignore] // Requires SSH tunnel + ADMIN_DATABASE_URL
async fn test_build_from_log_skips_unplayable_mutations() {
    // A mutation with bad payload should be skipped, not crash the replay
    let pool = get_pool().await;
    let admin_pool = get_admin_pool().await;
    let tenant_id = create_tenant(&admin_pool).await;

    // v1: valid entity
    let e1 = Uuid::new_v4();
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        1,
        "entity_created",
        &serde_json::json!({
            "type": "entity_created",
            "entity_id": e1.to_string(),
            "entity_type": "person",
            "member_count": 1
        }),
    )
    .await;

    // v2: bad merge (references non-existent entities)
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        2,
        "entities_merged",
        &serde_json::json!({
            "type": "entities_merged",
            "winner_id": Uuid::new_v4().to_string(),
            "loser_id": Uuid::new_v4().to_string(),
        }),
    )
    .await;

    // v3: another valid entity
    let e3 = Uuid::new_v4();
    write_mutation_admin(
        &admin_pool,
        tenant_id,
        3,
        "entity_created",
        &serde_json::json!({
            "type": "entity_created",
            "entity_id": e3.to_string(),
            "entity_type": "company",
            "member_count": 2
        }),
    )
    .await;

    let ig = GraphRegistry::build_from_log(&pool, tenant_id).await.unwrap();

    // Should have 2 entities (v1 and v3). v2 merge was on non-existent nodes - no-op.
    assert_eq!(ig.graph.node_count(), 2);
    assert!(ig.get_node(&e1).is_some());
    assert!(ig.get_node(&e3).is_some());
    assert_eq!(ig.version, 3);

    cleanup_tenant(&pool, tenant_id).await;
    admin_pool.close().await;
}
