//! Per-cohort signal state ledger.
//!
//! Mirrors the global `SignalLedger` semantics (decay, velocity, windowed counts)
//! but keys entries by `(cohort_name, entity_id, signal_type_id)`. Shares the
//! same schema-derived lambdas as the global ledger.
//!
//! Cohort-level signal writes are best-effort: they do not go through the WAL
//! and do not fail the primary signal write path.

use std::collections::HashMap;
use std::fmt;

use dashmap::DashMap;

use crate::schema::{DecayModel, EntityId, Schema, SchemaError, TidalError, Window};
use crate::signals::SignalTypeId;
use crate::signals::hot::HotSignalState;
use crate::signals::ledger::types::EntitySignalEntry;
use crate::signals::warm::BucketedCounter;

/// Per-cohort signal state.
///
/// The key type is `(String, EntityId, SignalTypeId)` where `String` is the
/// cohort name. Using `String` avoids lifetime issues with `Arc<str>` in
/// `DashMap` while keeping the performance impact negligible (cohort names
/// are short strings, and signal writes are already I/O-bound).
pub struct CohortSignalLedger {
    entries: DashMap<(String, EntityId, SignalTypeId), EntitySignalEntry>,
    signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
    signal_name_to_id: HashMap<String, SignalTypeId>,
}

impl CohortSignalLedger {
    /// Construct an empty ledger with no signal types (for no-schema mode).
    #[must_use]
    pub fn empty() -> Self {
        Self {
            entries: DashMap::with_shard_amount(16),
            signal_lambdas: HashMap::new(),
            signal_name_to_id: HashMap::new(),
        }
    }

    /// Construct from a validated schema, building the same lambda map as `SignalLedger`.
    #[must_use]
    pub fn new(schema: &Schema) -> Self {
        let mut signal_list: Vec<&crate::schema::SignalTypeDef> = schema.signals().collect();
        signal_list.sort_by_key(|s| s.name());

        let mut signal_name_to_id = HashMap::with_capacity(signal_list.len());
        let mut signal_lambdas = HashMap::with_capacity(signal_list.len());

        for (idx, sig) in signal_list.iter().enumerate() {
            let type_id = SignalTypeId::new(idx as u16);
            signal_name_to_id.insert(sig.name().to_owned(), type_id);

            let lambdas = match sig.decay() {
                DecayModel::Exponential { lambda, .. } => vec![*lambda],
                DecayModel::Linear { .. } | DecayModel::Permanent => vec![],
            };
            signal_lambdas.insert(type_id, lambdas);
        }

        Self {
            entries: DashMap::with_shard_amount(16),
            signal_lambdas,
            signal_name_to_id,
        }
    }

    /// Record a signal for a specific cohort.
    ///
    /// This is best-effort: it does not write to WAL and never returns an error.
    /// If recording fails (e.g., unknown signal type), it is silently dropped.
    pub fn record(
        &self,
        cohort: &str,
        entity_id: EntityId,
        type_id: SignalTypeId,
        weight: f64,
        ts_ns: u64,
    ) {
        let lambdas = self
            .signal_lambdas
            .get(&type_id)
            .map_or_else(<&[f64]>::default, Vec::as_slice);

        let key = (cohort.to_owned(), entity_id, type_id);
        let entry = self
            .entries
            .entry(key)
            .or_insert_with(|| EntitySignalEntry {
                hot: HotSignalState::new(entity_id.as_u64(), type_id.as_u16()),
                warm: BucketedCounter::with_start_time(ts_ns),
            });

        entry.hot.on_signal(weight, ts_ns, lambdas);

        #[cfg(any(test, feature = "test-utils"))]
        crate::testing::crash_injector::check_crash_point(
            crate::testing::CrashPoint::CohortLedgerUpdate,
        );

        entry.warm.increment(ts_ns);
        drop(entry);
    }

