//! 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 crate::schema::{EntityId, Timestamp, Window};

use super::ProfileExecutor;
use super::formulas::{
    controversial_score, hidden_gems_score, hot_score, shuffle_score, trending_score,
};
use super::helpers::read_agg;
use crate::ranking::profile::{SignalAgg, Sort};

impl ProfileExecutor<'_> {
    /// Compute base score from the sort mode alone.
    pub(super) fn score_by_sort(
        &self,
        entity_id: EntityId,
        sort: Option<&Sort>,
        now: Timestamp,
    ) -> f64 {
        match sort {
            Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now),
            Some(Sort::Trending) => self.score_trending(entity_id),
            Some(Sort::Controversial) => self.score_controversial(entity_id),
            Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id),
            Some(Sort::Shuffle) => shuffle_score(entity_id.as_u64()),
            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;
                score
            }
            Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window),
            Some(Sort::MostViewed { window }) => read_agg(
                entity_id,
                "view",
                &SignalAgg::Value,
                *window,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::MostLiked { window }) => read_agg(
                entity_id,
                "like",
                &SignalAgg::Value,
                *window,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::MostFollowed) => read_agg(
                entity_id,
                "follow",
                &SignalAgg::Value,
                Window::AllTime,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::CreatorEngagementRate) => {
                let view_vel = read_agg(
                    entity_id,
                    "view",
                    &SignalAgg::Velocity,
                    Window::TwentyFourHours,
                    self.ledger,
                    self.degradation_level,
                );
                let like_vel = read_agg(
                    entity_id,
                    "like",
                    &SignalAgg::Velocity,
                    Window::TwentyFourHours,
                    self.ledger,
                    self.degradation_level,
                );
                view_vel + like_vel
            }
            Some(Sort::Rising) => self.score_rising(entity_id),
            Some(Sort::AlphabeticalAsc) => self.score_alphabetical_asc(entity_id),
            Some(Sort::AlphabeticalDesc) => self.score_alphabetical_desc(entity_id),
            Some(Sort::Shortest) => self.score_shortest(entity_id),
            Some(Sort::Longest) => self.score_longest(entity_id),
            Some(Sort::MostCommented { window }) => read_agg(
                entity_id,
                "comment",
                &SignalAgg::Value,
                *window,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::MostShared { window }) => read_agg(
                entity_id,
                "share",
                &SignalAgg::Value,
                *window,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::LiveViewerCount) => read_agg(
                entity_id,
                "viewer_count",
                &SignalAgg::DecayScore,
                Window::AllTime,
                self.ledger,
                self.degradation_level,
            ),
            Some(Sort::DateSaved) => self.score_date_saved(entity_id),
            None => 0.0,
        }
    }

    fn score_hot(&self, entity_id: EntityId, gravity: f64, _now: Timestamp) -> f64 {
        let views = read_agg(
            entity_id,
            "view",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
        );
        // M2 limitation: the executor does not have access to entity metadata, so
        // `created_at` is unavailable here. All entities are treated as 24 hours old,
        // producing identical age contributions to hot scoring. The hot sort mode
        // therefore ranks solely by view count at M2 scale. Per-entity age will be
        // wired in when `TidalDb::read_item()` is plumbed through the executor (M3+).
        let age_hours = 24.0_f64;
        hot_score(views, age_hours, gravity)
    }

    fn score_trending(&self, entity_id: EntityId) -> 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()
        {
            let view_type_id = self.ledger.resolve_signal_type("view");
            let share_type_id = self.ledger.resolve_signal_type("share");

            let view_vel = view_type_id.map_or(0.0, |tid| {
                user_signal_idx.aggregate_velocity(entity_id, users, tid, Window::TwentyFourHours)
            });
            let share_vel = share_type_id.map_or(0.0, |tid| {
                user_signal_idx.aggregate_velocity(entity_id, users, tid, Window::TwentyFourHours)
            });
            return trending_score(view_vel, share_vel);
        }

        // Fallback: global ledger velocity.
        let view_vel = read_agg(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
        );
        let share_vel = read_agg(
            entity_id,
            "share",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
        );
        trending_score(view_vel, share_vel)
    }

    fn score_controversial(&self, entity_id: EntityId) -> f64 {
        let pos = read_agg(
            entity_id,
            "like",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
        );
        let neg = read_agg(
            entity_id,
            "dislike",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
        );
        controversial_score(pos, neg)
    }

    fn score_hidden_gems(&self, entity_id: EntityId) -> f64 {
        let quality = read_agg(
            entity_id,
            "completion",
            &SignalAgg::DecayScore,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
        );
        let view_count = read_agg(
            entity_id,
            "view",
            &SignalAgg::Value,
            Window::AllTime,
            self.ledger,
            self.degradation_level,
        );
        hidden_gems_score(quality, view_count)
    }

    fn score_top_window(&self, entity_id: EntityId, window: Window) -> f64 {
        let views = read_agg(
            entity_id,
            "view",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
        );
        let likes = read_agg(
            entity_id,
            "like",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
        );
        let shares = read_agg(
            entity_id,
            "share",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
        );
        let completion = read_agg(
            entity_id,
            "completion",
            &SignalAgg::Value,
            window,
            self.ledger,
            self.degradation_level,
        );
        views.mul_add(
            0.3,
            likes.mul_add(0.3, shares.mul_add(0.2, completion * views * 0.1)),
        )
    }

    fn score_rising(&self, entity_id: EntityId) -> f64 {
        let short = read_agg(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::OneHour,
            self.ledger,
            self.degradation_level,
        );
        let long = read_agg(
            entity_id,
            "view",
            &SignalAgg::Velocity,
            Window::TwentyFourHours,
            self.ledger,
            self.degradation_level,
        );
        if long < f64::EPSILON {
            short
        } else {
            short / long
        }
    }

    /// Score for `AlphabeticalAsc`: pack first 8 bytes of lowercased title into a
    /// u64, then return `1.0 - (packed / u64::MAX)` so ascending names score higher.
    /// Missing title returns -1.0 (sorted last after normalization).
    #[allow(clippy::cast_precision_loss)]
    fn score_alphabetical_asc(&self, entity_id: EntityId) -> f64 {
        self.read_title(entity_id)
            .map_or(-1.0, |packed| 1.0 - (packed as f64 / u64::MAX as f64))
    }

    /// Score for `AlphabeticalDesc`: pack first 8 bytes of lowercased title into a
    /// u64, return `packed / u64::MAX` so descending names (Z before A) score higher.
    /// Missing title returns -1.0 (sorted last after normalization).
    #[allow(clippy::cast_precision_loss)]
    fn score_alphabetical_desc(&self, entity_id: EntityId) -> f64 {
        self.read_title(entity_id)
            .map_or(-1.0, |packed| packed as f64 / u64::MAX as f64)
    }

    /// Read the "title" metadata for an entity, lowercase it, and pack the first
    /// 8 bytes into a u64 for numeric comparison. Returns `None` if no metadata
    /// or no title.
    fn read_title(&self, entity_id: EntityId) -> Option<u64> {
        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;
        }
        let lower = title.to_lowercase();
        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))
    }

    /// Score for `Shortest`: read "duration" metadata, return `-duration` so
    /// shorter items score higher (more negative = lower after negation).
    /// Missing duration returns `f64::NEG_INFINITY` (sorted last).
    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` (sorted last).
    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` (sorted last).
    #[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)
            })
    }
}
