use std::time::Duration;

use super::{super::types::NoopWalWriter, *};
use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};

fn test_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,
            Window::AllTime,
        ])
        .velocity(true)
        .add();
    builder.build().expect("valid test schema")
}

fn test_schema_multi() -> Schema {
    let mut builder = SchemaBuilder::new();
    let _ = builder
        .signal(
            "like",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(14 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "skip",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    let _ = builder
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .add();
    builder.build().expect("valid multi-signal test schema")
}

#[test]
fn ledger_record_and_read() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(42);

    let now = Timestamp::now();
    ledger
        .record_signal("view", entity_id, 1.0, now)
        .expect("record");

    let score = ledger
        .read_decay_score(entity_id, "view", 0)
        .expect("score");
    assert!(score.is_some());
    assert!(score.expect("some") > 0.0);

    let count = ledger
        .read_windowed_count(entity_id, "view", Window::OneHour)
        .expect("count");
    assert_eq!(count, 1);

    let all_time = ledger
        .read_windowed_count(entity_id, "view", Window::AllTime)
        .expect("all_time");
    assert_eq!(all_time, 1);
}

#[test]
fn ledger_unknown_signal_type_returns_error() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

    let result = ledger.record_signal("nonexistent", EntityId::new(1), 1.0, Timestamp::now());
    assert!(result.is_err());
}

#[test]
fn ledger_read_nonexistent_entity_returns_none() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));

    let score = ledger
        .read_decay_score(EntityId::new(999), "view", 0)
        .expect("ok");
    assert!(score.is_none());

    let count = ledger
        .read_windowed_count(EntityId::new(999), "view", Window::OneHour)
        .expect("ok");
    assert_eq!(count, 0);

    let velocity = ledger
        .read_velocity(EntityId::new(999), "view", Window::OneHour)
        .expect("ok");
    assert!((velocity - 0.0).abs() < 1e-15);
}

#[test]
fn ledger_quiet_entity_windowed_count_decays_to_zero_on_read() {
    // CRITICAL #6 regression at the ledger level: an entity that received signals
    // and then went quiet must report a decaying windowed count on a pure read.
    // We record events stamped > 1h in the past, then read at the present wall
    // clock — the read-time rotation must age the stale minute buckets out so the
    // 1h window reads 0, while AllTime (unbounded) still reflects every event.
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(7);

    // Stamp the events ~2h before now so they fall outside the 1h window when read
    // at the present time.
    let two_hours_ns = 2 * 3_600_000_000_000_u64;
    let past = Timestamp::from_nanos(Timestamp::now().as_nanos().saturating_sub(two_hours_ns));
    for _ in 0..25 {
        ledger
            .record_signal("view", entity_id, 1.0, past)
            .expect("record");
    }

    // The 1h window read (at the present clock) must have rotated the 2h-old
    // events out — no intervening write fired the rotation, the read did.
    let one_hour = ledger
        .read_windowed_count(entity_id, "view", Window::OneHour)
        .expect("count");
    assert_eq!(
        one_hour, 0,
        "a quiet entity's 1h window must decay to 0 once its events fall outside the window"
    );

    // Velocity over the 1h window must likewise read 0.
    let velocity = ledger
        .read_velocity(entity_id, "view", Window::OneHour)
        .expect("velocity");
    assert!(
        velocity.abs() < 1e-15,
        "1h velocity of a quiet entity must decay to 0, got {velocity}"
    );

    // AllTime is unbounded and must still reflect every recorded event.
    let all_time = ledger
        .read_windowed_count(entity_id, "view", Window::AllTime)
        .expect("all_time");
    assert_eq!(
        all_time, 25,
        "all-time count must be untouched by read-time rotation"
    );
}

#[test]
fn read_decay_score_out_of_range_idx_returns_none() {
    // Regression: an out-of-range `decay_rate_idx` must NOT silently fall back to
    // lambda 0.0 and return an undecayed score. An `Exponential` signal registers
    // exactly one lambda, so idx 0 is valid and any idx >= 1 has no decay rate —
    // `read_decay_score` must report that as `None`, distinct from a real score.
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(7);

    ledger
        .record_signal("view", entity_id, 1.0, Timestamp::now())
        .expect("record");

    // Index 0 is in range and yields a real, positive (decayed) score.
    let in_range = ledger.read_decay_score(entity_id, "view", 0).expect("ok");
    assert!(
        in_range.is_some_and(|s| s > 0.0),
        "idx 0 must resolve to the single registered decay rate"
    );

    // Index 1 has no configured decay rate — must be `None`, NOT an undecayed
    // raw score from a 0.0-lambda fallback.
    let out_of_range = ledger.read_decay_score(entity_id, "view", 1).expect("ok");
    assert!(
        out_of_range.is_none(),
        "an out-of-range decay_rate_idx must return None, not a never-decaying score"
    );

    // A far-out index behaves the same.
    let way_out = ledger.read_decay_score(entity_id, "view", 99).expect("ok");
    assert!(way_out.is_none(), "any idx beyond the lambda count is None");
}

