//! Milestone 7 Phase 5: End-to-end UAT integration test suite.
//!
//! Validates the complete M7 feature set through public API only:
//! - Crash recovery (state survives restart)
//! - Hard negatives survive restart
//! - Session TTL auto-cleanup
//! - Graceful degradation under load
//! - Per-agent rate limiting isolation
//! - `QueryStats` populated in results
//! - RLHF signal export
//! - User session aggregation
//! - `MetricsState` fields available
//! - tidalctl diagnostics structural check
#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]

use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::Duration;

use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
use tidaldb::{AgentPolicy, DegradationLevel, ExportRequest, TidalDb, TidalError};

// ── Shared schema builder ───────────────────────────────────────────────────

fn uat_schema() -> tidaldb::schema::Schema {
    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime])
        .velocity(true)
        .add();
    let _ = builder
        .signal(
            "like",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(14 * 24 * 3600),
            },
        )
        .windows(&[Window::TwentyFourHours, Window::AllTime])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "hide",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "block",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();

    // Short-TTL policy for session TTL tests.
    builder.session_policy(
        "short_ttl",
        AgentPolicy {
            max_session_duration: Duration::from_millis(500),
            max_signals_per_session: 1000,
            allowed_signals: vec![],
            denied_signals: vec![],
        },
    );

    // Normal policy for general session tests.
    builder.session_policy(
        "default",
        AgentPolicy {
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 10_000,
            allowed_signals: vec![],
            denied_signals: vec![],
        },
    );

    builder.build().unwrap()
}

/// Helper: build item metadata with a `creator_id` assignment.
fn item_meta(id: u64) -> HashMap<String, String> {
    let mut meta = HashMap::new();
    meta.insert("title".to_string(), format!("Item {id}"));
    meta.insert("category".to_string(), "test".to_string());
    meta.insert("format".to_string(), "video".to_string());
    meta.insert("creator_id".to_string(), format!("{}", (id % 5) + 1));
    meta
}

// ── UAT-01: Crash Recovery -- State Survives Restart ────────────────────────

/// Write 200 items with metadata and signals. Close and reopen (simulating
/// crash/restart). Verify RETRIEVE returns items (state persisted).
#[test]
fn uat_01_crash_recovery_state_survives_restart() {
    let dir = tempfile::tempdir().unwrap();
    let schema = uat_schema();

    // Phase 1: populate and close.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema.clone())
            .open()
            .unwrap();

        let now = Timestamp::now();
        for i in 1..=200u64 {
            let meta = item_meta(i);
            db.write_item_with_metadata(EntityId::new(i), &meta)
                .unwrap();
            db.signal("view", EntityId::new(i), 1.0, now).unwrap();
        }

        db.close().unwrap();
    }

    // Phase 2: reopen, repopulate in-memory bitmap indexes, query.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema)
            .open()
            .unwrap();

        // Repopulate in-memory indexes (bitmap/universe) since they are not persisted.
        for i in 1..=200u64 {
            let meta = item_meta(i);
            db.write_item_with_metadata(EntityId::new(i), &meta)
                .unwrap();
        }

        let query = tidaldb::query::retrieve::Retrieve::builder()
            .profile("new")
            .limit(50)
            .build()
            .unwrap();
        let results = db.retrieve(&query).unwrap();

        assert!(
            !results.is_empty(),
            "RETRIEVE after restart must return items; got 0"
        );

        db.close().unwrap();
    }
}

// ── UAT-02: Hard Negatives Don't Leak After Restart ─────────────────────────

