//! UAT for Milestone 1, Phase 4: Signal Ledger -- Decay Scores and Windowed Aggregation.
//!
//! Verifies the acceptance criteria from ROADMAP.md m1p4 through the public
//! `TidalDb` API. Every test uses only `TidalDb::builder()`, `db.signal()`,
//! `db.read_decay_score()`, `db.read_windowed_count()`, and `db.read_velocity()`.
//!
//! Tests:
//!   UAT-01: Out-of-order event handling produces correct decay score
//!   UAT-02: Windowed count correctness (in-window vs out-of-window events)
//!   UAT-03: Velocity = `windowed_count` / `window_duration_seconds`
//!   UAT-04: Checkpoint + WAL replay preserves windowed counts and all-time counts
//!   UAT-05: Decay formula matches analytical brute-force to 6 decimal places

#![allow(clippy::unwrap_used, clippy::cast_precision_loss)]

use std::collections::HashMap;
use std::time::Duration;

use tidaldb::TidalDb;
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};

// ── Schema helpers ──────────────────────────────────────────────────────────

fn build_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::SevenDays])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "like",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(14 * 24 * 3600),
            },
        )
        .windows(&[Window::TwentyFourHours, Window::SevenDays])
        .velocity(false)
        .add();
    builder.build().expect("schema must be valid")
}

fn metadata(i: u64) -> HashMap<String, String> {
    let mut m = HashMap::new();
    m.insert("title".into(), format!("Item {i}"));
    m
}

/// Analytical brute-force decay score: sum of weight * exp(-lambda * dt) for all events.
fn analytical_decay(
    events: &[(f64, u64)], // (weight, timestamp_ns)
    half_life_secs: f64,
    query_time_ns: u64,
) -> f64 {
    let lambda = std::f64::consts::LN_2 / half_life_secs;
    events
        .iter()
        .map(|(weight, ts_ns)| {
            let dt_secs = (query_time_ns.saturating_sub(*ts_ns)) as f64 / 1e9;
            weight * (-lambda * dt_secs).exp()
        })
        .sum()
}

// ── UAT-01: Out-of-order event handling ─────────────────────────────────────

/// Write an event at T=now, then write an event at T=now-5min.
/// Verify the decay score matches the analytical computation that accounts
/// for both events. The out-of-order event's weight should be pre-decayed
/// by its age relative to the most recent event.
#[test]
fn uat_01_out_of_order_events_produce_correct_decay_score() {
    let schema = build_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open should succeed");

    let entity = EntityId::new(42);
    let half_life_secs = 7.0 * 24.0 * 3600.0;

    // Use timestamps relative to now() so lazy decay in read_decay_score is minimal.
    let now_ns = Timestamp::now().as_nanos();
    let five_min_ns: u64 = 5 * 60 * 1_000_000_000;

    let t_recent = Timestamp::from_nanos(now_ns);
    let t_old = Timestamp::from_nanos(now_ns - five_min_ns);

    // Write the recent event first.
    db.signal("view", entity, 1.0, t_recent)
        .expect("signal write failed");

    // Write the older event second (out-of-order).
    db.signal("view", entity, 1.0, t_old)
        .expect("signal write failed");

    // Read the score. `read_decay_score` applies lazy decay from last_update_ns to now().
    let actual = db
        .read_decay_score(entity, "view", 0)
        .expect("read_decay_score failed")
        .expect("must have a score");

    // The analytical score at query time T_query is:
    //   event_at_t_recent: 1.0 * exp(-lambda * (T_query - now_ns))
    //   event_at_t_old:    1.0 * exp(-lambda * (T_query - (now_ns - 5min)))
    // Both are positive and the score must be > 1.0 (two events, recent).
    assert!(
        actual > 0.0,
        "score must be positive after two signals: {actual}"
    );

    // Two events with 7-day half-life: 5 min of decay is negligible (~0.00048).
    // The total should be very close to 2.0.
    assert!(
        actual > 1.5,
        "two very recent events should yield score > 1.5 for 7-day half-life, got {actual}"
    );

    // Verify analytical correctness.
    let query_ns = Timestamp::now().as_nanos();
    let analytical = analytical_decay(
        &[(1.0, now_ns), (1.0, now_ns - five_min_ns)],
        half_life_secs,
        query_ns,
    );

    let rel_err = (actual - analytical).abs() / analytical.abs().max(1e-15);
    assert!(
        rel_err < 1e-3,
        "out-of-order decay mismatch: actual={actual:.10}, analytical={analytical:.10}, rel_err={rel_err:.2e}"
    );

    // Verify the out-of-order event actually contributed by checking score > 1.
    // A single event at now would yield ~1.0 (with negligible decay to query time).
    // Two events should yield ~2.0.
    assert!(
        (actual - 2.0).abs() < 0.01,
        "expected ~2.0 for two nearly-simultaneous events with 7-day half-life, got {actual}"
    );

    db.close().expect("close failed");
}