#[test]
fn ledger_velocity_all_time_is_zero() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(1);

    ledger
        .record_signal("view", entity_id, 1.0, Timestamp::now())
        .expect("record");
    let velocity = ledger
        .read_velocity(entity_id, "view", Window::AllTime)
        .expect("ok");
    assert!(
        (velocity - 0.0).abs() < 1e-15,
        "all-time velocity should be 0.0"
    );
}

#[test]
fn ledger_multiple_signal_types() {
    let schema = test_schema_multi();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(1);
    let now = Timestamp::now();

    ledger
        .record_signal("view", entity_id, 1.0, now)
        .expect("record view");
    ledger
        .record_signal("like", entity_id, 1.0, now)
        .expect("record like");

    let view_count = ledger
        .read_windowed_count(entity_id, "view", Window::AllTime)
        .expect("ok");
    let like_count = ledger
        .read_windowed_count(entity_id, "like", Window::AllTime)
        .expect("ok");
    let skip_count = ledger
        .read_windowed_count(entity_id, "skip", Window::AllTime)
        .expect("ok");

    assert_eq!(view_count, 1);
    assert_eq!(like_count, 1);
    assert_eq!(skip_count, 0);
}

#[test]
fn ledger_multiple_entities() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let now = Timestamp::now();

    ledger
        .record_signal("view", EntityId::new(1), 1.0, now)
        .expect("1");
    ledger
        .record_signal("view", EntityId::new(2), 1.0, now)
        .expect("2a");
    ledger
        .record_signal("view", EntityId::new(2), 1.0, now)
        .expect("2b");

    let count1 = ledger
        .read_windowed_count(EntityId::new(1), "view", Window::AllTime)
        .expect("ok");
    let count2 = ledger
        .read_windowed_count(EntityId::new(2), "view", Window::AllTime)
        .expect("ok");

    assert_eq!(count1, 1);
    assert_eq!(count2, 2);
}

#[test]
fn ledger_is_send_and_sync() {
    fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<SignalLedger>();
}

#[test]
fn signal_type_id_newtype() {
    use super::super::super::SignalTypeId;
    let id = SignalTypeId::new(5);
    assert_eq!(id.as_u16(), 5);
    assert_eq!(id.to_string(), "5");
    assert_eq!(id, SignalTypeId::new(5));
    assert_ne!(id, SignalTypeId::new(6));
}

/// W20: `read_decay_score` must equal `read_decay_score_at` evaluated at the
/// same instant, and the explicit-clock variant must apply forward decay for a
/// later `now_ns` — proving a caller can read the clock once and reuse it.
#[test]
fn read_decay_score_at_matches_wrapper_and_decays_forward() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(7);

    let event_ns = 1_000_000_000_000u64;
    ledger
        .record_signal("view", entity_id, 1.0, Timestamp::from_nanos(event_ns))
        .expect("record");

    // At the event instant the explicit-clock score equals the stored score.
    let at_event = ledger
        .read_decay_score_at(entity_id, "view", 0, event_ns)
        .expect("ok")
        .expect("some");
    assert!((at_event - 1.0).abs() < 1e-9);

    // One day later (well within the 7-day half-life) the score has decayed but
    // is still positive.
    let one_day_ns = event_ns + 24 * 3600 * 1_000_000_000;
    let later = ledger
        .read_decay_score_at(entity_id, "view", 0, one_day_ns)
        .expect("ok")
        .expect("some");
    assert!(
        later < at_event,
        "score must decay forward: {later} < {at_event}"
    );
    assert!(later > 0.0);

    // The clock-reading wrapper agrees with the explicit-clock variant when both
    // are evaluated at "now" (taken once, threaded into _at).
    let now_ns = Timestamp::now().as_nanos();
    let via_at = ledger
        .read_decay_score_at(entity_id, "view", 0, now_ns)
        .expect("ok");
    let via_wrapper = ledger.read_decay_score(entity_id, "view", 0).expect("ok");
    // Both should be Some and within a tiny tolerance (the wrapper reads its own
    // clock a few microseconds later, so they decay by a negligible amount).
    let (a, b) = (via_at.expect("some"), via_wrapper.expect("some"));
    assert!((a - b).abs() < 1e-3, "wrapper {b} vs _at {a}");
}

