//! Signal write/read operations on `TidalDb`.

use std::collections::HashMap;

use super::{
    TidalDb,
    metadata::{
        UserStateSignal, classify_user_state_signal, is_positive_engagement_signal,
        serialize_metadata,
    },
};
use crate::{
    entities::{HardNegIndex, RelationshipType},
    governance::SignalScope,
    schema::{EntityId, EntityKind, TidalError, Timestamp, Window},
};

impl TidalDb {
    /// Write (or overwrite) item metadata.
    ///
    /// Routes through [`write_item_with_metadata`](Self::write_item_with_metadata)
    /// so it applies the SAME metadata size validation and in-memory index
    /// population (bitmap/range/universe/creator-items, text, suggestions) as the
    /// primary write path. A previous direct-to-storage `write_item` bypassed
    /// both: items written through it were durable but invisible to RETRIEVE and
    /// uncapped in metadata size — a latent inconsistency between two "write an
    /// item" entry points. There is now only one indexing path.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired (use `with_schema()`).
    /// - `TidalError::invalid_input` if the metadata exceeds the size limits.
    /// - `TidalError::Storage` on storage engine failure.
    pub fn write_item(
        &self,
        id: EntityId,
        metadata: &HashMap<String, String>,
    ) -> crate::Result<()> {
        self.write_item_with_metadata(id, metadata)
    }

    /// Persist item metadata directly to the items storage backend with no
    /// validation or index population.
    ///
    /// Internal-only: this is the raw durable-write step that
    /// [`write_item_with_metadata`](Self::write_item_with_metadata) calls AFTER
    /// validating sizes and materializing defaults. No public path may bypass the
    /// indexing wrapper via this method.
    pub(super) fn persist_item_metadata(
        &self,
        id: EntityId,
        metadata: &HashMap<String, String>,
    ) -> crate::Result<()> {
        use crate::storage::{Tag, encode_key};
        self.require_writeable("write_item")?;

        let storage = self.storage()?;
        let key = encode_key(id, Tag::Meta, b"");
        let value = serialize_metadata(metadata);
        storage
            .items_engine()
            .put(&key, &value)
            .map_err(TidalError::from)
    }

    /// Shared weight-validation guard for the signal-write entry points.
    ///
    /// Both [`signal`](Self::signal) and [`signal_scoped`](Self::signal_scoped)
    /// run this identical preamble, so it lives in one place — like
    /// [`check_write_backpressure`](Self::check_write_backpressure) — to keep the
    /// two admission policies from drifting apart.
    ///
    /// Rejects the write with [`TidalError::InvalidInput`] when the weight is:
    /// - **non-finite** (`NaN` / `±Inf`): would corrupt every decay-score CAS, or
    /// - **finite negative**: spec §8 says "negative weights are rejected
    ///   (negative signals use separate signal types, not negative weights)". A
    ///   negative weight folds into the decay accumulator and can drive the hot
    ///   decay score below zero, violating the INV-SIG-3 "decay scores are
    ///   non-negative" invariant (and tripping the `debug_assert!` in
    ///   [`HotSignalState::on_signal`](crate::signals::HotSignalState::on_signal)).
    ///
    /// A weight of exactly `0.0` is permitted: it is a no-op fold that records the
    /// event in the warm-tier count without perturbing the decay score.
    pub(crate) fn validate_signal_weight(weight: f64) -> crate::Result<()> {
        if !weight.is_finite() {
            return Err(TidalError::invalid_input(
                "signal weight must be finite (NaN and Inf are not allowed)",
            ));
        }
        if weight < 0.0 {
            return Err(TidalError::invalid_input(
                "signal weight must be non-negative; negative engagement uses \
                 separate signal types (e.g. \"dislike\"), not negative weights \
                 (spec §8)",
            ));
        }
        Ok(())
    }