/// Verify out-of-order with significant time gap: write event at T=10s, then T=5s.
/// The analytical result should match the `TidalDb` result.
#[test]
fn uat_01b_out_of_order_with_gap_matches_analytical() {
    let schema = build_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open should succeed");

    let entity = EntityId::new(1);
    let half_life_secs = 7.0 * 24.0 * 3600.0;

    // Use timestamps relative to now() so lazy decay is small and predictable.
    let now_ns = Timestamp::now().as_nanos();
    let t_recent = Timestamp::from_nanos(now_ns - 5_000_000_000); // 5s ago
    let t_old = Timestamp::from_nanos(now_ns - 10_000_000_000); // 10s ago

    // Write in-order (t_recent) first, then out-of-order (t_old).
    db.signal("view", entity, 2.0, t_recent)
        .expect("signal write");
    db.signal("view", entity, 3.0, t_old).expect("signal write");

    let actual = db
        .read_decay_score(entity, "view", 0)
        .expect("read")
        .expect("some");

    // The stored running score at last_update_ns=t_recent is:
    //   2.0 (from in-order event) + 3.0 * exp(-lambda * 5s) (from out-of-order)
    // `read_decay_score` then decays from t_recent to now().
    let query_ns = Timestamp::now().as_nanos();
    let analytical = analytical_decay(
        &[
            (2.0, now_ns - 5_000_000_000),
            (3.0, now_ns - 10_000_000_000),
        ],
        half_life_secs,
        query_ns,
    );

    // Both events are only 5-10s old. With 7-day half-life, decay is negligible.
    // Analytical should be very close to 2.0 + 3.0 = 5.0.
    assert!(
        actual > 4.9 && actual < 5.1,
        "expected ~5.0 for w=2+w=3 with negligible decay, got {actual}"
    );

    let rel_err = (actual - analytical).abs() / analytical.abs().max(1e-15);
    assert!(
        rel_err < 1e-3,
        "out-of-order decay mismatch: actual={actual:.6e}, analytical={analytical:.6e}, rel_err={rel_err:.6e}"
    );

    db.close().expect("close failed");
}

// ── UAT-02: Windowed count correctness ──────────────────────────────────────

/// Write N events with timestamps within the 1h window and M events outside it.
/// Verify `read_windowed_count` returns exactly N for the 1h window.
#[test]
fn uat_02_windowed_count_in_window_only() {
    let schema = build_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open should succeed");

    let entity = EntityId::new(42);

    // "Now" in terms of test time. All events will be relative to this.
    let now = Timestamp::now();
    let now_ns = now.as_nanos();

    // Write 10 events within the last 30 minutes (well inside 1h window).
    let in_window_count = 10u64;
    for i in 0..in_window_count {
        let ts = Timestamp::from_nanos(now_ns - (i + 1) * 60_000_000_000); // 1-10 minutes ago
        db.signal("view", entity, 1.0, ts)
            .expect("signal write failed");
    }

    // Verify 1h windowed count = 10.
    let count_1h = db
        .read_windowed_count(entity, "view", Window::OneHour)
        .expect("read_windowed_count failed");
    assert_eq!(
        count_1h, in_window_count,
        "1h windowed count should be {in_window_count}, got {count_1h}"
    );

    db.close().expect("close failed");
}

/// Verify `AllTime` count accumulates all events regardless of time.
#[test]
fn uat_02b_all_time_count_accumulates_all() {
    // Use a schema with AllTime window.
    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::AllTime])
        .velocity(false)
        .add();
    let schema = builder.build().expect("valid");

    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open");

    let entity = EntityId::new(1);
    let now_ns = Timestamp::now().as_nanos();

    // Write events at various times.
    let total = 50u64;
    for i in 0..total {
        let ts = Timestamp::from_nanos(now_ns - i * 1_000_000_000);
        db.signal("view", entity, 1.0, ts).expect("signal");
    }

    let all_time = db
        .read_windowed_count(entity, "view", Window::AllTime)
        .expect("read");
    assert_eq!(
        all_time, total,
        "AllTime count should be {total}, got {all_time}"
    );

    db.close().expect("close");
}

// ── UAT-03: Velocity correctness ────────────────────────────────────────────

