//! The signal ledger: coordinates hot and warm tiers for all active entities.
//!
//! `SignalLedger` is the single entry point for signal state management.
//! It owns a `DashMap<(EntityId, SignalTypeId), EntitySignalEntry>` that
//! provides concurrent access to per-entity signal state.

use std::{collections::HashMap, fmt};

use dashmap::DashMap;

use super::{
    super::{SignalTypeId, hot::HotSignalState, warm::BucketedCounter},
    derive_signal_ids_and_lambdas,
    types::{EntitySignalEntry, WalWriter},
};
use crate::{
    governance::scope::SignalScope,
    schema::{EntityId, Schema, SchemaError, TidalError, Timestamp, Window},
};

// ── Ledger ────────────────────────────────────────────────────────────────────

/// The signal ledger: coordinates hot and warm tiers for all active entities.
///
/// This is the single entry point for signal state management. `TidalDB`
/// holds a `SignalLedger` and delegates all signal operations to it.
pub struct SignalLedger {
    /// Per-(entity, `signal_type`) state. Sharded for concurrent access.
    pub(crate) entries: DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
    /// WAL writer for durability.
    wal: Box<dyn WalWriter>,
    /// Schema for signal type lookup and lambda retrieval.
    schema: Schema,
    /// Signal name -> `SignalTypeId` mapping (built at construction, immutable).
    signal_name_to_id: HashMap<String, SignalTypeId>,
    /// `SignalTypeId` -> lambda array (cached from schema, immutable after construction).
    signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
}

impl SignalLedger {
    /// Construct a new ledger from a validated schema and WAL writer.
    ///
    /// Signal types are enumerated in alphabetical order and assigned
    /// sequential `SignalTypeId` values (0, 1, 2, ...) via the shared
    /// [`derive_signal_ids_and_lambdas`] helper — the same derivation the cohort
    /// ledger uses, so the two can never drift in their id assignment.
    #[must_use]
    pub fn new(schema: Schema, wal: Box<dyn WalWriter>) -> Self {
        let (signal_name_to_id, signal_lambdas) = derive_signal_ids_and_lambdas(&schema);

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

    /// Record a signal event for an entity.
    ///
    /// Steps:
    /// 1. Resolve signal type name -> `SignalTypeId`
    /// 2. Append event to WAL (WAL-first)
    /// 3. Get or create `EntitySignalEntry` in `DashMap`
    /// 4. Update hot-tier decay score
    /// 5. Update warm-tier windowed counters
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    /// - `TidalError::Durability` if WAL write fails
    ///
    /// # Durability contract
    ///
    /// The WAL is written **before** in-memory state is updated. If
    /// `wal.append_signal()` returns `Err`, the in-memory state is NOT updated
    /// and the error is returned to the caller. Implementors of `WalWriter`
    /// MUST guarantee persistence before returning `Ok(())`.
    pub fn record_signal(
        &self,
        signal_type_name: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
    ) -> crate::Result<()> {
        let type_id = self.resolve_signal_type(signal_type_name)?;

        // WAL-first: durability before in-memory update.
        self.wal
            .append_signal(type_id, entity_id, weight, timestamp)?;

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

        let lambdas = self
            .signal_lambdas
            .get(&type_id)
            .map_or_else(<&[f64]>::default, Vec::as_slice);

        let ts_ns = timestamp.as_nanos();

        self.apply_event_local(entity_id, type_id, weight, ts_ns, lambdas);

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

        tracing::trace!(
            signal = signal_type_name,
            entity_id = entity_id.as_u64(),
            weight,
            "signal recorded"
        );

        Ok(())
    }

    /// Record a signal event carrying an explicit [`SignalScope`].
    ///
    /// The event is always durably logged to the WAL (with its v3 governance
    /// envelope), so it survives crashes and can be shipped to communities. The
    /// **local** in-memory aggregate is updated *only* for
    /// [`SignalScope::Local`] events — this is the load-bearing
    /// "local-profile-intact" invariant: a community/session/agent-scoped write
    /// never perturbs the user's own ranking state. Non-local aggregation is the
    /// job of the community ledger (M9p2+), which replays the same WAL events.
    ///
    /// `share_policy_version` and `membership_epoch` are stamped into the
    /// event's provenance; pass `0` for each when not community-governed.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    /// - `TidalError::Durability` if the WAL write fails
    // The governance envelope (scope + 3 wire fields) is passed flat to mirror
    // the WAL record layout rather than wrapping it in a struct here.
    #[allow(clippy::too_many_arguments)]
    pub fn record_signal_scoped(
        &self,
        signal_type_name: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        scope: SignalScope,
        share_policy_version: u16,
        membership_epoch: u32,
    ) -> crate::Result<()> {
        let type_id = self.resolve_signal_type(signal_type_name)?;

        // WAL-first: durability before any in-memory update. Always logged,
        // regardless of scope, so the event is recoverable and shippable.
        self.wal.append_signal_scoped(
            type_id,
            entity_id,
            weight,
            timestamp,
            scope.discriminant(),
            crate::governance::WRITER_DIRECT,
            share_policy_version,
            membership_epoch,
        )?;

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

        // Local-profile-intact: only local-scope events touch the local
        // aggregate. Non-local events are durably logged for shipping /
        // community aggregation but never perturb local ranking state.
        if matches!(scope, SignalScope::Local) {
            let lambdas = self
                .signal_lambdas
                .get(&type_id)
                .map_or_else(<&[f64]>::default, Vec::as_slice);
            self.apply_event_local(entity_id, type_id, weight, timestamp.as_nanos(), lambdas);
        }

        Ok(())
    }

    /// Read the current decay score for an entity-signal pair.
    ///
    /// Applies lazy decay from the stored timestamp to the current wall-clock time.
    ///
    /// Returns `Ok(None)` in two distinct cases:
    /// - no signals have been recorded for this entity-signal pair, or
    /// - `decay_rate_idx` is out of range for the signal's configured decay
    ///   rates (e.g. an `Exponential` signal registers exactly one lambda, so any
    ///   `decay_rate_idx >= 1` has no decay rate to evaluate).
    ///
    /// The second case is deliberately **not** a silent fallback to an undecayed
    /// score: without the range guard, an out-of-range index would resolve to
    /// lambda `0.0` and `current_score` would saturate the index, returning a
    /// raw, never-decaying number that looks plausible but answers a question the
    /// schema cannot. Returning `None` lets the caller distinguish "no such decay
    /// rate" from a real score; existing callers (`unwrap_or(0.0)`) already treat
    /// `None` as "no contribution," which is the correct degradation here.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_decay_score(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        decay_rate_idx: usize,
    ) -> crate::Result<Option<f64>> {
        // Read the wall clock once and delegate to the explicit-clock variant.
        let now_ns = Timestamp::now().as_nanos();
        self.read_decay_score_at(entity_id, signal_type_name, decay_rate_idx, now_ns)
    }