    /// Shared write-admission guard for the signal-write entry points.
    ///
    /// Rejects the write with [`TidalError::Backpressure`] when either:
    /// - a backup is in progress (the data directory is being copied; Acquire
    ///   ordering pairs with `create_backup`'s Release store), or
    /// - the WAL channel depth has reached `queue_depth_threshold`.
    ///
    /// Both [`signal`](Self::signal) and [`signal_scoped`](Self::signal_scoped)
    /// run this identical preamble, so it lives in one place to keep the two
    /// admission policies from drifting apart.
    fn check_write_backpressure(&self) -> crate::Result<()> {
        if self
            .backup_in_progress
            .load(std::sync::atomic::Ordering::Acquire)
        {
            return Err(TidalError::Backpressure {
                retry_after_ms: 500,
            });
        }

        // O(1): crossbeam::channel::bounded::len() is atomic.
        if let Ok(guard) = self.wal.lock()
            && let Some(wal) = guard.as_ref()
        {
            let queue_depth = wal.channel_len();
            if queue_depth >= self.backpressure_config.queue_depth_threshold {
                tracing::warn!(
                    queue_depth,
                    threshold = self.backpressure_config.queue_depth_threshold,
                    "WAL backpressure: rejecting signal write"
                );
                return Err(TidalError::Backpressure {
                    retry_after_ms: self.backpressure_config.retry_after_ms,
                });
            }
        }

        Ok(())
    }

    /// Record a signal event for an entity.
    ///
    /// Atomically:
    /// 1. Appends the event to the WAL (WAL-first durability).
    /// 2. Updates the in-memory decay score (hot tier).
    /// 3. Updates the in-memory windowed counter (warm tier).
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired (use `with_schema()`).
    /// - `TidalError::InvalidInput` if `weight` is non-finite (NaN/Inf) or
    ///   finite-negative (negative engagement uses separate signal types).
    /// - `TidalError::Schema` if `signal_type` is not defined in the schema.
    /// - `TidalError::Durability` if the WAL write fails.
    /// - `TidalError::Backpressure` if the WAL queue is saturated or a backup is in progress.
    #[tracing::instrument(skip_all, fields(signal_type, entity_id = entity_id.as_u64(), weight))]
    pub fn signal(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
    ) -> crate::Result<()> {
        self.require_writeable("signal")?;
        Self::validate_signal_weight(weight)?;

        // Reject the write if a backup is in progress or the WAL queue is
        // saturated, before doing any work or starting the latency timer.
        self.check_write_backpressure()?;

        #[cfg(feature = "metrics")]
        let write_start = std::time::Instant::now();

        let result = self
            .ledger()?
            .record_signal(signal_type, entity_id, weight, timestamp);

        #[cfg(feature = "metrics")]
        {
            use std::sync::atomic::Ordering;
            // `as_micros()` is u128; narrow to the histogram's u64 microsecond
            // unit. A u64 microsecond count holds ~584,000 years, so this is
            // saturating-in-practice and the truncation is acceptable on this
            // signal-write hot path (mirrors the query-latency note).
            let elapsed_us = write_start.elapsed().as_micros() as u64;
            self.metrics
                .signal_writes_total
                .fetch_add(1, Ordering::Relaxed);
            self.metrics.signal_write_latency.observe(elapsed_us);
        }

        result
    }

    /// Record a signal event with an explicit [`SignalScope`].
    ///
    /// [`signal`](Self::signal) writes [`SignalScope::Local`]; this is the
    /// opt-in path for community/session/agent-scoped writes. The event is
    /// always durably logged with its v3 governance envelope, but only
    /// local-scope events affect the local profile — community-scoped events are
    /// logged for replication and community aggregation without perturbing local
    /// ranking state (the "local profile remains intact" invariant).
    ///
    /// `share_policy_version` is recorded in the event's provenance; pass `0`
    /// when no share policy governs the write.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired (use `with_schema()`).
    /// - `TidalError::InvalidInput` if `weight` is non-finite (NaN/Inf) or
    ///   finite-negative (negative engagement uses separate signal types).
    /// - `TidalError::Schema` if `signal_type` is not defined in the schema.
    /// - `TidalError::Durability` if the WAL write fails.
    /// - `TidalError::Backpressure` if the WAL queue is saturated or a backup is in progress.
    pub fn signal_scoped(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        scope: SignalScope,
        share_policy_version: u16,
    ) -> crate::Result<()> {
        self.require_writeable("signal_scoped")?;
        Self::validate_signal_weight(weight)?;

        self.check_write_backpressure()?;

        self.ledger()?.record_signal_scoped(
            signal_type,
            entity_id,
            weight,
            timestamp,
            scope,
            share_policy_version,
            0, // membership_epoch: community-membership writes go via signal_for_community
        )
    }