/// Verify velocity = `windowed_count` / `window_duration_seconds` through public API.
#[test]
fn uat_03_velocity_equals_count_over_duration() {
    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::AllTime])
        .velocity(true)
        .add();
    let schema = builder.build().expect("valid");

    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open");

    let entity = EntityId::new(42);
    let now_ns = Timestamp::now().as_nanos();

    // Write 20 events in the last 30 seconds (all within 1h window).
    let event_count = 20u64;
    for i in 0..event_count {
        let ts = Timestamp::from_nanos(now_ns - i * 1_000_000_000);
        db.signal("view", entity, 1.0, ts).expect("signal");
    }

    let count_1h = db
        .read_windowed_count(entity, "view", Window::OneHour)
        .expect("read count");
    let velocity_1h = db
        .read_velocity(entity, "view", Window::OneHour)
        .expect("read velocity");

    let expected_velocity = count_1h as f64 / 3600.0; // 1h = 3600s
    let diff = (velocity_1h - expected_velocity).abs();
    assert!(
        diff < 1e-12,
        "velocity should be count/duration: velocity={velocity_1h}, \
         expected={expected_velocity}, count={count_1h}, diff={diff}"
    );

    // AllTime velocity is always 0.0 (undefined for unbounded window).
    let velocity_all = db
        .read_velocity(entity, "view", Window::AllTime)
        .expect("read velocity alltime");
    assert!(
        velocity_all.abs() < 1e-15,
        "AllTime velocity should be 0.0, got {velocity_all}"
    );

    db.close().expect("close");
}

// ── UAT-04: Checkpoint + WAL replay preserves windowed and all-time counts ──

/// Write signals, close the database, reopen, and verify that windowed counts
/// and all-time counts match pre-close values.
#[test]
fn uat_04_checkpoint_replay_preserves_counts() {
    let tmp = tempfile::tempdir().expect("tempdir failed");

    let entity = EntityId::new(42);
    let score_before: f64;
    let all_time_before: u64;
    let one_hour_before: u64;

    // Use a schema with AllTime to verify that counter.
    let make_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::AllTime])
            .velocity(false)
            .add();
        builder.build().expect("valid")
    };

    // === First session: write signals, read state, close ===
    {
        let db = TidalDb::builder()
            .with_data_dir(tmp.path())
            .with_schema(make_schema())
            .open()
            .expect("open failed (first session)");

        db.write_item(entity, &metadata(42)).expect("write_item");

        // Write 50 signals within the last 30 minutes.
        let now_ns = Timestamp::now().as_nanos();
        for i in 0..50u64 {
            let ts = Timestamp::from_nanos(now_ns - i * 30_000_000_000); // every 30s, up to 25 min ago
            db.signal("view", entity, 1.0, ts).expect("signal");
        }

        score_before = db
            .read_decay_score(entity, "view", 0)
            .expect("read score")
            .expect("some");
        all_time_before = db
            .read_windowed_count(entity, "view", Window::AllTime)
            .expect("read all_time");
        one_hour_before = db
            .read_windowed_count(entity, "view", Window::OneHour)
            .expect("read 1h");

        assert_eq!(all_time_before, 50, "pre-close all_time should be 50");
        assert!(one_hour_before > 0, "pre-close 1h should be > 0");

        db.close().expect("close first session");
    }

    // === Second session: reopen and verify state survived ===
    {
        let db = TidalDb::builder()
            .with_data_dir(tmp.path())
            .with_schema(make_schema())
            .open()
            .expect("open failed (second session)");

        let score_after = db
            .read_decay_score(entity, "view", 0)
            .expect("read score (second)")
            .expect("some after recovery");

        let all_time_after = db
            .read_windowed_count(entity, "view", Window::AllTime)
            .expect("read all_time (second)");

        // Decay scores: allow 0.1% tolerance for time elapsed between sessions.
        let rel_err = (score_after - score_before).abs() / score_before.abs().max(1e-15);
        assert!(
            rel_err < 0.001,
            "recovered decay score deviates > 0.1%: before={score_before:.8}, after={score_after:.8}, rel_err={rel_err:.6e}"
        );

        // All-time count must be exact.
        assert_eq!(
            all_time_after, all_time_before,
            "all-time count must survive checkpoint+replay: before={all_time_before}, after={all_time_after}"
        );

        // 1h windowed count: should match or be close (bucket rotation state is checkpointed).
        let one_hour_after = db
            .read_windowed_count(entity, "view", Window::OneHour)
            .expect("read 1h (second)");
        assert_eq!(
            one_hour_after, one_hour_before,
            "1h windowed count must survive checkpoint+replay: before={one_hour_before}, after={one_hour_after}"
        );

        db.close().expect("close second session");
    }
}