    /// Read the current decay score against an explicit query clock.
    ///
    /// Identical to [`read_decay_score`](Self::read_decay_score) but takes the
    /// "now" timestamp from the caller instead of reading `Timestamp::now()`
    /// internally. The ranking per-candidate loop can read the clock *once* at
    /// the top of a query and call this for every candidate, giving a consistent
    /// query clock across the result set and eliding one `clock_gettime` syscall
    /// per candidate (the existing wrapper still reads it per call for callers
    /// that do not yet thread a clock).
    ///
    /// `now_ns` is the nanosecond timestamp to decay the stored score forward to.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_decay_score_at(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        decay_rate_idx: usize,
        now_ns: u64,
    ) -> crate::Result<Option<f64>> {
        let type_id = self.resolve_signal_type(signal_type_name)?;

        // Range-check the decay rate against the signal's configured lambdas. A
        // missing entry means "no such decay rate" — surface it as `None` rather
        // than defaulting lambda to 0.0 and returning an undecayed score.
        let Some(&lambda) = self
            .signal_lambdas
            .get(&type_id)
            .and_then(|v| v.get(decay_rate_idx))
        else {
            return Ok(None);
        };

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

    /// Read the windowed event count for an entity-signal pair.
    ///
    /// Returns 0 if no signals have been recorded for this entity-signal pair.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_windowed_count(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
    ) -> crate::Result<u64> {
        // Read the clock once (matching `read_decay_score`) and delegate to the
        // explicit-clock variant. The clock must be threaded into the warm tier
        // so expired buckets rotate out at read time — without it a quiet entity
        // reports its peak windowed/velocity count forever (CRITICAL #6,
        // CODING_GUIDELINES §8).
        let now_ns = Timestamp::now().as_nanos();
        self.read_windowed_count_at(entity_id, signal_type_name, window, now_ns)
    }

    /// Read the windowed event count against an explicit query clock.
    ///
    /// Identical to [`read_windowed_count`](Self::read_windowed_count) but takes
    /// the "now" timestamp from the caller instead of reading
    /// `Timestamp::now()` internally, so a per-candidate ranking loop can read
    /// the clock once and reuse it (consistent query clock, one fewer syscall
    /// per candidate). `now_ns` rotates expired buckets out at read time.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_windowed_count_at(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
        now_ns: u64,
    ) -> crate::Result<u64> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        match self.entries.get(&(entity_id, type_id)) {
            None => Ok(0),
            Some(entry) => Ok(entry.warm.windowed_count(window, now_ns)),
        }
    }

    /// Read the velocity (events per second) for an entity-signal-window.
    ///
    /// Velocity = `windowed_count / window_duration_seconds`.
    /// `AllTime` returns 0.0 (velocity is undefined for unbounded windows).
    /// Returns 0.0 if no signals have been recorded for this entity-signal pair.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_velocity(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
    ) -> crate::Result<f64> {
        // Read the clock once and delegate to the explicit-clock variant, so the
        // expired-bucket rotation behind `read_windowed_count` sees a single
        // consistent "now" (matching `read_windowed_count` / `read_decay_score`).
        let now_ns = Timestamp::now().as_nanos();
        self.read_velocity_at(entity_id, signal_type_name, window, now_ns)
    }

    /// Read the velocity (events per second) against an explicit query clock.
    ///
    /// Identical to [`read_velocity`](Self::read_velocity) but takes the "now"
    /// timestamp from the caller instead of reading `Timestamp::now()`
    /// internally. The ranking per-candidate loop reads the clock once at the top
    /// of a query and reuses it for every candidate, so the whole result set is
    /// aged to one consistent query clock (reproducible scores) and one
    /// `clock_gettime` syscall is elided per candidate.
    ///
    /// Velocity = `windowed_count(now_ns) / window_duration_seconds`.
    /// `AllTime` returns 0.0 (velocity is undefined for unbounded windows).
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if `signal_type_name` is not defined
    pub fn read_velocity_at(
        &self,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
        now_ns: u64,
    ) -> crate::Result<f64> {
        let duration_secs = window.duration_secs_f64();
        if duration_secs.is_infinite() {
            // AllTime window -- velocity is undefined. Skip the count read.
            return Ok(0.0);
        }
        let count = self.read_windowed_count_at(entity_id, signal_type_name, window, now_ns)?;
        #[allow(clippy::cast_precision_loss)]
        Ok(count as f64 / duration_secs)
    }

    /// Resolve a signal type name to its `SignalTypeId`.
    ///
    /// # Errors
    ///
    /// - `TidalError::Schema` if the name is not defined in this ledger's 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())))
    }

    /// Get the existing `(entity_id, type_id)` entry or create a zeroed one
    /// started at `ts_ns`.
    ///
    /// This is the single construction site for an `EntitySignalEntry`: every
    /// path that needs an entry (incremental updates *and* CRDT force-sets) funnels
    /// through here so the hot/warm pairing can never drift across call sites.
    fn get_or_create_entry(
        &self,
        entity_id: EntityId,
        type_id: SignalTypeId,
        ts_ns: u64,
    ) -> dashmap::mapref::one::RefMut<'_, (EntityId, SignalTypeId), EntitySignalEntry> {
        self.entries
            .entry((entity_id, type_id))
            .or_insert_with(|| EntitySignalEntry {
                hot: HotSignalState::new(entity_id.as_u64(), type_id.as_u16()),
                warm: BucketedCounter::with_start_time(ts_ns),
            })
    }

    /// Apply an in-order signal event to the local aggregate: get-or-create the
    /// entry, fold the event into the hot decay score and the warm windowed
    /// counters, then release the shard lock.
    ///
    /// This is the shared core of every incremental-update path
    /// ([`record_signal`](Self::record_signal),
    /// [`record_signal_scoped`](Self::record_signal_scoped)'s local branch,
    /// [`apply_wal_event`](Self::apply_wal_event), and
    /// [`apply_replicated_event`](Self::apply_replicated_event)). It does **not**
    /// touch the WAL — durability is each caller's responsibility *before*
    /// invoking this — so it is purely the in-memory aggregate mutation.
    fn apply_event_local(
        &self,
        entity_id: EntityId,
        type_id: SignalTypeId,
        weight: f64,
        ts_ns: u64,
        lambdas: &[f64],
    ) {
        let entry = self.get_or_create_entry(entity_id, type_id, ts_ns);
        entry.hot.on_signal(weight, ts_ns, lambdas);
        entry.warm.increment(ts_ns);
        // Explicitly drop the DashMap entry ref to release the shard lock before
        // returning (satisfies clippy::significant_drop_tightening).
        drop(entry);
    }

    /// Apply a WAL event directly to in-memory state, bypassing the WAL write.
    ///
    /// Used during WAL replay on startup. The event has already been durably
    /// stored on disk; we only need to rebuild in-memory state from it.
    ///
    /// Unlike `record_signal`, this method:
    /// - Does **not** call `wal.append_signal()`.
    /// - Accepts a `SignalTypeId` directly (already resolved from the WAL record).
    /// - Silently ignores unknown `signal_type_id` values (may occur when the
    ///   schema changes between restarts; missing lambdas default to 0.0).
    pub(crate) fn apply_wal_event(
        &self,
        signal_type_id: SignalTypeId,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
    ) {
        let lambdas = self
            .signal_lambdas
            .get(&signal_type_id)
            .map_or_else(<&[f64]>::default, Vec::as_slice);

        self.apply_event_local(
            entity_id,
            signal_type_id,
            weight,
            timestamp.as_nanos(),
            lambdas,
        );
    }

    /// Apply a replicated WAL event on a follower, **WAL-first**.
    ///
    /// Unlike [`apply_wal_event`](Self::apply_wal_event) (which bypasses the WAL
    /// because the event is *already* durable on disk during local replay), a
    /// replicated event arriving over the network has NO durable backing on this
    /// follower yet. This method therefore appends the event to the follower's
    /// **own** WAL before mutating in-memory state, so the follower's normal
    /// WAL replay on open reconstructs the applied aggregate exactly like any
    /// locally-written signal. This is the load-bearing half of BLOCKER 6's fix:
    /// without it, every replicated event between the last checkpoint and a
    /// follower crash is silently lost.
    ///
    /// The WAL append assigns a *follower-local* sequence number (the follower
    /// owns its own seqno space); the leader's seqno is tracked separately by
    /// [`ReplicationState`](crate::replication::state::ReplicationState) for
    /// per-segment idempotency. Idempotency against re-shipped segments is the
    /// caller's responsibility (the receiver skips batches at or below the
    /// persisted high-water-mark *before* calling this), so this method must
    /// only ever be invoked for not-yet-applied events.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Durability` if the follower's WAL append fails. The
    /// in-memory aggregate is **not** mutated on error, so the follower never
    /// acknowledges (advances its high-water-mark for) an event it failed to
    /// durably record.
    pub(crate) fn apply_replicated_event(
        &self,
        signal_type_id: SignalTypeId,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
    ) -> crate::Result<()> {
        // WAL-first: durability on THIS follower before any in-memory update.
        self.wal
            .append_signal(signal_type_id, entity_id, weight, timestamp)?;

        let lambdas = self
            .signal_lambdas
            .get(&signal_type_id)
            .map_or_else(<&[f64]>::default, Vec::as_slice);

        self.apply_event_local(
            entity_id,
            signal_type_id,
            weight,
            timestamp.as_nanos(),
            lambdas,
        );

        Ok(())
    }

    /// Apply a merged CRDT signal state to the ledger entry.
    ///
    /// Called by `ReconciliationEngine::apply()` after a merge plan is computed.
    /// Computes the combined decay score at the current time and force-sets it
    /// on the hot tier, then reconciles the warm tier's windowed counts to the
    /// merged total event count — both bypass the normal WAL-first path.
    ///
    /// This is intentionally NOT WAL-backed: reconciliation replaces diverged
    /// in-memory state from CRDT-merged snapshots. The WAL remains the
    /// authoritative event log; reconciliation is a state-level correction
    /// that aligns the hot AND warm tiers with the merged truth.
    ///
    /// # Hot/warm consistency
    ///
    /// Reconciling only the hot decay score would leave the warm windowed counts
    /// diverged, so a post-reconciliation `read_decay_score` (merged) and
    /// `read_windowed_count` (still local-only) would disagree about the same
    /// signal. We therefore also fold the CRDT's merged total event count
    /// (`CrdtSignalState::total_count`) into the warm tier via
    /// `BucketedCounter::reconcile_to_count`, so both tiers reflect the same
    /// merged truth as of `now_ns`.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Internal` if the signal type has no lambda entry
    /// in this ledger (unknown signal type).
    pub(crate) fn apply_crdt_state(
        &self,
        entity_id: EntityId,
        signal_type_id: SignalTypeId,
        state: &crate::replication::crdt::CrdtSignalState,
    ) -> crate::Result<()> {
        let now_ns = Timestamp::now().as_nanos();

        // Verify the signal type is known. This also gives us the lambda
        // array so we know how many decay rate slots to fill.
        let lambdas = self.signal_lambdas.get(&signal_type_id).ok_or_else(|| {
            TidalError::internal(
                "apply_crdt_state",
                format!("unknown signal type id: {}", signal_type_id.as_u16()),
            )
        })?;

        // Compute the merged decay score at the current time.
        let score = state.decay_score(now_ns);

        // Get or create the entry via the shared constructor. The *update* below
        // is force-set (not the incremental on_signal/increment of
        // `apply_event_local`), so only the construction is shared.
        let entry = self.get_or_create_entry(entity_id, signal_type_id, now_ns);

        // Force-set the score for each lambda index. CrdtSignalState uses a
        // single lambda per signal type, so all indices receive the same merged
        // score (each index corresponds to a different decay rate for the same
        // signal, but the CRDT only tracks the primary rate).
        for i in 0..lambdas.len().min(crate::signals::hot::MAX_DECAY_RATES) {
            entry.hot.force_set_score(i, score);
        }
        // If no lambdas are configured (permanent signal), set index 0 as fallback.
        if lambdas.is_empty() {
            entry.hot.force_set_score(0, score);
        }
        entry.hot.force_set_last_update_ns(now_ns);

        // Reconcile the warm tier too, so windowed counts agree with the merged
        // hot score. The CRDT's merged event total is authoritative; folding it
        // in keeps hot and warm from diverging after reconciliation.
        entry.warm.reconcile_to_count(state.total_count(), now_ns);

        drop(entry);
        Ok(())
    }

    /// Reference to the `DashMap` for checkpoint iteration and benchmarking.
    #[must_use]
    pub const fn entries(&self) -> &DashMap<(EntityId, SignalTypeId), EntitySignalEntry> {
        &self.entries
    }

    /// Snapshot every live `(entity, signal_type)` entry as a per-node CRDT
    /// contribution for reconciliation.
    ///
    /// For each entry this materializes a [`CrdtSignalState`] via
    /// [`from_node_contribution`](crate::replication::crdt::CrdtSignalState::from_node_contribution),
    /// carrying:
    /// - the entry's **stored decay accumulator as of its `last_update_ns`**
    ///   (decay-rate index 0, the primary rate) — NOT decayed forward to
    ///   `now_ns`, because the merge/reconcile path applies the one decay from
    ///   `last_update_ns` to query time and a pre-decayed score would
    ///   double-decay,
    /// - the entry's `last_update_ns` (the timestamp the stored score is paired
    ///   with),
    /// - the entry's **live `AllTime` windowed count** as the per-node
    ///   `bucket_count` (so the windowed total round-trips through merge instead
    ///   of silently dropping to 0 — the gap the `bucket_count` parameter was
    ///   added to close), and
    /// - the signal type's primary lambda.
    ///
    /// All contributions are attributed to `node` (this node's shard). The
    /// result is the local half of a [`StateSnapshot`](crate::replication::reconcile::StateSnapshot)
    /// the reconciliation engine merges against a remote snapshot after a
    /// partition heals.
    ///
    /// Entries whose signal type has no configured lambda (a permanent signal)
    /// contribute with `lambda = 0.0`, which decays nothing — the score is
    /// carried verbatim.
    #[must_use]
    pub(crate) fn crdt_contributions(
        &self,
        node: crate::replication::shard::ShardId,
    ) -> Vec<(
        EntityId,
        SignalTypeId,
        crate::replication::crdt::CrdtSignalState,
    )> {
        let mut out = Vec::with_capacity(self.entries.len());
        for entry in &self.entries {
            let (entity_id, type_id) = *entry.key();
            let lambda = self
                .signal_lambdas
                .get(&type_id)
                .and_then(|v| v.first().copied())
                .unwrap_or(0.0);
            // Pass the STORED accumulator as of `last_update_ns`, NOT a value
            // already decayed forward to `now_ns`. `from_node_contribution`
            // stores `score` against `last_update_ns`, and the reconcile path
            // (`apply_crdt_state` -> `CrdtSignalState::decay_score`) applies the
            // one and only decay from `last_update_ns` to query time. Passing
            // `current_score(0, now_ns, ..)` here would decay the
            // `[last_update_ns, now_ns]` interval twice (CRITICAL fix).
            let score = entry.value().hot.stored_score(0);
            let last_update_ns = entry.value().hot.last_update_ns();
            let bucket_count = entry.value().warm.all_time_count();
            let state = crate::replication::crdt::CrdtSignalState::from_node_contribution(
                node,
                score,
                last_update_ns,
                bucket_count,
                lambda,
            );
            out.push((entity_id, type_id, state));
        }
        out
    }

    /// The schema this ledger was constructed with.
    #[must_use]
    pub const fn schema(&self) -> &Schema {
        &self.schema
    }
}

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

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::cast_precision_loss)]
#[path = "core_tests.rs"]
mod tests;