/// Write 10 items from 2 creators; hide item 1 from user 1; block creator 2
/// for user 1. Close and reopen. Verify hidden/blocked items absent.
#[test]
fn uat_02_hard_negatives_survive_restart() {
    use tidaldb::entities::RelationshipType;

    let dir = tempfile::tempdir().unwrap();
    let schema = uat_schema();
    let user_id = 1001u64;
    let creator_fn = |id: u64| (id % 2) + 1; // creators 1 and 2

    // Phase 1: populate, hide, block, close.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema.clone())
            .open()
            .unwrap();

        let now = Timestamp::now();
        for i in 1..=10u64 {
            let mut meta = HashMap::new();
            meta.insert("title".to_string(), format!("Item {i}"));
            meta.insert("category".to_string(), "test".to_string());
            meta.insert("format".to_string(), "video".to_string());
            meta.insert("creator_id".to_string(), creator_fn(i).to_string());
            db.write_item_with_metadata(EntityId::new(i), &meta)
                .unwrap();
            db.signal("view", EntityId::new(i), 1.0, now).unwrap();
        }

        // Write user so user-context filtering is active.
        db.write_user(EntityId::new(user_id), &HashMap::new())
            .unwrap();

        // Hide item 1.
        db.write_relationship(
            EntityId::new(user_id),
            RelationshipType::Hide,
            EntityId::new(1),
            1.0,
            now,
        )
        .unwrap();

        // Block creator 2 (owns odd items: 1, 3, 5, 7, 9).
        db.write_relationship(
            EntityId::new(user_id),
            RelationshipType::Blocks,
            EntityId::new(2),
            1.0,
            now,
        )
        .unwrap();

        db.close().unwrap();
    }

    // Phase 2: reopen, repopulate, verify.
    {
        let db = TidalDb::builder()
            .with_data_dir(dir.path())
            .with_schema(schema)
            .open()
            .unwrap();

        for i in 1..=10u64 {
            let mut meta = HashMap::new();
            meta.insert("title".to_string(), format!("Item {i}"));
            meta.insert("category".to_string(), "test".to_string());
            meta.insert("format".to_string(), "video".to_string());
            meta.insert("creator_id".to_string(), creator_fn(i).to_string());
            db.write_item_with_metadata(EntityId::new(i), &meta)
                .unwrap();
        }

        let query = tidaldb::query::retrieve::Retrieve::builder()
            .profile("new")
            .for_user(user_id)
            .limit(50)
            .build()
            .unwrap();
        let results = db.retrieve(&query).unwrap();

        let returned_ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();

        // Item 1 was hidden -- must not appear.
        assert!(
            !returned_ids.contains(&1),
            "hidden item 1 must not appear after restart; got: {returned_ids:?}"
        );

        // Creator 2's items: (id % 2) + 1 == 2 means id is odd: 1, 3, 5, 7, 9.
        // Item 1 is also hidden, but 3, 5, 7, 9 should be blocked.
        for blocked_item in [3u64, 5, 7, 9] {
            assert!(
                !returned_ids.contains(&blocked_item),
                "blocked creator 2's item {blocked_item} must not appear; got: {returned_ids:?}"
            );
        }

        // Should still have some results (creator 1's non-hidden items).
        assert!(
            !results.is_empty(),
            "should have non-blocked, non-hidden items"
        );

        db.close().unwrap();
    }
}

// ── UAT-03: Session TTL Auto-Cleanup ────────────────────────────────────────

/// Create a session with short TTL (500ms). Wait past TTL. Force sweep.
/// Verify session is auto-closed.
#[test]
fn uat_03_session_ttl_auto_cleanup() {
    let schema = uat_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .unwrap();

    // Start a session with the short_ttl policy (500ms max duration).
    let handle = db
        .start_session(1, "agent-ttl", "short_ttl", HashMap::new())
        .unwrap();
    let session_id = handle.id;

    // Write a signal while session is active.
    db.session_signal(
        &handle,
        "view",
        EntityId::new(1),
        1.0,
        Timestamp::now(),
        None,
    )
    .unwrap();

    assert_eq!(db.active_sessions().len(), 1);

    // Wait past the TTL.
    thread::sleep(Duration::from_millis(600));

    // Manually trigger a sweep (ephemeral mode: sweeper thread not auto-spawned).
    db.force_sweep();

    // Session should be removed from active sessions.
    assert_eq!(
        db.active_sessions().len(),
        0,
        "expired session must be swept"
    );

    // The session snapshot must be archived.
    let snapshot = db.session_snapshot(session_id).unwrap();
    assert_eq!(snapshot.id, session_id);

    // The handle's closed flag must be set.
    assert!(
        handle.closed.load(Ordering::Acquire),
        "handle.closed must be true after auto-close"
    );
}

// ── UAT-04: Graceful Degradation -- Queries Return Results Under Load ───────

