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

use std::collections::HashMap;

use crate::entities::HardNegIndex;
use crate::schema::{EntityId, EntityKind, TidalError, Timestamp, Window};

use super::TidalDb;
use super::metadata::{is_positive_engagement_signal, serialize_metadata};

impl TidalDb {
    /// Write (or overwrite) item metadata.
    ///
    /// Stores the `metadata` key-value map under the entity's `Tag::Meta` key
    /// in the items storage backend.
    ///
    /// # Errors
    ///
    /// - `TidalError::Internal` if no storage backend is wired (use `with_schema()`).
    /// - `TidalError::Storage` on storage engine failure.
    pub fn write_item(
        &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)
    }

    /// 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::Schema` if `signal_type` is not defined in the schema.
    /// - `TidalError::Durability` if the WAL write fails.
    #[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")?;
        if !weight.is_finite() {
            return Err(TidalError::invalid_input(
                "signal weight must be finite (NaN and Inf are not allowed)",
            ));
        }

        // Backup-in-progress guard: reject writes while the data directory
        // is being copied. Acquire ordering ensures we see the flag set by
        // create_backup's Release store.
        if self
            .backup_in_progress
            .load(std::sync::atomic::Ordering::Acquire)
        {
            return Err(TidalError::Backpressure {
                retry_after_ms: 500,
            });
        }

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

        // Backpressure check: inspect WAL channel depth before enqueuing.
        // This is 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,
                });
            }
        }

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

        #[cfg(feature = "metrics")]
        {
            use std::sync::atomic::Ordering;
            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
    }

    /// 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.
    /// User-context side effects (seen state, interaction weights, preference vector updates)
    /// are reconstructed from durable storage on restart via `rebuild_entity_state`.
    /// Hard negatives (hide/block) are durably written via `write_relationship()`.
    ///
    /// 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".
    ///
    /// # Errors
    ///
    /// Returns errors from the underlying `signal()` method.
    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")?;
        // 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 {
            #[allow(clippy::cast_possible_truncation)]
            let item_u32 = entity_id.as_u64() as u32;

            // 1. Hard negatives.
            if HardNegIndex::is_hard_neg_signal(signal_type) {
                self.hard_negatives.add(user_id, item_u32);
            }

            // 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.
            if let Some(cid) = creator_id {
                self.interaction_ledger
                    .record(user_id, cid, weight, timestamp.as_nanos());
            }

            // 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 is_positive_engagement_signal(signal_type) {
                self.try_update_preference_vector(user_id, entity_id);
                self.co_engagement.record_positive(user_id, entity_id);
            }

            // 6. 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());
            }

            // 7. 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(())
    }

    /// 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);
        }
    }

    /// 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 determined by the schema's first Item embedding slot, falling
    /// back to "content" if no schema is available.
    pub(super) fn try_update_preference_vector(&self, user_id: u64, entity_id: EntityId) {
        // Determine which embedding slot to read.
        let slot_name = self
            .schema_def
            .as_ref()
            .and_then(|s| {
                s.embedding_slots()
                    .iter()
                    .find(|slot| slot.entity_kind == EntityKind::Item)
                    .map(|slot| slot.name.as_str())
            })
            .unwrap_or("content");

        // 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"
            );
        }
    }
}
