//! Internal scoring methods for `ProfileExecutor`.
//!
//! All methods in this module are part of the `ProfileExecutor` implementation.
//! Extracted from `mod.rs` to keep file sizes manageable.

use super::{
    ProfileExecutor,
    formulas::{
        controversial_score, hidden_gems_score, hot_score, shuffle_quality_score,
        shuffle_quality_weight, shuffle_random, trending_score,
    },
    helpers::read_agg_for_sort,
};
use crate::{
    ranking::profile::{SignalAgg, Sort},
    schema::{EntityId, Timestamp, Window},
    signals::{SignalLedger, SignalTypeId},
};

/// Resolve a fixed sort-mode signal name to its type id, treating a signal the
/// schema does not define as `None` rather than an error.
///
/// This mirrors [`read_agg_for_sort`]'s degradation contract for the
/// social-graph trending path, which reads velocity off the per-user signal
/// index by *type id* (so it cannot go through `read_agg_for_sort` directly). A
/// missing generic signal (`view` / `share` absent from this application's
/// schema) contributes 0.0 to the score; every other resolution error -- which
/// would signal a real ledger fault, not an optional-vocabulary gap -- still
/// propagates loud instead of being swallowed by a bare `map_or(0.0, ..)`.
///
/// # Errors
///
/// Propagates any non-`UnknownSignalType` [`SignalLedger::resolve_signal_type`]
/// error.
fn resolve_sort_signal_type(
    ledger: &SignalLedger,
    signal: &str,
) -> crate::Result<Option<SignalTypeId>> {
    match ledger.resolve_signal_type(signal) {
        Ok(tid) => Ok(Some(tid)),
        Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
            Ok(None)
        }
        Err(other) => Err(other),
    }
}

/// `2^64` as an `f64`, used to scale `u64` keys into `[0.0, 1.0)`.
///
/// `u64::MAX as f64` rounds up to exactly `2^64` (it is not representable), so
/// naming the value explicitly avoids the off-by-one ULP confusion of dividing
/// by `u64::MAX`. `2^64` is exactly representable in `f64`.
const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;