    /// Read the current decay score for an entity-signal pair.
    ///
    /// Applies lazy decay from the stored timestamp to the current wall-clock
    /// time. Returns `None` if no signals have been recorded.
    ///
    /// `decay_rate_idx` selects the lambda index from the signal definition.
    /// For exponential signals with one rate, use `0`.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired.
    /// - `TidalError::Schema` if `signal_type` is not defined.
    pub fn read_decay_score(
        &self,
        entity_id: EntityId,
        signal_type: &str,
        decay_rate_idx: usize,
    ) -> crate::Result<Option<f64>> {
        self.ledger()?
            .read_decay_score(entity_id, signal_type, decay_rate_idx)
    }

    /// Read the windowed event count for an entity-signal pair.
    ///
    /// Returns `0` if no signals have been recorded.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired.
    /// - `TidalError::Schema` if `signal_type` is not defined.
    pub fn read_windowed_count(
        &self,
        entity_id: EntityId,
        signal_type: &str,
        window: Window,
    ) -> crate::Result<u64> {
        self.ledger()?
            .read_windowed_count(entity_id, signal_type, window)
    }

    /// Read the velocity (events per second) for an entity-signal-window.
    ///
    /// Velocity = `windowed_count / window_duration_seconds`.
    /// Returns `0.0` for `AllTime` windows or if no signals recorded.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no ledger is wired.
    /// - `TidalError::Schema` if `signal_type` is not defined.
    pub fn read_velocity(
        &self,
        entity_id: EntityId,
        signal_type: &str,
        window: Window,
    ) -> crate::Result<f64> {
        self.ledger()?.read_velocity(entity_id, signal_type, window)
    }

