//! Stage 3b helper methods for the RETRIEVE executor.
//!
//! Contains cohort rescoring, notification cap enforcement, and related helpers.

use crate::db::deserialize_metadata as deserialize_item_metadata;
use crate::query::retrieve::QueryError;
use crate::schema::EntityId;
use crate::storage::{Tag, encode_key};

use super::RetrieveExecutor;

impl RetrieveExecutor<'_> {
    // ── Stage 3b helpers (M6) ────────────────────────────────────────────

    /// Resolve an ad-hoc cohort predicate to a registered cohort name.
    ///
    /// Reads the user's metadata from items storage, evaluates the predicate,
    /// and then checks which registered cohorts the user belongs to.
    ///
    /// # Returns
    ///
    /// - `Ok(Some(name))` when a registered cohort matches the user.
    /// - `Ok(None)` when the user does not match the predicate, or when
    ///   metadata cannot be read (storage miss is recoverable).
    /// - `Err(QueryError::InvalidFilter)` when the user matches the predicate
    ///   but no registered cohort is defined for it.
    pub(super) fn resolve_adhoc_cohort(
        &self,
        pred: &crate::cohort::Predicate,
        user_id: u64,
        warnings: &mut Vec<String>,
    ) -> Result<Option<String>, QueryError> {
        // Read user metadata from items storage (users are stored there too
        // via write_user). We need users_engine, but the executor only has
        // items_storage. Fall back to items_storage for metadata lookup.
        let Some(storage) = self.items_storage else {
            warnings.push(format!(
                "cohort_predicate: no storage available for user {user_id}; ignoring"
            ));
            return Ok(None);
        };

        let user_entity_id = EntityId::new(user_id);
        let key = encode_key(user_entity_id, Tag::Meta, b"");
        let meta_bytes = match storage.get(&key) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => {
                warnings.push(format!(
                    "cohort_predicate: user {user_id} has no metadata; ignoring"
                ));
                return Ok(None);
            }
            Err(_) => {
                warnings.push(format!(
                    "cohort_predicate: failed to read metadata for user {user_id}; ignoring"
                ));
                return Ok(None);
            }
        };

        let metadata = deserialize_item_metadata(&meta_bytes);

        // Evaluate the ad-hoc predicate against the user's metadata.
        if !crate::cohort::resolver::evaluate_predicate(pred, &metadata) {
            warnings.push(format!(
                "cohort_predicate: user {user_id} does not match the predicate; ignoring"
            ));
            return Ok(None);
        }

        // Find which registered cohorts this user belongs to.
        let Some(registry) = self.cohort_registry else {
            return Err(QueryError::InvalidFilter {
                field: "cohort_predicate".to_string(),
                reason: "user matches predicate but no cohort registry is available; \
                         use define_cohort() to register a cohort before querying \
                         with cohort_predicate"
                    .to_string(),
            });
        };

        let cohort_names = registry.names();
        for name in &cohort_names {
            if let Some(def) = registry.get(name)
                && crate::cohort::resolver::evaluate_predicate(&def.predicate, &metadata)
            {
                return Ok(Some(name.to_string()));
            }
        }

        Err(QueryError::InvalidFilter {
            field: "cohort_predicate".to_string(),
            reason: "user matches predicate but no registered cohort is defined for it; \
                     use define_cohort() to register a cohort before querying \
                     with cohort_predicate"
                .to_string(),
        })
    }

    /// Re-score candidates using cohort signal values and re-sort.
    #[allow(clippy::unused_self)]
    pub(super) fn rescore_with_cohort(
        &self,
        scored: &mut [crate::ranking::executor::ScoredCandidate],
        cohort_name: &str,
        cohort_ledger: &crate::cohort::CohortSignalLedger,
        boosts: &[crate::ranking::profile::Boost],
    ) {
        for candidate in scored.iter_mut() {
            let mut cohort_score = 0.0;
            for boost in boosts {
                let value = match &boost.agg {
                    crate::ranking::profile::SignalAgg::Value => {
                        #[allow(clippy::cast_precision_loss)]
                        let count = cohort_ledger
                            .read_windowed_count(
                                cohort_name,
                                candidate.entity_id,
                                &boost.signal,
                                boost.window,
                            )
                            .unwrap_or(0) as f64;
                        count
                    }
                    crate::ranking::profile::SignalAgg::Velocity => cohort_ledger
                        .read_velocity(
                            cohort_name,
                            candidate.entity_id,
                            &boost.signal,
                            boost.window,
                        )
                        .unwrap_or(0.0),
                    crate::ranking::profile::SignalAgg::DecayScore => cohort_ledger
                        .read_decay_score(cohort_name, candidate.entity_id, &boost.signal, 0)
                        .unwrap_or(None)
                        .unwrap_or(0.0),
                    _ => 0.0,
                };
                cohort_score += value * boost.weight;
            }
            candidate.score = cohort_score;
        }

        // Re-sort by descending score.
        scored.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
    }

    /// Apply notification caps as a post-diversity pass.
    ///
    /// Enforces per-creator and total daily limits by consulting the
    /// `NotificationTracker` for historical deliveries and capping within
    /// this response. Retained results are recorded into the tracker when
    /// the profile is "notification".
    ///
    /// Creator resolution: `ScoredCandidate.creator_id` is populated by the
    /// ranking executor only when diversity is evaluated. As a fallback,
    /// we resolve the `creator_id` from item metadata via `items_storage` so
    /// that per-creator caps work correctly even when the ranking path leaves
    /// `creator_id = None`.
    pub(super) fn apply_notification_caps(
        &self,
        candidates: Vec<crate::ranking::executor::ScoredCandidate>,
        caps: crate::query::retrieve::NotificationCaps,
        user_id: u64,
        profile_name: &str,
    ) -> Vec<crate::ranking::executor::ScoredCandidate> {
        use std::collections::HashMap as StdMap;

        // Days since epoch (avoids chrono dependency).
        // u32 wraps at ~4.3 billion days (~11.8 million years); safe for any
        // practical use. The 86_400s * 1e9ns constant avoids a chrono dep.
        const NANOS_PER_DAY: u64 = 86_400 * 1_000_000_000;
        let today = {
            let now_ns = crate::schema::Timestamp::now().as_nanos();
            #[allow(clippy::cast_possible_truncation)]
            let day = (now_ns / NANOS_PER_DAY) as u32;
            day
        };

        // Resolve creator_id for a candidate: prefer the pre-populated field,
        // fall back to reading item metadata from storage.
        let resolve_creator_id = |c: &crate::ranking::executor::ScoredCandidate| -> Option<u64> {
            if let Some(cid) = c.creator_id {
                return Some(cid.as_u64());
            }
            let storage = self.items_storage?;
            let key = encode_key(c.entity_id, Tag::Meta, b"");
            let raw = storage.get(&key).ok()??;
            let meta = deserialize_item_metadata(&raw);
            meta.get("creator_id")?.parse::<u64>().ok()
        };

        // Cross-request total already delivered today (from prior RETRIEVE calls).
        let already_total = self
            .notification_tracker
            .map_or(0, |t| t.count_total_today(user_id, today));

        let mut per_creator: StdMap<u64, u32> = StdMap::new();
        let mut in_response_total: u32 = 0;

        let retained: Vec<_> = candidates
            .into_iter()
            .filter(|c| {
                // Enforce total cap across both prior requests and this response.
                if already_total + in_response_total >= caps.max_total_per_day {
                    return false;
                }

                // Per-creator cap: only enforced when the item has a resolvable creator.
                // Items without any creator_id (neither in the candidate nor in metadata)
                // skip the per-creator check to avoid incorrect bucketing.
                if let Some(creator_id) = resolve_creator_id(c) {
                    let already_delivered = self
                        .notification_tracker
                        .map_or(0, |t| t.count_today(user_id, creator_id, today));

                    let in_response = per_creator.get(&creator_id).copied().unwrap_or(0);
                    if already_delivered + in_response >= caps.max_per_creator_per_day {
                        return false;
                    }

                    *per_creator.entry(creator_id).or_insert(0) += 1;
                }

                in_response_total += 1;
                true
            })
            .collect();

        // Record deliveries into the tracker for the "notification" profile.
        if profile_name == "notification"
            && let Some(tracker) = self.notification_tracker
        {
            for c in &retained {
                if let Some(creator_id) = resolve_creator_id(c) {
                    tracker.record(user_id, creator_id, today);
                }
            }
        }

        retained
    }
}