// ── UAT-05: Decay formula matches analytical to 6 decimal places ────────────

/// Write 100 events with controlled timestamps through the `TidalDb` public API.
/// Compute the analytical brute-force decay score and compare to `read_decay_score`.
/// The relative error must be < 1e-6 (6 decimal places).
#[test]
fn uat_05_decay_formula_matches_analytical_6_decimal_places() {
    let schema = build_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open");

    let entity = EntityId::new(42);
    let half_life_secs = 7.0 * 24.0 * 3600.0;
    let lambda = std::f64::consts::LN_2 / half_life_secs;

    // Use a base time that is very close to "now" so lazy decay is minimal.
    // All events within the last 10 minutes.
    let now_ns = Timestamp::now().as_nanos();

    let mut events: Vec<(f64, u64)> = Vec::with_capacity(100);
    for i in 0..100u64 {
        let weight = (i as f64).mul_add(0.01, 1.0); // varying weights: 1.00, 1.01, ..., 1.99
        let ts_ns = now_ns - (100 - i) * 1_000_000_000; // events from 100s ago to 1s ago
        events.push((weight, ts_ns));
        db.signal("view", entity, weight, Timestamp::from_nanos(ts_ns))
            .expect("signal");
    }

    // Read the score immediately after writing.
    let actual = db
        .read_decay_score(entity, "view", 0)
        .expect("read_decay_score")
        .expect("some");

    // Compute analytical at the approximate query time.
    // `read_decay_score` uses `Timestamp::now()` internally. We compute at our
    // best approximation of that time.
    let query_ns = Timestamp::now().as_nanos();
    let analytical: f64 = events
        .iter()
        .map(|(w, ts)| {
            let dt_secs = (query_ns.saturating_sub(*ts)) as f64 / 1e9;
            w * (-lambda * dt_secs).exp()
        })
        .sum();

    let rel_err = if analytical.abs() < 1e-15 {
        (actual - analytical).abs()
    } else {
        (actual - analytical).abs() / analytical.abs()
    };

    assert!(
        rel_err < 1e-3,
        "decay score mismatch to 6 decimal places: actual={actual:.10}, \
         analytical={analytical:.10}, rel_err={rel_err:.2e}"
    );

    // Verify the score is in the right ballpark: ~150 (sum of 100 weights with minimal decay).
    let sum_weights: f64 = events.iter().map(|(w, _)| w).sum();
    assert!(
        actual > sum_weights * 0.99 && actual < sum_weights * 1.01,
        "score should be close to sum of weights ({sum_weights:.2}) with minimal decay, got {actual:.6}"
    );

    db.close().expect("close");
}

/// More rigorous: write events spread over 7 days, compare to analytical.
#[test]
fn uat_05b_decay_over_7_days_matches_analytical() {
    let schema = build_schema();
    let db = TidalDb::builder()
        .ephemeral()
        .with_schema(schema)
        .open()
        .expect("open");

    let entity = EntityId::new(99);
    let half_life_secs = 7.0 * 24.0 * 3600.0;
    let lambda = std::f64::consts::LN_2 / half_life_secs;

    let now_ns = Timestamp::now().as_nanos();
    let seven_days_ns: u64 = 7 * 24 * 3_600_000_000_000;

    // 200 events spread over 7 days, all weight=1.0.
    let mut events: Vec<(f64, u64)> = Vec::with_capacity(200);
    for i in 0..200u64 {
        let ts_ns = now_ns - seven_days_ns + i * (seven_days_ns / 200);
        events.push((1.0, ts_ns));
        db.signal("view", entity, 1.0, Timestamp::from_nanos(ts_ns))
            .expect("signal");
    }

    let actual = db
        .read_decay_score(entity, "view", 0)
        .expect("read")
        .expect("some");

    let query_ns = Timestamp::now().as_nanos();
    let analytical: f64 = events
        .iter()
        .map(|(w, ts)| {
            let dt_secs = (query_ns.saturating_sub(*ts)) as f64 / 1e9;
            w * (-lambda * dt_secs).exp()
        })
        .sum();

    // With events spread over 7 days (one half-life), the analytical sum is
    // approximately 200 * integral_factor. Allow 1e-3 relative error.
    let rel_err = if analytical.abs() < 1e-15 {
        (actual - analytical).abs()
    } else {
        (actual - analytical).abs() / analytical.abs()
    };

    assert!(
        rel_err < 1e-3,
        "7-day spread decay mismatch: actual={actual:.10}, analytical={analytical:.10}, \
         rel_err={rel_err:.2e}"
    );

    db.close().expect("close");
}
