//! 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, fmt, sync::Arc};

use dashmap::DashMap;

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

/// Per-cohort signal state.
///
/// The key type is `(CohortName, EntityId, SignalTypeId)` where
/// `CohortName = Arc<str>`. The attribution fan-out (`db::signals` resolves a
/// `Vec<CohortName>` and records the signal into each one) holds the cohort
/// name as an `Arc<str>` already, so `record` keys the map with an `Arc::clone`
/// — a refcount bump, not a fresh allocation. The previous `String` key forced
/// a `cohort.to_owned()` heap allocation per matched cohort on every signal
/// write, which violates `CODING_GUIDELINES §1` (no `String` in the hot-path).
/// Read paths (which take a borrowed `&str` from the query executor) build a
/// single lookup `Arc<str>`; that is the same single allocation the old `String`
/// key incurred, and reads are off the signal-write fan-out path.
pub struct CohortSignalLedger {
    entries: DashMap<(CohortName, 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`.
    ///
    /// The signal-id and per-signal lambda derivation is shared verbatim with
    /// `SignalLedger::new` via `signals::ledger::derive_signal_ids_and_lambdas`,
    /// so the cohort ledger and the global ledger assign identical
    /// `SignalTypeId`s and decay rates for the same schema — a correctness
    /// requirement, since cohort and global entries share the same on-disk
    /// fixed-format record and resolve names through the same id space.
    #[must_use]
    pub fn new(schema: &Schema) -> Self {
        let (signal_name_to_id, signal_lambdas) =
            crate::signals::ledger::derive_signal_ids_and_lambdas(schema);

        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.
    ///
    /// Takes the cohort name as `&CohortName` (`&Arc<str>`) so the per-write key
    /// is built with `Arc::clone` (a refcount bump) rather than a fresh `String`
    /// allocation. A single signal can fan out to every matching cohort, so this
    /// is the hot path the `String` key previously taxed once per matched cohort.
    pub fn record(
        &self,
        cohort: &CohortName,
        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 = (Arc::clone(cohort), 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();

        // Range-check the decay rate against the signal's configured lambdas,
        // mirroring the global `SignalLedger::read_decay_score_at`. A missing
        // lambda means "no such decay rate" — surface it as `None` rather than
        // defaulting lambda to 0.0, which would return an *undecayed* score (the
        // stored value as if it never aged) and silently mask a bad index.
        let Some(&lambda) = self
            .signal_lambdas
            .get(&type_id)
            .and_then(|v| v.get(decay_rate_idx))
        else {
            return Ok(None);
        };

        // Reads take a borrowed name from the query executor, so we build a
        // single lookup `Arc<str>` (the tuple key element is `Arc<str>`, which is
        // not `Copy`, so the key must own it). This is the same single allocation
        // the former `String` key incurred — reads are off the signal-write
        // fan-out hot path that the `Arc::clone` change in `record` optimizes.
        let key: (CohortName, EntityId, SignalTypeId) = (Arc::from(cohort), 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)?;
        // Read the clock here (matching `read_decay_score`) and thread it into the
        // warm tier so a quiet cohort-entity's windowed/velocity count ages
        // correctly at read time — without it CohortTrending stays frozen at its
        // peak forever (CRITICAL #6, CODING_GUIDELINES §8).
        let now_ns = crate::schema::Timestamp::now().as_nanos();
        // Build the lookup key with a single `Arc<str>` (see `read_decay_score`).
        let key: (CohortName, EntityId, SignalTypeId) = (Arc::from(cohort), entity_id, type_id);
        match self.entries.get(&key) {
            None => Ok(0),
            Some(entry) => Ok(entry.warm.windowed_count(window, now_ns)),
        }
    }

    /// 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<
            '_,
            (CohortName, EntityId, SignalTypeId),
            EntitySignalEntry,
        >,
    > {
        self.entries.iter()
    }

    /// Insert a restored entry (for checkpoint restore).
    pub fn insert_entry(
        &self,
        cohort: CohortName,
        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};

    /// Build a `CohortName` (`Arc<str>`) from a string literal for `record`,
    /// which keys by `&CohortName` so the production fan-out clones an `Arc`
    /// (refcount bump) instead of allocating a `String` per matched cohort.
    fn cn(name: &str) -> CohortName {
        Arc::from(name)
    }

    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(&cn("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(&cn("sports"), entity_id, type_id, 1.0, ts_ns);
        ledger.record(&cn("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 quiet_cohort_entity_windowed_count_decays_to_zero_on_read() {
        // CRITICAL #6 regression for the cohort tier: a cohort-entity that went
        // quiet must report a decaying 1h windowed count on a pure read so
        // CohortTrending ages correctly. Record events stamped ~2h in the past,
        // then read at the present clock — the read-time rotation must zero the
        // stale minute buckets.
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(3);
        let type_id = ledger.resolve_signal_type("view").unwrap();

        let two_hours_ns = 2 * 3_600_000_000_000_u64;
        let past = Timestamp::now().as_nanos().saturating_sub(two_hours_ns);
        for _ in 0..20 {
            ledger.record(&cn("tech"), entity_id, type_id, 1.0, past);
        }

        let one_hour = ledger
            .read_windowed_count("tech", entity_id, "view", Window::OneHour)
            .unwrap();
        assert_eq!(
            one_hour, 0,
            "a quiet cohort-entity's 1h window must decay to 0 once its events fall outside it"
        );

        // AllTime is unbounded and must still reflect every recorded event.
        let all_time = ledger
            .read_windowed_count("tech", entity_id, "view", Window::AllTime)
            .unwrap();
        assert_eq!(all_time, 20);
    }

    #[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(&cn("cohort_a"), entity_id, type_id, 1.0, ts_ns);
        ledger.record(&cn("cohort_a"), entity_id, type_id, 1.0, ts_ns + 1_000_000);
        ledger.record(&cn("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 out_of_range_decay_index_returns_none() {
        // W42 regression: an out-of-range `decay_rate_idx` must return `Ok(None)`
        // (mirroring the global `SignalLedger`), not an undecayed score from a
        // lambda silently defaulted to 0.0. "view" has a single decay rate, so
        // index 0 is valid and index 1 is out of range.
        let schema = test_schema();
        let ledger = CohortSignalLedger::new(&schema);
        let entity_id = EntityId::new(7);
        let type_id = ledger.resolve_signal_type("view").unwrap();
        let ts_ns = Timestamp::now().as_nanos();
        ledger.record(&cn("tech"), entity_id, type_id, 1.0, ts_ns);

        // Valid index returns a real score for the recorded entry.
        let valid = ledger
            .read_decay_score("tech", entity_id, "view", 0)
            .unwrap();
        assert!(valid.is_some_and(|s| s > 0.0));

        // Out-of-range index must be None — not the undecayed score the old
        // `unwrap_or(0.0)` lambda default would have produced.
        let out_of_range = ledger
            .read_decay_score("tech", entity_id, "view", 1)
            .unwrap();
        assert!(
            out_of_range.is_none(),
            "out-of-range decay index must return None, got {out_of_range:?}"
        );
    }

    #[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(&cn("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(&cn("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(&cn("c"), EntityId::new(1), type_id, 1.0, ts_ns + 1);
        assert_eq!(ledger.entry_count(), 1);

        // Different cohort = different key.
        ledger.record(&cn("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(cn("test"), 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>();
    }
}
