//! Ranking profile type system.
//!
//! A `RankingProfile` is the unit of ranking configuration. It declares how
//! candidates are sourced, scored, gated, penalized, and diversified. Profiles
//! are versioned and registered in the `ProfileRegistry`.
//!
//! Every type here is `Serialize + Deserialize` so profiles can be stored in
//! the schema layer and exchanged over the API.

use serde::{Deserialize, Serialize};

use crate::schema::Window;

// ── Core profile ────────────────────────────────────────────────────────────

/// A complete ranking profile -- the unit of ranking configuration.
///
/// Profiles combine:
/// - **Candidate strategy**: how to source candidates (ANN, scan, signal-ranked)
/// - **Boosts**: signal-based score multipliers
/// - **Decay**: time-decay weighting
/// - **Gates**: minimum thresholds that filter candidates
/// - **Penalties**: negative score adjustments
/// - **Excludes**: hard filters that remove candidates
/// - **Diversity**: per-creator and format-mix constraints
/// - **Exploration**: fraction of random candidates injected for discovery
/// - **Sort**: the primary scoring formula
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RankingProfile {
    pub name: String,
    pub version: u32,
    pub candidate_strategy: CandidateStrategy,
    pub boosts: Vec<Boost>,
    pub decay: Option<ProfileDecay>,
    pub gates: Vec<Gate>,
    pub penalties: Vec<Penalty>,
    pub excludes: Vec<Exclude>,
    pub diversity: DiversitySpec,
    pub exploration: f64,
    pub sort: Option<Sort>,
    pub is_builtin: bool,
}

// ── Sort modes ──────────────────────────────────────────────────────────────

/// Primary sort mode. Determines the scoring formula applied to candidates.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Sort {
    Hot {
        gravity: f64,
    },
    Trending,
    Rising,
    Controversial,
    HiddenGems,
    Shuffle,
    New,
    TopWindow {
        window: Window,
    },
    MostViewed {
        window: Window,
    },
    MostLiked {
        window: Window,
    },
    /// Sort creators by total follow count (`AllTime` follow signal value).
    /// Uses the "follow" signal's `AllTime` windowed count as a proxy for
    /// follower count. Degrades gracefully when "follow" signal is absent.
    MostFollowed,
    /// Sort creators by engagement rate proxy (view + like velocity over 24h).
    /// Higher combined velocity = higher engagement rate.
    CreatorEngagementRate,
    /// Sort alphabetically by item "title" metadata field (A-Z, case-insensitive).
    /// Items without a title are sorted last.
    AlphabeticalAsc,
    /// Sort reverse-alphabetically by item "title" metadata field (Z-A, case-insensitive).
    /// Items without a title are sorted last.
    AlphabeticalDesc,
    /// Sort by item "duration" metadata field in seconds, shortest first.
    /// Items without a duration are sorted last.
    Shortest,
    /// Sort by item "duration" metadata field in seconds, longest first.
    /// Items without a duration are sorted last.
    Longest,
    /// Sort by windowed count of "comment" signal.
    MostCommented {
        window: Window,
    },
    /// Sort by windowed count of "share" signal.
    MostShared {
        window: Window,
    },
    /// Sort by current decayed score of `viewer_count` signal.
    /// Items without the signal score 0.0.
    LiveViewerCount,
    /// Sort by timestamp when the querying user saved the item.
    /// Requires `for_user` context; returns `QueryError` if absent.
    DateSaved,
}

// ── Candidate strategy ──────────────────────────────────────────────────────

/// How candidates are sourced for ranking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CandidateStrategy {
    Ann { slot: String, limit: usize },
    Scan { sort_field: String },
    SignalRanked { signal: String, window: Window },
    Hybrid,
    Relationship,
    CohortTrending,
}

// ── Signal aggregation ──────────────────────────────────────────────────────

/// Which aggregation to read from a signal for scoring/gating.
///
/// - `Value`: raw windowed count
/// - `Velocity`: rate of change within a window
/// - `DecayScore`: exponentially decayed running score in `[0.0, ~1.0]`
/// - `Ratio`: ratio of two signals (planned for M3; currently returns 0.0 with a warning)
/// - `RelativeVelocity`: velocity relative to a baseline (planned for M3; currently returns 0.0 with a warning)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SignalAgg {
    Value,
    Velocity,
    Ratio,
    DecayScore,
    RelativeVelocity,
}

// ── Boost ───────────────────────────────────────────────────────────────────

/// A signal-based score multiplier. Adds `weight * agg(signal, window)` to the
/// candidate's score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Boost {
    pub signal: String,
    pub agg: SignalAgg,
    pub window: Window,
    pub weight: f64,
}

// ── Decay ───────────────────────────────────────────────────────────────────

/// Profile-level time-decay. Applies an exponential decay factor to the score
/// based on the named signal's age.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileDecay {
    pub signal: String,
    pub half_life_secs: u64,
    pub weight: f64,
}

// ── Gate ────────────────────────────────────────────────────────────────────

/// A minimum-threshold filter. Candidates with `agg(signal, window) < min_threshold`
/// are excluded from the result set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gate {
    pub signal: String,
    pub agg: SignalAgg,
    pub window: Window,
    pub min_threshold: f64,
}

// ── Penalty ─────────────────────────────────────────────────────────────────

/// A negative score adjustment. Subtracts `weight * agg(signal, window)` from
/// the candidate's score.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Penalty {
    pub signal: String,
    pub agg: SignalAgg,
    pub window: Window,
    pub weight: f64,
}

// ── Exclude ─────────────────────────────────────────────────────────────────

/// A hard filter. Candidates with `agg(signal, window) > above` are removed
/// entirely.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Exclude {
    pub signal: String,
    pub agg: SignalAgg,
    pub window: Window,
    pub above: f64,
}

// ── Diversity ───────────────────────────────────────────────────────────────

/// Diversity constraints applied after scoring and before final result assembly.
///
/// Both fields are optional. When `None`, the corresponding constraint is not enforced.
/// M2 enforces `max_per_creator` and `format_mix_max_fraction` via the `DiversitySelector`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiversitySpec {
    /// Maximum number of items from any single creator in the result set.
    /// `None` means no per-creator limit.
    pub max_per_creator: Option<usize>,
    /// Maximum fraction of the result set that any single content format may occupy.
    /// For example, `Some(0.5)` means no single format can exceed 50% of results.
    /// `None` means no format-mix constraint.
    pub format_mix_max_fraction: Option<f64>,
}

// ── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    fn minimal_profile(name: &str) -> RankingProfile {
        RankingProfile {
            name: name.to_owned(),
            version: 1,
            candidate_strategy: CandidateStrategy::Scan {
                sort_field: "created_at".into(),
            },
            boosts: vec![],
            decay: None,
            gates: vec![],
            penalties: vec![],
            excludes: vec![],
            diversity: DiversitySpec::default(),
            exploration: 0.0,
            sort: None,
            is_builtin: false,
        }
    }

    #[test]
    fn profile_serializes_to_json() {
        let profile = minimal_profile("test_profile");
        let json = serde_json::to_string(&profile).unwrap();
        let deserialized: RankingProfile = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.name, "test_profile");
    }

    #[test]
    fn diversity_spec_default_is_none() {
        let spec = DiversitySpec::default();
        assert!(spec.max_per_creator.is_none());
        assert!(spec.format_mix_max_fraction.is_none());
    }
}