    /// Records a signal with user context, updating the interaction ledger, seen state,
    /// preference vectors, and cohort signal state in-memory.
    ///
    /// In addition to updating the signal ledger, this method:
    /// 1. Hard negatives: if the signal is skip/hide/dislike/block, records
    ///    a hard negative for the (user, item) pair.
    /// 2. Interaction weight: if `for_user` is provided, updates the
    ///    (user, creator) interaction weight.
    /// 3. Seen tracking: if `for_user` is provided, marks the item as seen.
    /// 4. Preference vector: for positive engagement signals (like, share,
    ///    completion), looks up the item's embedding from durable storage and
    ///    blends it into the user's preference vector via EMA.
    /// 5. Cohort attribution (M6): resolves the user's cohort memberships
    ///    and records the signal into each matching cohort's signal state.
    ///
    /// # Preference vector updates
    ///
    /// The update triggers when all three conditions are met:
    /// - The signal type is a positive engagement signal ("like", "share", "completion").
    /// - `for_user` is `Some` (the acting user is known).
    /// - The item has a stored embedding in the entity store (written via
    ///   `insert_embedding` or `update_embedding` during item ingestion).
    ///
    /// The embedding is read from the first Item embedding slot declared in the
    /// schema. If no schema or no embedding slot is declared, falls back to the
    /// slot name "content". If the lookup fails (no embedding stored, storage
    /// error), the preference update is silently skipped -- the base signal is
    /// still recorded.
    ///
    /// # Durability
    ///
    /// The base signal (entity, type, weight, timestamp) is WAL-backed and survives crashes.
    /// The per-user side effects do NOT live in the WAL (the signal record carries no
    /// `for_user` identity), so each one is made crash-recoverable on its own path:
    ///
    /// - **Hard negatives** (skip/hide/dislike/block) are written write-through as durable
    ///   `Tag::HardNeg` rows (zero loss window) and restored on open via
    ///   [`HardNegIndex::restore`](crate::entities::HardNegIndex::restore).
    /// - **Interaction weights** are persisted write-through as a merged
    ///   `RelationshipType::InteractionWeight` edge carrying the decayed running score and
    ///   last-update timestamp, so `rebuild_entity_state` reproduces the ledger on open.
    /// - **Completion / save / like** state is written write-through as durable
    ///   `Tag::UserState` rows and restored on open, so the `InProgress` filter and
    ///   `DateSaved` sort survive a crash.
    /// - **Preference vectors** are periodically checkpointed (bounded post-crash loss
    ///   window) and restored on open.
    /// - **Co-engagement edges** (positive-engagement signals) are periodically
    ///   checkpointed (bounded post-crash loss window) and restored on open via
    ///   [`CoEngagementIndex::restore`](crate::entities::CoEngagementIndex::restore).
    /// - **Cohort signal state** is periodically checkpointed and restored on open;
    ///   on a corrupt checkpoint the ledger starts empty and re-accumulates from new
    ///   signals.
    ///
    /// Seen state from regular views/likes is intentionally ephemeral -- users should see
    /// content again after a restart. Only explicit hides (via `write_relationship` with
    /// `RelationshipType::Hide`) survive restarts as "seen". The **per-user signal index**
    /// (social-graph trending) is likewise intentionally ephemeral and not persisted:
    /// trending re-stabilizes from new signals after a restart.
    ///
    /// # Errors
    ///
    /// - `TidalError::InvalidInput` if `for_user` is set and `entity_id` exceeds
    ///   the `u32` item-universe limit (it would alias a lower id in the durable
    ///   per-user rows).
    /// - Any error from the underlying [`signal`](Self::signal) call (invalid
    ///   weight, missing ledger, unknown signal type, WAL durability/backpressure).
    pub fn signal_with_context(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        for_user: Option<u64>,
        creator_id: Option<u64>,
    ) -> crate::Result<()> {
        self.require_writeable("signal_with_context")?;

        // When a `for_user` identity is present this call narrows the item id to
        // its u32 slot and writes it into DURABLE Tag::HardNeg / Tag::UserState
        // rows. A bare `as u32` truncation would silently alias two items whose
        // ids share their low 32 bits — a permanent cross-id collision in the
        // hard-negative / seen / saved / liked correctness primitives that
        // survives restart. Reject an over-range id up front (mirroring
        // `write_item_with_metadata`) so a colliding durable row never lands on
        // disk. Done before the base `signal()` so a rejected write leaves no
        // trace at all.
        if for_user.is_some() && entity_id.as_u64() > u64::from(u32::MAX) {
            let raw = entity_id.as_u64();
            return Err(TidalError::invalid_input(format!(
                "item entity id {raw} exceeds the u32 item-universe limit ({}); \
                 allocate dense item IDs within the u32 range",
                u32::MAX
            )));
        }

        // Record the base signal.
        self.signal(signal_type, entity_id, weight, timestamp)?;

        // Signal dispatch: side effects based on signal type and context.
        if let Some(user_id) = for_user {
            // Lossless: the over-range guard above already rejected any id past
            // u32::MAX. Route through the observable helper anyway so this path
            // shares the single narrowing seam with the Hide relationship path
            // and items.rs (a future relaxation of the guard stays observable).
            let item_u32 = crate::entities::user_state::narrow_item_slot(entity_id.as_u64());
            // Durable-write target for the write-through side effects below.
            // `None` in ephemeral mode (no persistence; in-memory state is the
            // whole story and there is nothing to crash-recover).
            let storage = self.storage.as_ref();

            // 1. Hard negatives. Write-through: a durable Tag::HardNeg row is
            //    persisted with the SAME durability class as the base signal so a
            //    skip/dislike survives a hard crash with zero loss window, then
            //    restored into the in-memory bitmap on open.
            if HardNegIndex::is_hard_neg_signal(signal_type) {
                self.hard_negatives.add(user_id, item_u32);
                if let Some(sb) = storage
                    && let Err(e) =
                        self.hard_negatives
                            .persist(sb.items_engine(), user_id, item_u32)
                {
                    tracing::error!(
                        user_id,
                        item = item_u32,
                        error = %e,
                        "failed to persist hard-negative durable row; \
                         it would not survive a crash"
                    );
                }
            }

            // 2. Seen tracking.
            self.user_state.mark_seen(user_id, item_u32);

            // 3. Interaction weight: if creator is known, update the
            //    (user, creator) interaction strength, then persist the decayed
            //    running score + last-update as a merged InteractionWeight edge so
            //    `rebuild_entity_state` reproduces the ledger on open.
            if let Some(cid) = creator_id {
                self.interaction_ledger
                    .record(user_id, cid, weight, timestamp.as_nanos());
                self.persist_interaction_edge(user_id, cid);
            }

            // 3b. Completion / save / like: drive the UserStateIndex maps the
            //     InProgress filter and DateSaved sort read. Write-through so they
            //     survive a crash. Signals do not carry a completion ratio, so the
            //     signal weight IS the completion ratio (documented choice).
            if let Some(kind) = classify_user_state_signal(signal_type) {
                self.persist_user_state_signal(
                    kind, user_id, entity_id, item_u32, weight, timestamp,
                );
            }

            // 4. Preference vector + co-engagement: for positive engagement signals,
            //    look up the item's embedding and blend into the user's taste vector,
            //    and record co-occurrence with user's recent positively-engaged items.
            if self.is_positive_engagement(signal_type) {
                self.try_update_preference_vector(user_id, entity_id);
                self.co_engagement.record_positive(user_id, entity_id);
            }

            // 5. Per-user signal tracking for social-graph-scoped trending.
            if let Ok(ledger) = self.ledger()
                && let Ok(type_id) = ledger.resolve_signal_type(signal_type)
            {
                self.user_signal_index
                    .record(user_id, entity_id, type_id, timestamp.as_nanos());
            }

            // 6. Cohort attribution (M6): read user metadata, resolve cohorts,
            //    record signal into each matching cohort's ledger.
            self.try_cohort_attribution(signal_type, entity_id, weight, timestamp, user_id);
        }

        Ok(())
    }