impl ProfileExecutor<'_> {
    /// Compute base score and signal snapshot from the sort mode alone.
    ///
    /// Returns `(score, snapshot)` where `snapshot` lists the raw signal values
    /// that contributed to the score, for explain-ability in API responses.
    ///
    /// **Clock contract:** `now` is the query's logical clock. It is converted to
    /// `now_ns` **once** here and threaded into every time-dependent ledger read
    /// (velocity and `DecayScore`-backed sorts) via the ledger's explicit-clock
    /// `_at` variants, so the whole candidate set is aged to one consistent "now"
    /// and re-running the identical query against an unchanged ledger yields
    /// byte-identical scores (Accuracy-W, M0-M10 review). Quality-weighted
    /// `Shuffle` also consumes the per-minute `timestamp_minute` derived from
    /// `now` (spec §11.6).
    ///
    /// # Errors
    ///
    /// Sort modes read a fixed generic signal vocabulary via
    /// [`read_agg_for_sort`], so a signal the schema omits contributes 0.0
    /// rather than erroring; any other read error (notably an unimplemented
    /// aggregation) still propagates.
    pub(super) fn score_by_sort(
        &self,
        entity_id: EntityId,
        sort: Option<&Sort>,
        now: Timestamp,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        // Capture the query clock ONCE so every per-candidate ledger read below
        // ages to the same "now" (reproducible scores; one fewer syscall per read).
        let now_ns = now.as_nanos();
        match sort {
            Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now),
            Some(Sort::Trending) => self.score_trending(entity_id, now_ns),
            Some(Sort::Controversial) => self.score_controversial(entity_id, now_ns),
            Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id, now_ns),
            Some(Sort::Shuffle) => Ok((self.score_shuffle(entity_id, now_ns)?, vec![])),
            Some(Sort::New) => {
                // M2 limitation: entity metadata (`created_at`) is not accessible from the
                // executor. Entity ID is used as a proxy for recency -- ranks higher IDs
                // first, which is correct only when IDs are assigned monotonically.
                // Caller-specified u64 IDs are not guaranteed monotonic; this is a
                // best-effort approximation. Exact creation-time sorting requires
                // `db.read_item()` to be plumbed into the executor (deferred to M3+).
                // f64 cast cannot overflow (u64::MAX fits in f64 with ~11 bits of
                // precision loss for very large IDs, which is acceptable for ranking).
                #[allow(clippy::cast_precision_loss)]
                let score = entity_id.as_u64() as f64;
                Ok((score, vec![]))
            }
            Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window, now_ns),
            Some(Sort::MostViewed { window }) => {
                self.single_signal_score(entity_id, "view", &SignalAgg::Value, *window, now_ns)
            }
            Some(Sort::MostLiked { window }) => {
                self.single_signal_score(entity_id, "like", &SignalAgg::Value, *window, now_ns)
            }
            Some(Sort::MostFollowed) => self.single_signal_score(
                entity_id,
                "follow",
                &SignalAgg::Value,
                Window::AllTime,
                now_ns,
            ),
            Some(Sort::CreatorEngagementRate) => {
                let view_vel = read_agg_for_sort(
                    entity_id,
                    "view",
                    &SignalAgg::Velocity,
                    Window::TwentyFourHours,
                    self.ledger,
                    self.degradation_level,
                    now_ns,
                )?;
                let like_vel = read_agg_for_sort(
                    entity_id,
                    "like",
                    &SignalAgg::Velocity,
                    Window::TwentyFourHours,
                    self.ledger,
                    self.degradation_level,
                    now_ns,
                )?;
                Ok((
                    view_vel + like_vel,
                    vec![
                        ("view_velocity".to_string(), view_vel),
                        ("like_velocity".to_string(), like_vel),
                    ],
                ))
            }
            Some(Sort::Rising) => self.score_rising(entity_id, now_ns),
            Some(Sort::AlphabeticalAsc) => Ok((self.score_alphabetical_asc(entity_id), vec![])),
            Some(Sort::AlphabeticalDesc) => Ok((self.score_alphabetical_desc(entity_id), vec![])),
            Some(Sort::Shortest) => Ok((self.score_shortest(entity_id), vec![])),
            Some(Sort::Longest) => Ok((self.score_longest(entity_id), vec![])),
            Some(Sort::MostCommented { window }) => {
                self.single_signal_score(entity_id, "comment", &SignalAgg::Value, *window, now_ns)
            }
            Some(Sort::MostShared { window }) => {
                self.single_signal_score(entity_id, "share", &SignalAgg::Value, *window, now_ns)
            }
            Some(Sort::LiveViewerCount) => self.single_signal_score(
                entity_id,
                "viewer_count",
                &SignalAgg::DecayScore,
                Window::AllTime,
                now_ns,
            ),
            Some(Sort::DateSaved) => Ok((self.score_date_saved(entity_id), vec![])),
            None => Ok((0.0, vec![])),
        }
    }

    /// `now` is the query clock: it ages the all-time view count read consistently
    /// across the candidate set (via the ledger's explicit-clock read). It is not
    /// yet a per-candidate *age* reference: Hot scoring applies a *uniform* 24h age
    /// term because no per-entity `created_at` is plumbed into the executor (see
    /// the `age_hours` comment below). When age-aware Hot scoring lands, this `now`
    /// becomes the age reference too.
    fn score_hot(
        &self,
        entity_id: EntityId,
        gravity: f64,
        now: Timestamp,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let views = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now.as_nanos(),
        )?;
        // The scoring loop receives no per-entity `created_at`: the executor reads
        // only the signal ledger, and the `created_at` range index is keyed
        // `value -> {entity_id}` (built for range filters), so resolving an
        // individual candidate's age would require an O(n) tree scan or a separate
        // `EntityId -> created_at_ns` reverse map that is not built. Hot scoring
        // therefore treats every candidate as 24 hours old, ranking by view count
        // with a uniform age term. (A reverse lookup is the prerequisite for
        // genuine age-aware hot scoring; it is intentionally not faked here.)
        let age_hours = 24.0_f64;
        Ok((
            hot_score(views, age_hours, gravity),
            vec![("view".to_string(), views)],
        ))
    }

    /// Shuffle (spec §11.6): `random(seed) * quality_weight(item)` where
    /// `seed = hash(user_id, timestamp_minute)` and
    /// `quality_weight = sqrt(quality_score)`.
    ///
    /// The random draw is reproducible within a single minute for a single user
    /// (the seed is constant over that window), so a page refresh does not
    /// reshuffle — but it varies per user and refreshes each minute, delivering
    /// the per-user, time-windowed variety the mode advertises. Quality biases the
    /// draw multiplicatively, so high-quality items are more likely to surface
    /// without being guaranteed. The `quality_score` is built from real ledger
    /// reads (completion decay score, like/view counts) aged to the query clock,
    /// not a fixed constant, so low-quality items are genuinely demoted.
    ///
    /// Missing generic signals degrade to 0.0 via [`read_agg_for_sort`]. With no
    /// quality signals at all, every item's `quality_score` is 0, so the random
    /// draw is scaled to 0 and the items tie (normalizing to the neutral midpoint)
    /// — the mode is only meaningfully a *quality-weighted* shuffle once the app
    /// records `completion`/`like`/`view`. The seed inputs still vary per user and
    /// per minute, so the permutation refreshes correctly once any quality signal
    /// exists.
    ///
    /// # Errors
    ///
    /// Propagates any non-degradable ledger read error (an unimplemented
    /// aggregation), matching the other formula sorts.
    fn score_shuffle(&self, entity_id: EntityId, now_ns: u64) -> crate::Result<f64> {
        // Quality inputs, all aged to the single query clock for reproducibility.
        let completion_rate = read_agg_for_sort(
            entity_id,
            "completion",
            &SignalAgg::DecayScore,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?
        .clamp(0.0, 1.0);
        let likes = read_agg_for_sort(
            entity_id,
            "like",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let views = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        // like_ratio = likes / (views + 1), clamped to [0, 1]: a like is only
        // emitted by a viewer, so the ratio is a bounded engagement-quality proxy.
        let like_ratio = (likes / (views + 1.0)).clamp(0.0, 1.0);

        let quality_score = shuffle_quality_score(completion_rate, like_ratio, views);
        let quality_weight = shuffle_quality_weight(quality_score);

        // timestamp_minute: the query clock truncated to whole minutes, so the
        // seed (and therefore the whole permutation) is stable within a minute and
        // refreshes at the boundary. 60e9 ns per minute.
        let timestamp_minute = now_ns / 60_000_000_000;
        let draw = shuffle_random(self.shuffle_user_id, timestamp_minute, entity_id.as_u64());

        Ok(draw * quality_weight)
    }

    fn single_signal_score(
        &self,
        entity_id: EntityId,
        signal: &str,
        agg: &SignalAgg,
        window: Window,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let val = read_agg_for_sort(
            entity_id,
            signal,
            agg,
            window,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        Ok((val, vec![(signal.to_string(), val)]))
    }

    fn score_trending(
        &self,
        entity_id: EntityId,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        // M6: social-graph-scoped trending. When a social subgraph and
        // per-user signal index are available, compute aggregate velocity
        // across the subgraph users instead of using the global ledger.
        if let (Some(user_signal_idx), Some(users)) =
            (self.user_signal_index, &self.social_graph_users)
            && !users.is_empty()
        {
            // Resolve through the degradation-aware helper: an `UnknownSignalType`
            // (the generic `view` / `share` signal is absent from this app's
            // schema) degrades to `None` -> 0.0, matching the global-ledger
            // `read_agg_for_sort` fallback below; any other resolution error
            // propagates loud rather than being swallowed by `map_or(0.0, ..)`.
            let view_type_id = resolve_sort_signal_type(self.ledger, "view")?;
            let share_type_id = resolve_sort_signal_type(self.ledger, "share")?;

            // Degradation exemption (Tech Debt-S, M0-M10 review): this social-graph
            // path does NOT route through `read_agg`'s degradation window
            // substitution, and it does not need to. `read_agg` substitutes
            // `SignalAgg::Velocity` reads to `helpers::COARSE_VELOCITY_WINDOW`
            // (`Window::TwentyFourHours`) under `coarsens_aggregates()`; the
            // trending velocity already reads that exact window (the coarsest
            // velocity bucket, matching the global-ledger fallback below). So the
            // substitution would be a no-op here -- the path is already at the
            // coarse-aggregate window in every degradation level. Reusing the
            // shared `COARSE_VELOCITY_WINDOW` constant keeps this site in lockstep
            // with `read_agg` so the two can never silently diverge if the
            // velocity coarsening target ever changes.
            let vel_window = super::helpers::COARSE_VELOCITY_WINDOW;

            let view_vel = view_type_id.map_or(0.0, |tid| {
                user_signal_idx.aggregate_velocity(entity_id, users, tid, vel_window)
            });
            let share_vel = share_type_id.map_or(0.0, |tid| {
                user_signal_idx.aggregate_velocity(entity_id, users, tid, vel_window)
            });
            return Ok((
                trending_score(view_vel, share_vel),
                vec![
                    ("view_velocity".to_string(), view_vel),
                    ("share_velocity".to_string(), share_vel),
                ],
            ));
        }

        // Fallback: global ledger velocity.
        let view_vel = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let share_vel = read_agg_for_sort(
            entity_id,
            "share",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        Ok((
            trending_score(view_vel, share_vel),
            vec![
                ("view_velocity".to_string(), view_vel),
                ("share_velocity".to_string(), share_vel),
            ],
        ))
    }

    fn score_controversial(
        &self,
        entity_id: EntityId,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let pos = read_agg_for_sort(
            entity_id,
            "like",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let neg = read_agg_for_sort(
            entity_id,
            "dislike",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        Ok((
            controversial_score(pos, neg),
            vec![("like".to_string(), pos), ("dislike".to_string(), neg)],
        ))
    }

    fn score_hidden_gems(
        &self,
        entity_id: EntityId,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let quality = read_agg_for_sort(
            entity_id,
            "completion",
            &SignalAgg::DecayScore,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let view_count = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        Ok((
            hidden_gems_score(quality, view_count),
            vec![
                ("completion".to_string(), quality),
                ("view".to_string(), view_count),
            ],
        ))
    }

    fn score_top_window(
        &self,
        entity_id: EntityId,
        window: Window,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let views = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let likes = read_agg_for_sort(
            entity_id,
            "like",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let shares = read_agg_for_sort(
            entity_id,
            "share",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let completion = read_agg_for_sort(
            entity_id,
            "completion",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        Ok((
            views.mul_add(
                0.3,
                likes.mul_add(0.3, shares.mul_add(0.2, completion * views * 0.1)),
            ),
            vec![
                ("view".to_string(), views),
                ("like".to_string(), likes),
                ("share".to_string(), shares),
                ("completion".to_string(), completion),
            ],
        ))
    }

    fn score_rising(
        &self,
        entity_id: EntityId,
        now_ns: u64,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let short = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::OneHour,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let long = read_agg_for_sort(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
            now_ns,
        )?;
        let score = if long < f64::EPSILON {
            short
        } else {
            short / long
        };
        Ok((
            score,
            vec![
                ("view_velocity_1h".to_string(), short),
                ("view_velocity_24h".to_string(), long),
            ],
        ))
    }

    /// Score for `AlphabeticalAsc`: ascending names (A before Z) score higher.
    /// Missing title returns -1.0 (sorted last after normalization).
    ///
    /// The score is the title's first 8 lowercased bytes packed into the f64,
    /// which dominates ordering. Titles sharing an 8-byte prefix collide on this
    /// score; the *full* title then breaks the tie deterministically via the
    /// score comparator's title-aware fallback (see
    /// [`super::ProfileExecutor::finalize`]), so prefix collisions never order
    /// arbitrarily.
    #[allow(clippy::cast_precision_loss)]
    fn score_alphabetical_asc(&self, entity_id: EntityId) -> f64 {
        self.read_title_prefix(entity_id)
            .map_or(-1.0, |packed| 1.0 - (packed as f64 / TWO_POW_64))
    }

    /// Score for `AlphabeticalDesc`: descending names (Z before A) score higher.
    /// Missing title returns -1.0 (sorted last after normalization).
    ///
    /// As with [`Self::score_alphabetical_asc`], titles sharing an 8-byte prefix
    /// are disambiguated by the full-title comparator fallback in
    /// [`super::ProfileExecutor::finalize`].
    #[allow(clippy::cast_precision_loss)]
    fn score_alphabetical_desc(&self, entity_id: EntityId) -> f64 {
        self.read_title_prefix(entity_id)
            .map_or(-1.0, |packed| packed as f64 / TWO_POW_64)
    }

    /// Read the "title" metadata for an entity, lowercase it, and pack the first
    /// 8 bytes big-endian into a `u64` for numeric comparison. Returns `None` if
    /// no metadata, no title, or an empty title.
    fn read_title_prefix(&self, entity_id: EntityId) -> Option<u64> {
        let lower = self.read_title(entity_id)?;
        let bytes = lower.as_bytes();
        let mut buf = [0u8; 8];
        let len = bytes.len().min(8);
        buf[..len].copy_from_slice(&bytes[..len]);
        Some(u64::from_be_bytes(buf))
    }

    /// Read the "title" metadata for an entity and lowercase it. Returns `None`
    /// if there is no metadata, no title, or the title is empty.
    ///
    /// Exposed to the executor so the score comparator can break 8-byte-prefix
    /// ties on the full title (see [`super::ProfileExecutor::finalize`]).
    pub(super) fn read_title(&self, entity_id: EntityId) -> Option<String> {
        let meta_map = self.item_metadata?;
        let meta = meta_map.get(&entity_id.as_u64())?;
        let title = meta.get("title")?;
        if title.is_empty() {
            return None;
        }
        Some(title.to_lowercase())
    }

    /// Score for `Shortest`: read "duration" metadata, return `-duration` so
    /// shorter items score higher (less negative = higher after negation).
    /// Missing duration returns `f64::NEG_INFINITY`, a "sort last" sentinel that
    /// [`super::helpers::normalize`] folds to the bottom of the `[0, 1]` output
    /// range (computing the min/max range over the finite scores only), so the
    /// missing item ranks last *and* reports the lowest score -- monotonic with
    /// rank, never the spurious maximum `1.0`.
    fn score_shortest(&self, entity_id: EntityId) -> f64 {
        self.read_duration(entity_id)
            .map_or(f64::NEG_INFINITY, |dur| -dur)
    }

    /// Score for `Longest`: read "duration" metadata, return `duration` directly.
    /// Missing duration returns `f64::NEG_INFINITY`, a "sort last" sentinel that
    /// [`super::helpers::normalize`] folds to the bottom of the `[0, 1]` output
    /// range (see [`Self::score_shortest`]).
    fn score_longest(&self, entity_id: EntityId) -> f64 {
        self.read_duration(entity_id).unwrap_or(f64::NEG_INFINITY)
    }

    /// Read the "duration" metadata field as f64 seconds. Returns `None` if absent
    /// or unparseable.
    fn read_duration(&self, entity_id: EntityId) -> Option<f64> {
        let meta_map = self.item_metadata?;
        let meta = meta_map.get(&entity_id.as_u64())?;
        meta.get("duration")?.parse::<f64>().ok()
    }

    /// Score for `DateSaved`: look up the save timestamp from user state.
    /// Returns timestamp as f64 (later saves = higher score = first in descending).
    /// Missing save returns `f64::NEG_INFINITY`, a "sort last" sentinel that
    /// [`super::helpers::normalize`] folds to the bottom of the `[0, 1]` output
    /// range (see [`Self::score_shortest`]).
    #[allow(clippy::cast_precision_loss)]
    fn score_date_saved(&self, entity_id: EntityId) -> f64 {
        self.user_state_for_date_saved
            .map_or(f64::NEG_INFINITY, |(user_state, user_id)| {
                user_state
                    .get_save_timestamp(user_id, entity_id.as_u64())
                    .map_or(f64::NEG_INFINITY, |ts| ts as f64)
            })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::resolve_sort_signal_type;
    use crate::ranking::test_fixtures::ledger_for;

    #[test]
    fn resolve_sort_signal_type_defined_returns_some() {
        // A signal the schema defines resolves to its type id (Some), so the
        // social-graph trending path reads its per-user velocity.
        let ledger = ledger_for(&["view", "share"]);
        assert!(resolve_sort_signal_type(&ledger, "view").unwrap().is_some());
        assert!(
            resolve_sort_signal_type(&ledger, "share")
                .unwrap()
                .is_some()
        );
    }

    #[test]
    fn resolve_sort_signal_type_missing_degrades_to_none_not_error() {
        // Regression (ranking SUGGESTION, M0-M10 review): a generic sort-mode
        // signal absent from this app's schema must degrade to `None` -> 0.0
        // (mirroring `read_agg_for_sort`), NOT be swallowed nor propagate as an
        // error. Here a `view`-only schema lacks `share`: `share` degrades to
        // `None`, `view` still resolves.
        let ledger = ledger_for(&["view"]);
        assert!(
            resolve_sort_signal_type(&ledger, "share")
                .unwrap()
                .is_none(),
            "an undefined generic sort signal must degrade to None, not error"
        );
        assert!(resolve_sort_signal_type(&ledger, "view").unwrap().is_some());
    }
}