/// Spawn 50 concurrent threads each doing 5 RETRIEVE queries.
/// All must return Ok (no non-backpressure errors).
/// Verify `degradation_level` field exists and is a `DegradationLevel`.
#[test]
fn uat_04_graceful_degradation_queries_ok_under_load() {
    let schema = uat_schema();
    let db = Arc::new(
        TidalDb::builder()
            .ephemeral()
            .with_schema(schema)
            .open()
            .unwrap(),
    );

    // Seed items.
    for i in 1..=100u64 {
        let meta = item_meta(i);
        db.write_item_with_metadata(EntityId::new(i), &meta)
            .unwrap();
        db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
            .unwrap();
    }

    let error_count = Arc::new(AtomicUsize::new(0));
    let query_count = Arc::new(AtomicUsize::new(0));
    let stop = Arc::new(AtomicBool::new(false));

    let mut handles = Vec::new();
    for _ in 0..50 {
        let db = Arc::clone(&db);
        let errors = Arc::clone(&error_count);
        let queries = Arc::clone(&query_count);
        let stop = Arc::clone(&stop);
        handles.push(thread::spawn(move || {
            for _ in 0..5 {
                if stop.load(Ordering::Relaxed) {
                    break;
                }
                let query = tidaldb::query::retrieve::Retrieve::builder()
                    .profile("hot")
                    .limit(10)
                    .build()
                    .unwrap();
                match db.retrieve(&query) {
                    Ok(results) => {
                        queries.fetch_add(1, Ordering::Relaxed);
                        // Verify degradation_level field is accessible and is a valid variant.
                        assert!(matches!(
                            results.degradation_level,
                            DegradationLevel::Full
                                | DegradationLevel::ReducedCandidates
                                | DegradationLevel::CoarseAggregates
                                | DegradationLevel::NoDiversity
                        ));
                    }
                    Err(TidalError::Backpressure { .. }) => {
                        // Backpressure is acceptable under overload.
                    }
                    Err(_) => {
                        errors.fetch_add(1, Ordering::Relaxed);
                    }
                }
            }
        }));
    }

    for h in handles {
        h.join().unwrap();
    }

    let total_queries = query_count.load(Ordering::Relaxed);
    let total_errors = error_count.load(Ordering::Relaxed);

    assert!(
        total_queries > 0,
        "expected some queries to complete; got 0"
    );
    assert_eq!(
        total_errors, 0,
        "expected zero non-backpressure errors, got {total_errors}"
    );
}

// ── UAT-05: Per-Agent Rate Limiting Isolation ───────────────────────────────

/// Configure rate-limited sessions. Flood agent A past burst capacity.
/// Verify agent B's bucket is unaffected.
#[test]
fn uat_05_per_agent_rate_limiting_isolation() {
    let schema = uat_schema();
    let rl_config = tidaldb::load::RateLimiterConfig::limited(100.0, 200.0);
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .with_rate_limiter_config(rl_config)
        .open()
        .unwrap();

    // Seed at least one item for signals.
    db.write_item_with_metadata(EntityId::new(1), &item_meta(1))
        .unwrap();
    db.write_item_with_metadata(EntityId::new(2), &item_meta(2))
        .unwrap();

    let handle_a = db
        .start_session(1, "agent-a", "default", HashMap::new())
        .unwrap();
    let handle_b = db
        .start_session(2, "agent-b", "default", HashMap::new())
        .unwrap();

    // Flood agent A with 300 signals (burst cap = 200).
    let mut a_accepted = 0u32;
    let mut a_rejected = 0u32;
    for i in 0..300u64 {
        let ts = Timestamp::from_nanos(Timestamp::now().as_nanos().saturating_add(i));
        match db.session_signal(&handle_a, "view", EntityId::new(1), 1.0, ts, None) {
            Ok(()) => a_accepted += 1,
            Err(TidalError::RateLimited { .. }) => a_rejected += 1,
            Err(e) => panic!("unexpected error for agent-a: {e}"),
        }
    }

    // Agent B should still be able to write (separate bucket).
    for i in 0..10u64 {
        let ts = Timestamp::from_nanos(Timestamp::now().as_nanos().saturating_add(i));
        let result = db.session_signal(&handle_b, "view", EntityId::new(2), 1.0, ts, None);
        assert!(
            result.is_ok(),
            "agent-b signal {i} must not be rate-limited by agent-a: {result:?}"
        );
    }

    // Agent A should have hit the limit.
    assert!(
        a_rejected > 0,
        "expected agent-a to be rate-limited; accepted={a_accepted}, rejected={a_rejected}"
    );
    assert!(
        a_accepted > 0,
        "expected some agent-a signals to succeed; accepted={a_accepted}"
    );

    db.close_session(handle_a).unwrap();
    db.close_session(handle_b).unwrap();
}