/// W20: `read_windowed_count` must equal `read_windowed_count_at` at the same
/// instant, and the explicit clock must rotate stale buckets out — so a quiet
/// entity's 1h window reads 0 at a `now_ns` more than an hour past the event.
#[test]
fn read_windowed_count_at_matches_wrapper_and_rotates() {
    let schema = test_schema();
    let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
    let entity_id = EntityId::new(8);

    let event_ns = 1_000_000_000_000u64;
    ledger
        .record_signal("view", entity_id, 1.0, Timestamp::from_nanos(event_ns))
        .expect("record");

    // Read at the event instant: the 1h window holds the single event.
    let at_event = ledger
        .read_windowed_count_at(entity_id, "view", Window::OneHour, event_ns)
        .expect("ok");
    assert_eq!(at_event, 1);

    // Read 90 minutes later: the 1h window has rotated the event out (CRITICAL
    // #6 semantics), but AllTime still counts it.
    let later_ns = event_ns + 90 * 60 * 1_000_000_000;
    let one_hour_later = ledger
        .read_windowed_count_at(entity_id, "view", Window::OneHour, later_ns)
        .expect("ok");
    assert_eq!(
        one_hour_later, 0,
        "stale event must rotate out of the 1h window"
    );
    let all_time = ledger
        .read_windowed_count_at(entity_id, "view", Window::AllTime, later_ns)
        .expect("ok");
    assert_eq!(all_time, 1, "AllTime is unbounded and keeps the event");

    // The wrapper agrees with the explicit-clock variant evaluated at "now".
    let now_ns = Timestamp::now().as_nanos();
    let via_at = ledger
        .read_windowed_count_at(entity_id, "view", Window::AllTime, now_ns)
        .expect("ok");
    let via_wrapper = ledger
        .read_windowed_count(entity_id, "view", Window::AllTime)
        .expect("ok");
    assert_eq!(via_at, via_wrapper);
}

#[allow(clippy::cast_precision_loss)]
mod proptests {
    use std::time::Duration;

    use proptest::prelude::*;

    use super::super::{super::types::NoopWalWriter, *};
    use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Window};

    fn test_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::AllTime])
            .velocity(true)
            .add();
        builder.build().expect("valid schema")
    }

    // Velocity equals windowed_count / duration for all windows.
    proptest! {
        #[test]
        fn velocity_equals_count_over_duration(
            event_count in 1u64..1000,
        ) {
            let schema = test_schema();
            let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
            let entity_id = EntityId::new(1);

            // All events in the current minute (within 1h window).
            let now = Timestamp::now();
            for i in 0..event_count {
                let ts = Timestamp::from_nanos(now.as_nanos() + i * 1_000_000);
                ledger.record_signal("view", entity_id, 1.0, ts).expect("record");
            }

            let count_1h = ledger
                .read_windowed_count(entity_id, "view", Window::OneHour)
                .expect("ok");
            let velocity_1h = ledger
                .read_velocity(entity_id, "view", Window::OneHour)
                .expect("ok");

            let expected_velocity = count_1h as f64 / Window::OneHour.duration_secs_f64();
            prop_assert!(
                (velocity_1h - expected_velocity).abs() < 1e-15,
                "velocity={velocity_1h}, expected={expected_velocity}"
            );
        }
    }
}