    /// Persist the current `(user, creator)` interaction weight as a durable
    /// `RelationshipType::InteractionWeight` edge so `rebuild_entity_state`
    /// reproduces the ledger on open.
    ///
    /// Reads back the raw running score + last-update timestamp (post-`record`)
    /// and overwrites the edge with those exact values. Because the rebuild path
    /// replays a stored edge through `InteractionLedger::record` into a fresh
    /// ledger — folding the persisted score as one in-order event at
    /// `last_update_ns` — the rebuilt entry equals this one within decay
    /// tolerance. Best-effort: a storage error is logged, not propagated, so the
    /// base signal still succeeds.
    fn persist_interaction_edge(&self, user_id: u64, creator_id: u64) {
        use crate::entities::relationship::{
            encode_relationship_key, serialize_relationship_value,
        };
        let Some((score, last_update_ns)) = self.interaction_ledger.raw_entry(user_id, creator_id)
        else {
            return;
        };
        let Some(storage) = self.storage.as_ref() else {
            return;
        };
        let key = encode_relationship_key(
            EntityId::new(user_id),
            RelationshipType::InteractionWeight,
            EntityId::new(creator_id),
        );
        let value = serialize_relationship_value(score, Timestamp::from_nanos(last_update_ns));
        if let Err(e) = storage.users_engine().put(&key, &value) {
            tracing::error!(
                user_id,
                creator_id,
                error = %e,
                "failed to persist interaction-weight edge; \
                 it would reset to zero on restart"
            );
        }
    }