// ── UAT-06: QueryStats Populated in Results ─────────────────────────────────

/// Write 20 items, execute RETRIEVE and SEARCH. Verify `QueryStats` fields.
#[test]
fn uat_06_query_stats_populated_in_results() {
    let schema = uat_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .unwrap();

    for i in 1..=20u64 {
        let meta = item_meta(i);
        db.write_item_with_metadata(EntityId::new(i), &meta)
            .unwrap();
        db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
            .unwrap();
    }

    // RETRIEVE: verify stats populated.
    let query = tidaldb::query::retrieve::Retrieve::builder()
        .profile("hot")
        .limit(10)
        .build()
        .unwrap();
    let results = db.retrieve(&query).unwrap();

    assert!(
        results.stats.total_time_us > 0,
        "RETRIEVE total_time_us must be > 0; got {}",
        results.stats.total_time_us
    );
    assert!(
        !results.stats.profile_name.is_empty(),
        "RETRIEVE profile_name must be non-empty"
    );
    assert_eq!(
        results.stats.profile_name, "hot",
        "RETRIEVE profile_name must match the requested profile"
    );

    // SEARCH: verify stats populated.
    let search = tidaldb::query::search::Search::builder()
        .query("Item")
        .limit(10)
        .build()
        .unwrap();
    let search_results = db.search(&search).unwrap();

    assert!(
        search_results.stats.total_time_us > 0,
        "SEARCH total_time_us must be > 0; got {}",
        search_results.stats.total_time_us
    );
    assert_eq!(
        search_results.stats.profile_name, "search",
        "SEARCH profile_name must be 'search'"
    );
}

// ── UAT-07: RLHF Signal Export ──────────────────────────────────────────────

/// Open a persistent DB, write signals, and export them before close.
/// Verify the export contains the written signals.
#[cfg(feature = "test-utils")]
#[test]
fn uat_07_rlhf_signal_export() {
    use tidaldb::TempTidalHome;

    let home = TempTidalHome::new().unwrap();
    let schema = uat_schema();

    let db = TidalDb::builder()
        .with_data_dir(home.path())
        .with_schema(schema)
        .open()
        .unwrap();

    let since = Timestamp::now().as_nanos();

    // Write 5 "view" signals to items 1-5.
    for i in 1..=5u64 {
        db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
            .unwrap();
    }

    // Export BEFORE close -- WAL segments are live on disk.
    let req = ExportRequest::time_range(since, u64::MAX);
    let signals = db.export_signals(&req).unwrap();

    assert!(
        signals.len() >= 5,
        "expected at least 5 exported signals; got {}",
        signals.len()
    );

    // Verify all are "view" signals.
    let views: Vec<_> = signals.iter().filter(|s| s.signal_type == "view").collect();
    assert!(
        views.len() >= 5,
        "expected at least 5 view signals; got {}",
        views.len()
    );

    // Verify entity IDs 1-5 are present.
    for eid in 1..=5u64 {
        assert!(
            signals.iter().any(|s| s.entity_id == eid),
            "entity {eid} must be present in exported signals"
        );
    }

    db.close().unwrap();
}

// ── UAT-08: User Session Aggregation ────────────────────────────────────────

/// Open 2 sessions for user 1. Write 5 signals in each. Close both.
/// Verify `user_session_summary` aggregates correctly.
#[test]
fn uat_08_user_session_aggregation() {
    let schema = uat_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .unwrap();

    let user_id = 42u64;

    // Session 1: 5 view signals.
    let handle1 = db
        .start_session(user_id, "agent-1", "default", HashMap::new())
        .unwrap();
    for i in 1..=5u64 {
        db.session_signal(
            &handle1,
            "view",
            EntityId::new(i),
            1.0,
            Timestamp::now(),
            None,
        )
        .unwrap();
    }
    db.close_session(handle1).unwrap();

    // Session 2: 3 view + 2 like signals.
    let handle2 = db
        .start_session(user_id, "agent-2", "default", HashMap::new())
        .unwrap();
    for i in 6..=8u64 {
        db.session_signal(
            &handle2,
            "view",
            EntityId::new(i),
            1.0,
            Timestamp::now(),
            None,
        )
        .unwrap();
    }
    for i in 9..=10u64 {
        db.session_signal(
            &handle2,
            "like",
            EntityId::new(i),
            1.0,
            Timestamp::now(),
            None,
        )
        .unwrap();
    }
    db.close_session(handle2).unwrap();

    // Aggregate.
    let summary = db.user_session_summary(user_id, 0).unwrap();

    assert!(
        summary.sessions_count >= 2,
        "expected at least 2 sessions; got {}",
        summary.sessions_count
    );
    assert!(
        summary.total_signals >= 10,
        "expected at least 10 total signals; got {}",
        summary.total_signals
    );
    assert!(
        !summary.top_signal_types.is_empty(),
        "top_signal_types must be non-empty"
    );

    // Verify "view" is the top signal type (8 views vs 2 likes).
    assert_eq!(
        summary.top_signal_types[0].0, "view",
        "top signal type should be 'view'"
    );
}