// ── Reconciliation: hot and warm agree after a CRDT merge ──────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod reconcile_tests {
    use std::time::Duration;

    use crate::{
        replication::{crdt::CrdtSignalState, shard::ShardId},
        schema::{DecaySpec, EntityId, EntityKind, Schema, SchemaBuilder, Timestamp, Window},
        signals::{NoopWalWriter, SignalLedger},
    };

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

    /// Regression: reconciliation forced the hot decay score to the merged
    /// truth but left the warm windowed counts at their diverged local value,
    /// so the two tiers disagreed about the same signal. After the fix, the
    /// warm tier reflects the CRDT's merged total event count.
    #[test]
    fn reconcile_aligns_warm_counts_with_merged_state() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let id = EntityId::new(1);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let lambda = 0.0; // unused for the count assertion

        // Local node observed a single event.
        ledger
            .record_signal("view", id, 1.0, Timestamp::now())
            .unwrap();
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            1
        );

        // A CRDT merge from peers carries 5 total events for this signal.
        let mut state = CrdtSignalState::new(lambda);
        state.increment_bucket(ShardId(7), 5);
        assert_eq!(state.total_count(), 5);

        ledger.apply_crdt_state(id, type_id, &state).unwrap();

        // Hot was force-set to the merged decay score; warm must now agree by
        // reflecting the merged total — not the stale local count of 1.
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            5,
            "all-time count must equal the merged CRDT total after reconciliation"
        );
        // The reconciled delta is credited as current, so it lands in the
        // active 1h/24h windows too.
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::OneHour)
                .unwrap(),
            5
        );
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::TwentyFourHours)
                .unwrap(),
            5
        );
    }

    /// Reconciliation never shrinks a locally-durable count: if the CRDT
    /// snapshot has seen fewer events than the local node, the warm tier is
    /// left intact (the local events are durable; the snapshot was simply
    /// behind).
    #[test]
    fn reconcile_does_not_shrink_local_count() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let id = EntityId::new(2);
        let type_id = ledger.resolve_signal_type("view").unwrap();

        let now = Timestamp::now();
        for _ in 0..4 {
            ledger.record_signal("view", id, 1.0, now).unwrap();
        }
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            4
        );

        // CRDT carries only 2 events (it lags the local node).
        let mut state = CrdtSignalState::new(0.0);
        state.increment_bucket(ShardId(1), 2);
        ledger.apply_crdt_state(id, type_id, &state).unwrap();

        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            4,
            "reconciliation must never shrink a durable local count"
        );
    }

    /// Re-applying the same merged state is idempotent for the warm tier:
    /// the count does not grow on the second apply.
    #[test]
    fn reconcile_is_idempotent_for_warm_counts() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let id = EntityId::new(3);
        let type_id = ledger.resolve_signal_type("view").unwrap();

        let mut state = CrdtSignalState::new(0.0);
        state.increment_bucket(ShardId(2), 3);

        ledger.apply_crdt_state(id, type_id, &state).unwrap();
        ledger.apply_crdt_state(id, type_id, &state).unwrap();

        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            3,
            "re-applying the same merged total must not double-count"
        );
    }
}

// ── M9p1: scoped writes (local-profile-intact invariant) ───────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod scoped_tests {
    use std::time::Duration;

    use crate::{
        governance::{CommunityId, SignalScope},
        schema::{DecaySpec, EntityId, EntityKind, Schema, SchemaBuilder, Timestamp, Window},
        signals::{NoopWalWriter, SignalLedger},
    };

    fn schema() -> Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().unwrap()
    }

    #[test]
    fn local_scope_updates_local_aggregate() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let id = EntityId::new(1);
        ledger
            .record_signal_scoped("view", id, 1.0, Timestamp::now(), SignalScope::Local, 0, 0)
            .unwrap();
        let score = ledger.read_decay_score(id, "view", 0).unwrap();
        assert!(
            score.is_some_and(|s| s > 0.0),
            "local write must update the local aggregate"
        );
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            1
        );
    }

    #[test]
    fn community_scope_does_not_touch_local_aggregate() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let id = EntityId::new(2);
        ledger
            .record_signal_scoped(
                "view",
                id,
                1.0,
                Timestamp::now(),
                SignalScope::Community(CommunityId(1)),
                1,
                0,
            )
            .unwrap();
        // The community write is durably logged (NoopWal here) but must NOT
        // perturb the local profile — the load-bearing M9 invariant.
        assert!(
            ledger.read_decay_score(id, "view", 0).unwrap().is_none(),
            "community write must not create a local aggregate entry"
        );
        assert_eq!(
            ledger
                .read_windowed_count(id, "view", Window::AllTime)
                .unwrap(),
            0,
            "community write must not increment the local windowed count"
        );
    }

    #[test]
    fn session_and_agent_scopes_also_skip_local() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        for (id, scope) in [
            (EntityId::new(10), SignalScope::Session),
            (EntityId::new(11), SignalScope::Agent),
        ] {
            ledger
                .record_signal_scoped("view", id, 1.0, Timestamp::now(), scope, 0, 0)
                .unwrap();
            assert!(ledger.read_decay_score(id, "view", 0).unwrap().is_none());
        }
    }

    #[test]
    fn unknown_signal_type_errors_before_wal() {
        let ledger = SignalLedger::new(schema(), Box::new(NoopWalWriter));
        let err = ledger.record_signal_scoped(
            "nonexistent",
            EntityId::new(1),
            1.0,
            Timestamp::now(),
            SignalScope::Local,
            0,
            0,
        );
        assert!(err.is_err());
    }
}