    /// Drive (and durably persist) the `UserStateIndex` map for a
    /// completion / save / like signal.
    ///
    /// The signal weight is used as the completion ratio (signals carry no
    /// explicit ratio). Write-through so the `InProgress` filter / `DateSaved`
    /// sort survive a crash; best-effort persistence (logged, not propagated).
    fn persist_user_state_signal(
        &self,
        kind: UserStateSignal,
        user_id: u64,
        entity_id: EntityId,
        item_u32: u32,
        weight: f64,
        timestamp: Timestamp,
    ) {
        let Some(storage) = self.storage.as_ref() else {
            // Ephemeral mode: update in-memory state only (nothing to recover).
            match kind {
                UserStateSignal::Completion => {
                    self.user_state
                        .record_completion(user_id, entity_id.as_u64(), weight);
                }
                UserStateSignal::Save => {
                    self.user_state
                        .add_save_timestamped(user_id, item_u32, timestamp.as_nanos());
                }
                UserStateSignal::Like => self.user_state.add_like(user_id, item_u32),
            }
            return;
        };
        let engine = storage.items_engine();
        let result = match kind {
            UserStateSignal::Completion => self.user_state.record_completion_durable(
                engine,
                user_id,
                entity_id.as_u64(),
                weight,
            ),
            UserStateSignal::Save => {
                self.user_state
                    .add_save_durable(engine, user_id, item_u32, timestamp.as_nanos())
            }
            UserStateSignal::Like => self.user_state.add_like_durable(engine, user_id, item_u32),
        };
        if let Err(e) = result {
            tracing::error!(
                user_id,
                item = item_u32,
                error = %e,
                "failed to persist user-state durable row; \
                 InProgress/DateSaved would not survive a crash"
            );
        }
    }

    /// Best-effort cohort attribution: resolves user cohorts and records the signal
    /// into each matching cohort's signal ledger.
    ///
    /// Failures are silently ignored -- cohort attribution must not fail the
    /// primary signal write path.
    fn try_cohort_attribution(
        &self,
        signal_type: &str,
        entity_id: EntityId,
        weight: f64,
        timestamp: Timestamp,
        user_id: u64,
    ) {
        // Skip if no cohorts are defined.
        if self.cohort_registry.is_empty() {
            return;
        }

        // Resolve signal type to ID. If unknown, skip (should not happen if
        // the base signal succeeded, but defensive).
        let Ok(type_id) = self.cohort_ledger.resolve_signal_type(signal_type) else {
            return;
        };

        // Read user metadata from users_engine.
        let Ok(Some(metadata)) = self.get_user_metadata(EntityId::new(user_id)) else {
            return; // No metadata or storage error -- skip cohort attribution.
        };

        // Resolve cohort memberships.
        let cohorts = self.cohort_resolver.resolve(user_id, &metadata);

        // Record the signal into each matching cohort's ledger.
        let ts_ns = timestamp.as_nanos();
        for cohort_name in &cohorts {
            self.cohort_ledger
                .record(cohort_name, entity_id, type_id, weight, ts_ns);
        }
    }

    /// Whether `signal_type` is a positive-engagement signal — one that should
    /// fold the signalled item's embedding into the acting user's preference
    /// vector.
    ///
    /// Schema-driven with a back-compat fallback: when the schema declares any
    /// positive-engagement signal (`Schema::positive_engagement_enabled`), the
    /// declarations are authoritative — a signal is positive iff its
    /// `SignalTypeDef::is_positive_engagement()` is set. When the schema declares
    /// none (or there is no schema), the engine falls back to the built-in
    /// default set so apps predating the flag keep their prior behavior.
    pub(super) fn is_positive_engagement(&self, signal_type: &str) -> bool {
        match self.schema_def.as_ref() {
            Some(schema) if schema.positive_engagement_enabled() => schema
                .signal(signal_type)
                .is_some_and(crate::schema::SignalTypeDef::is_positive_engagement),
            _ => is_positive_engagement_signal(signal_type),
        }
    }

    /// Resolve the content embedding slot name for an entity kind.
    ///
    /// One resolver, parameterized by [`EntityKind`], so the item-side and
    /// creator-side lookups can never drift: it returns the schema's first
    /// embedding slot declared for `kind`, falling back to "content" when no
    /// schema declares one. The per-kind public wrappers
    /// ([`item_embedding_slot`](Self::item_embedding_slot) and
    /// `creator_embedding_slot`) both route through here. The "content" fallback
    /// matches the hardcoded slot names in `read_item_embedding` /
    /// `read_creator_embedding` so the executor-side read stays symmetric with
    /// the stored vector for the default case.
    pub(super) fn entity_embedding_slot(&self, kind: EntityKind) -> &str {
        self.schema_def
            .as_ref()
            .and_then(|s| {
                s.embedding_slots()
                    .iter()
                    .find(|slot| slot.entity_kind == kind)
                    .map(|slot| slot.name.as_str())
            })
            .unwrap_or("content")
    }