// ── UAT-09: MetricsState Fields Available ───────────────────────────────────

/// Verify `MetricsState` struct has expected fields and methods.
/// This is a compilation + field access test.
#[test]
fn uat_09_metrics_state_fields_available() {
    let schema = uat_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .unwrap();

    // Write some signals to populate metrics.
    for i in 1..=5u64 {
        db.write_item_with_metadata(EntityId::new(i), &item_meta(i))
            .unwrap();
        db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
            .unwrap();
    }

    let metrics = db.metrics();

    // Base fields available unconditionally.
    let uptime = metrics.uptime_seconds();
    assert!(uptime >= 0.0, "uptime must be non-negative; got {uptime}");

    let health = metrics.health_ok_value();
    assert!(
        (health - 1.0).abs() < f64::EPSILON,
        "health must be 1.0 for a healthy db; got {health}"
    );

    // Prometheus rendering works.
    let prom = metrics.render_prometheus();
    assert!(
        prom.contains("tidaldb_uptime_seconds"),
        "Prometheus output must contain uptime metric"
    );
    assert!(
        prom.contains("tidaldb_health_ok"),
        "Prometheus output must contain health_ok metric"
    );

    // Healthz rendering works.
    let healthz = metrics.render_healthz();
    assert!(
        healthz.contains("\"status\":"),
        "healthz must contain status"
    );
    assert!(
        healthz.contains("\"uptime_seconds\":"),
        "healthz must contain uptime_seconds"
    );
    assert!(
        healthz.contains("\"version\":"),
        "healthz must contain version"
    );
}

// ── UAT-10: tidalctl Diagnostics (Structural Test) ──────────────────────────

/// Structural test for the diagnostics infrastructure.
///
/// The tidalctl binary is tested in its own crate (`tidalctl/tests/cli.rs`).
/// This test verifies the underlying Paths, WAL diagnostics, and text index
/// stats APIs that tidalctl depends on -- proving the diagnostics data path
/// compiles and produces sensible values from the tidal library.
#[cfg(feature = "test-utils")]
#[test]
fn uat_10_tidalctl_diagnostics_structural() {
    use tidaldb::TempTidalHome;

    let home = TempTidalHome::new().unwrap();
    let paths = tidaldb::Paths::new(home.path());
    paths.ensure_all().unwrap();

    // Verify Paths resolves expected subdirectories.
    assert!(paths.base().exists(), "base dir must exist");
    assert!(paths.wal_dir().exists(), "wal dir must exist");
    assert!(paths.items_dir().exists(), "items dir must exist");
    assert!(paths.users_dir().exists(), "users dir must exist");
    assert!(paths.creators_dir().exists(), "creators dir must exist");

    // Verify WAL diagnostics can run against an empty directory.
    let report = tidaldb::wal::diagnostics::diagnose_wal(home.path()).unwrap();
    assert_eq!(report.total_events, 0, "empty WAL should have 0 events");
    assert_eq!(report.segment_count, 0, "empty WAL should have 0 segments");
    assert_eq!(
        report.inconsistency_count, 0,
        "empty WAL should have 0 inconsistencies"
    );

    // Verify text index stats reader works on non-existent dir.
    let (segs, docs) =
        tidaldb::text::read_stats_from_dir(&home.path().join("text_index")).unwrap_or((0, 0));
    assert_eq!(segs, 0, "no text index should have 0 segments");
    assert_eq!(docs, 0, "no text index should have 0 docs");

    // Verify BUILD_HASH compiles and is non-empty.
    assert!(
        !tidaldb::BUILD_HASH.is_empty(),
        "BUILD_HASH must be non-empty"
    );
}