    /// Read the current decay score for a cohort-entity-signal triple.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn read_decay_score(
        &self,
        cohort: &str,
        entity_id: EntityId,
        signal_type_name: &str,
        decay_rate_idx: usize,
    ) -> crate::Result<Option<f64>> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        let now_ns = crate::schema::Timestamp::now().as_nanos();

        let lambda = self
            .signal_lambdas
            .get(&type_id)
            .and_then(|v| v.get(decay_rate_idx))
            .copied()
            .unwrap_or(0.0);

        let key = (cohort.to_owned(), entity_id, type_id);
        match self.entries.get(&key) {
            None => Ok(None),
            Some(entry) => Ok(Some(entry.hot.current_score(
                decay_rate_idx,
                now_ns,
                lambda,
            ))),
        }
    }

    /// Read the windowed event count for a cohort-entity-signal triple.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn read_windowed_count(
        &self,
        cohort: &str,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
    ) -> crate::Result<u64> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        let key = (cohort.to_owned(), entity_id, type_id);
        match self.entries.get(&key) {
            None => Ok(0),
            Some(entry) => Ok(entry.warm.windowed_count(window)),
        }
    }

    /// Read the velocity for a cohort-entity-signal-window.
    ///
    /// Velocity = `windowed_count / window_duration_seconds`.
    /// Returns 0.0 for `AllTime` or if no signals recorded.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn read_velocity(
        &self,
        cohort: &str,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
    ) -> crate::Result<f64> {
        let count = self.read_windowed_count(cohort, entity_id, signal_type_name, window)?;
        let duration_secs = window.duration_secs_f64();
        if duration_secs.is_infinite() {
            return Ok(0.0);
        }
        #[allow(clippy::cast_precision_loss)]
        Ok(count as f64 / duration_secs)
    }

    /// Resolve a signal type name to its `SignalTypeId`.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the name is not in the schema.
    pub fn resolve_signal_type(&self, name: &str) -> crate::Result<SignalTypeId> {
        self.signal_name_to_id
            .get(name)
            .copied()
            .ok_or_else(|| TidalError::Schema(SchemaError::UnknownSignalType(name.to_owned())))
    }

    /// Iterate all entries (for checkpoint serialization).
    pub fn iter_entries(
        &self,
    ) -> impl Iterator<
        Item = dashmap::mapref::multiple::RefMulti<
            '_,
            (String, EntityId, SignalTypeId),
            EntitySignalEntry,
        >,
    > {
        self.entries.iter()
    }

    /// Insert a restored entry (for checkpoint restore).
    pub fn insert_entry(
        &self,
        cohort: String,
        entity_id: EntityId,
        type_id: SignalTypeId,
        entry: EntitySignalEntry,
    ) {
        self.entries.insert((cohort, entity_id, type_id), entry);
    }

    /// Number of entries in the ledger.
    #[must_use]
    pub fn entry_count(&self) -> usize {
        self.entries.len()
    }
}

impl fmt::Debug for CohortSignalLedger {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("CohortSignalLedger")
            .field("entry_count", &self.entries.len())
            .field("signal_types", &self.signal_name_to_id.len())
            .finish_non_exhaustive()
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    use super::*;
    use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp, 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();
        let _ = builder
            .signal(
                "like",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(14 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        builder.build().expect("valid test schema")
    }

    #[test]
    fn record_and_read_decay_score() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(42);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();

        ledger.record("tech", entity_id, type_id, 1.0, ts_ns);

        let score = ledger
            .read_decay_score("tech", entity_id, "view", 0)
            .unwrap();
        assert!(score.is_some());
        assert!(score.unwrap() > 0.0);
    }

    #[test]
    fn record_and_read_windowed_count() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(1);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();

        ledger.record("sports", entity_id, type_id, 1.0, ts_ns);
        ledger.record("sports", entity_id, type_id, 1.0, ts_ns + 1_000_000);

        let count = ledger
            .read_windowed_count("sports", entity_id, "view", Window::AllTime)
            .unwrap();
        assert_eq!(count, 2);
    }

    #[test]
    fn different_cohorts_independent() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(1);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();

        ledger.record("cohort_a", entity_id, type_id, 1.0, ts_ns);
        ledger.record("cohort_a", entity_id, type_id, 1.0, ts_ns + 1_000_000);
        ledger.record("cohort_b", entity_id, type_id, 1.0, ts_ns);

        let count_a = ledger
            .read_windowed_count("cohort_a", entity_id, "view", Window::AllTime)
            .unwrap();
        let count_b = ledger
            .read_windowed_count("cohort_b", entity_id, "view", Window::AllTime)
            .unwrap();
        assert_eq!(count_a, 2);
        assert_eq!(count_b, 1);
    }

    #[test]
    fn unknown_signal_type_returns_error() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let result = ledger.read_decay_score("cohort", EntityId::new(1), "nonexistent", 0);
        assert!(result.is_err());
    }

    #[test]
    fn read_nonexistent_entry_returns_none_or_zero() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let score = ledger
            .read_decay_score("cohort", EntityId::new(999), "view", 0)
            .unwrap();
        assert!(score.is_none());

        let count = ledger
            .read_windowed_count("cohort", EntityId::new(999), "view", Window::AllTime)
            .unwrap();
        assert_eq!(count, 0);

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

    #[test]
    fn velocity_all_time_is_zero() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(1);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();

        ledger.record("c", entity_id, type_id, 1.0, ts_ns);
        let vel = ledger
            .read_velocity("c", entity_id, "view", Window::AllTime)
            .unwrap();
        assert!((vel - 0.0).abs() < 1e-15);
    }

    #[test]
    fn entry_count() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        assert_eq!(ledger.entry_count(), 0);

        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();
        ledger.record("c", EntityId::new(1), type_id, 1.0, ts_ns);
        assert_eq!(ledger.entry_count(), 1);

        // Same key increments count, does not add new entry.
        ledger.record("c", EntityId::new(1), type_id, 1.0, ts_ns + 1);
        assert_eq!(ledger.entry_count(), 1);

        // Different cohort = different key.
        ledger.record("d", EntityId::new(1), type_id, 1.0, ts_ns);
        assert_eq!(ledger.entry_count(), 2);
    }

    #[test]
    fn insert_entry_for_restore() {
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let entry = EntitySignalEntry {
            hot: HotSignalState::new(42, type_id.as_u16()),
            warm: BucketedCounter::new(),
        };
        ledger.insert_entry("test".to_owned(), EntityId::new(42), type_id, entry);
        assert_eq!(ledger.entry_count(), 1);
    }

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