    /// The embedding slot name used for item content embeddings.
    ///
    /// Single source of truth for every path that reads or writes an item
    /// embedding (`write_item_embedding`, `read_item_embedding`,
    /// `try_update_preference_vector`, and the retrieve-time preference boost):
    /// the schema's first Item embedding slot, falling back to "content" when no
    /// schema declares one. Keeping all paths on this resolver is what makes the
    /// preference-vector chain coherent regardless of how an app names its slot.
    /// Thin wrapper over [`entity_embedding_slot`](Self::entity_embedding_slot).
    pub(super) fn item_embedding_slot(&self) -> &str {
        self.entity_embedding_slot(EntityKind::Item)
    }

    /// Attempt to update a user's preference vector from the item's stored embedding.
    ///
    /// Reads the item's embedding from durable storage (entity store) and blends it
    /// into the user's preference vector via `PreferenceVectors::update()`. This is
    /// a best-effort operation: if the item has no embedding, no storage is wired, or
    /// the embedding cannot be deserialized, the update is silently skipped.
    ///
    /// The slot name is resolved by [`Self::item_embedding_slot`] (the schema's
    /// first Item embedding slot, falling back to "content").
    pub(super) fn try_update_preference_vector(&self, user_id: u64, entity_id: EntityId) {
        // Determine which embedding slot to read.
        let slot_name = self.item_embedding_slot();

        // Read the item's embedding from durable storage.
        let Some(storage) = self.storage.as_ref() else {
            return;
        };
        let key = crate::storage::vector::embedding_store_key(entity_id, slot_name);
        let embedding_bytes = match storage.items_engine().get(&key) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => {
                tracing::debug!(
                    entity_id = entity_id.as_u64(),
                    slot = slot_name,
                    "preference vector update skipped: item has no stored embedding"
                );
                return;
            }
            Err(e) => {
                tracing::debug!(
                    entity_id = entity_id.as_u64(),
                    error = %e,
                    "preference vector update skipped: storage read failed"
                );
                return;
            }
        };

        // Deserialize the embedding.
        let embedding = match crate::storage::vector::deserialize_embedding(&embedding_bytes) {
            Ok(v) => v,
            Err(e) => {
                tracing::debug!(
                    entity_id = entity_id.as_u64(),
                    error = %e,
                    "preference vector update skipped: embedding deserialization failed"
                );
                return;
            }
        };

        // Blend into the user's preference vector.
        if !self.preference_vectors.update(user_id, &embedding) {
            tracing::debug!(
                user_id,
                entity_id = entity_id.as_u64(),
                embedding_dim = embedding.len(),
                "preference vector update skipped: dimension mismatch"
            );
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::{sync::atomic::Ordering, time::Duration};

    use crate::{
        TidalDb,
        governance::SignalScope,
        schema::{DecaySpec, EntityId, EntityKind, Schema, SchemaBuilder, Timestamp, Window},
    };

    fn schema_with_view() -> Schema {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        builder.build().expect("schema must be valid")
    }

    /// Regression: the backup-in-progress guard is now shared by `signal` and
    /// `signal_scoped` (W10). Both entry points must reject with `Backpressure`
    /// while a backup is in progress so neither path can race the data-dir copy.
    #[test]
    fn both_signal_paths_reject_during_backup() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(schema_with_view())
            .open()
            .unwrap();

        // Sanity: writes succeed before the guard is armed.
        db.signal("view", EntityId::new(1), 1.0, Timestamp::now())
            .unwrap();

        db.backup_in_progress.store(true, Ordering::Release);

        let unscoped = db.signal("view", EntityId::new(2), 1.0, Timestamp::now());
        assert!(
            matches!(
                unscoped,
                Err(crate::schema::TidalError::Backpressure { .. })
            ),
            "signal must reject while a backup is in progress, got {unscoped:?}"
        );

        let scoped = db.signal_scoped(
            "view",
            EntityId::new(3),
            1.0,
            Timestamp::now(),
            SignalScope::Local,
            0,
        );
        assert!(
            matches!(scoped, Err(crate::schema::TidalError::Backpressure { .. })),
            "signal_scoped must reject through the same shared guard, got {scoped:?}"
        );

        // Clearing the flag re-admits writes on both paths.
        db.backup_in_progress.store(false, Ordering::Release);
        db.signal("view", EntityId::new(4), 1.0, Timestamp::now())
            .unwrap();
        db.signal_scoped(
            "view",
            EntityId::new(5),
            1.0,
            Timestamp::now(),
            SignalScope::Local,
            0,
        )
        .unwrap();
    }

    /// Regression (signals, pass2): the finite-weight + non-negative-weight rule
    /// is shared by `signal` and `signal_scoped` via `validate_signal_weight`, so
    /// neither path can drift. Spec §8: "negative weights are rejected (negative
    /// signals use separate signal types, not negative weights)". A finite
    /// negative weight folds into the decay accumulator and can drive the hot
    /// decay score below zero, violating INV-SIG-3.
    #[test]
    fn both_signal_paths_reject_negative_and_non_finite_weight() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(schema_with_view())
            .open()
            .unwrap();

        for bad in [-1.0_f64, -f64::MIN_POSITIVE, f64::NAN, f64::INFINITY] {
            let unscoped = db.signal("view", EntityId::new(1), bad, Timestamp::now());
            assert!(
                matches!(unscoped, Err(crate::schema::TidalError::InvalidInput(_))),
                "signal must reject weight {bad}, got {unscoped:?}"
            );

            let scoped = db.signal_scoped(
                "view",
                EntityId::new(1),
                bad,
                Timestamp::now(),
                SignalScope::Local,
                0,
            );
            assert!(
                matches!(scoped, Err(crate::schema::TidalError::InvalidInput(_))),
                "signal_scoped must reject weight {bad} through the same shared guard, got {scoped:?}"
            );
        }

        // 0.0 and positive weights remain accepted (0.0 is a no-op fold that
        // still records the event in the warm-tier count).
        db.signal("view", EntityId::new(2), 0.0, Timestamp::now())
            .unwrap();
        db.signal("view", EntityId::new(3), 1.5, Timestamp::now())
            .unwrap();
    }

    /// Regression (W5, pass2): `signal_with_context` writes the item id into
    /// DURABLE `Tag::HardNeg` / `Tag::UserState` rows narrowed to a u32 slot. An id
    /// past `u32::MAX` would silently alias a lower id and collide permanently on
    /// disk. The path must reject such an id up front (mirroring
    /// `write_item_with_metadata`) so no colliding durable row ever lands.
    #[test]
    fn signal_with_context_rejects_over_range_item_id_for_user_path() {
        let db = TidalDb::builder()
            .ephemeral()
            .with_schema(schema_with_view())
            .open()
            .unwrap();

        let oversized = EntityId::new(u64::from(u32::MAX) + 1);
        let err = db
            .signal_with_context(
                "view",
                oversized,
                1.0,
                Timestamp::now(),
                Some(7), // for_user present -> durable per-user rows would be written
                None,
            )
            .unwrap_err();
        assert!(
            matches!(err, crate::schema::TidalError::InvalidInput(_)),
            "over-range item id on the for_user path must be rejected, got {err:?}"
        );

        // The boundary id (u32::MAX) narrows losslessly and is accepted.
        db.signal_with_context(
            "view",
            EntityId::new(u64::from(u32::MAX)),
            1.0,
            Timestamp::now(),
            Some(7),
            None,
        )
        .unwrap();

        // With no for_user identity, no per-user durable row is written, so the
        // over-range guard does not apply — the base signal is recorded.
        db.signal_with_context("view", oversized, 1.0, Timestamp::now(), None, None)
            .unwrap();
    }
}
