//! Profile registry: versioned storage and retrieval of ranking profiles.
//!
//! Profiles are stored by name, with multiple versions per name kept in a
//! `BTreeMap`. The registry enforces:
//! - Name format: `^[a-z0-9_]{1,64}$` (validated without regex crate)
//! - Monotonically increasing versions per name
//! - Exploration in `[0.0, 0.5]`
//! - Gate thresholds: `[0.0, 1.0]` for `DecayScore`/`Ratio`, `>= 0.0` for others

use std::collections::{BTreeMap, HashMap};

use super::profile::{RankingProfile, SignalAgg};

// ── Error ───────────────────────────────────────────────────────────────────

/// Errors from profile registration and lookup.
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
    #[error("invalid profile name '{0}': must match [a-z0-9_]{{1,64}}")]
    InvalidName(String),
    #[error(
        "version conflict for profile '{name}': new version {new} must be > existing {existing}"
    )]
    VersionConflict {
        name: String,
        existing: u32,
        new: u32,
    },
    #[error("exploration {0} out of range [0.0, 0.5]")]
    ExplorationOutOfRange(f64),
    #[error("gate threshold {0} out of range (DecayScore/Ratio: [0.0, 1.0]; others: >= 0.0)")]
    GateThresholdOutOfRange(f64),
    #[error("profile '{0}' not found")]
    NotFound(String),
    #[error("profile '{name}' version {version} not found")]
    VersionNotFound { name: String, version: u32 },
}

// ── Validation ──────────────────────────────────────────────────────────────

/// Validate profile name without the regex crate.
///
/// Accepts `^[a-z0-9_]{1,64}$` -- lowercase ASCII alphanumeric plus underscore,
/// 1 to 64 characters.
fn is_valid_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 64
        && name
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}

// ── Registry ────────────────────────────────────────────────────────────────

/// Versioned registry of ranking profiles.
///
/// Stores `name -> version -> profile`. Lookups return the latest version by
/// default. Registration validates name format, version monotonicity,
/// exploration bounds, and gate thresholds.
pub struct ProfileRegistry {
    /// name -> version -> profile
    profiles: HashMap<String, BTreeMap<u32, RankingProfile>>,
}

impl ProfileRegistry {
    #[must_use]
    pub fn new() -> Self {
        Self {
            profiles: HashMap::new(),
        }
    }

    /// Register a profile. Validates all constraints before insertion.
    ///
    /// # Errors
    ///
    /// - `InvalidName` if name does not match `[a-z0-9_]{1,64}`
    /// - `ExplorationOutOfRange` if exploration not in `[0.0, 0.5]`
    /// - `GateThresholdOutOfRange` if a `DecayScore`/`Ratio` gate is not in `[0.0, 1.0]`, or any other gate is negative
    /// - `VersionConflict` if version <= max existing version for this name
    pub fn register(&mut self, profile: RankingProfile) -> Result<(), ProfileError> {
        if !is_valid_name(&profile.name) {
            return Err(ProfileError::InvalidName(profile.name));
        }

        if !(0.0..=0.5).contains(&profile.exploration) {
            return Err(ProfileError::ExplorationOutOfRange(profile.exploration));
        }

        // Only naturally normalized aggregations must be in [0, 1].
        // Value / Velocity / RelativeVelocity aggregations represent raw counts and
        // may have thresholds above 1.0.
        for gate in &profile.gates {
            if matches!(gate.agg, SignalAgg::DecayScore | SignalAgg::Ratio) {
                if !(0.0..=1.0).contains(&gate.min_threshold) {
                    return Err(ProfileError::GateThresholdOutOfRange(gate.min_threshold));
                }
            } else if gate.min_threshold < 0.0 {
                return Err(ProfileError::GateThresholdOutOfRange(gate.min_threshold));
            }
        }

        let versions = self.profiles.entry(profile.name.clone()).or_default();
        if let Some((&max_version, _)) = versions.last_key_value()
            && profile.version <= max_version
        {
            return Err(ProfileError::VersionConflict {
                name: profile.name,
                existing: max_version,
                new: profile.version,
            });
        }

        versions.insert(profile.version, profile);
        Ok(())
    }

    /// Get the latest version of a named profile.
    ///
    /// # Errors
    ///
    /// Returns `NotFound` if no profile with the given name exists.
    pub fn get(&self, name: &str) -> Result<&RankingProfile, ProfileError> {
        self.profiles
            .get(name)
            .and_then(|versions| versions.values().next_back())
            .ok_or_else(|| ProfileError::NotFound(name.to_owned()))
    }

    /// Get a specific version of a named profile.
    ///
    /// # Errors
    ///
    /// Returns `NotFound` if the name does not exist, or `VersionNotFound` if
    /// the specific version is not registered.
    pub fn get_version(&self, name: &str, version: u32) -> Result<&RankingProfile, ProfileError> {
        let versions = self
            .profiles
            .get(name)
            .ok_or_else(|| ProfileError::NotFound(name.to_owned()))?;
        versions
            .get(&version)
            .ok_or_else(|| ProfileError::VersionNotFound {
                name: name.to_owned(),
                version,
            })
    }

    /// List the latest version of every registered profile.
    ///
    /// Results are sorted alphabetically by profile name.
    #[must_use]
    pub fn list(&self) -> Vec<&RankingProfile> {
        let mut result: Vec<&RankingProfile> = self
            .profiles
            .values()
            .filter_map(|versions| versions.values().next_back())
            .collect();
        result.sort_by(|a, b| a.name.cmp(&b.name));
        result
    }
}

impl Default for ProfileRegistry {
    fn default() -> Self {
        Self::new()
    }
}

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

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::ranking::profile::{
        CandidateStrategy, DiversitySpec, Gate, RankingProfile, SignalAgg,
    };
    use crate::schema::Window;

    fn minimal_profile(name: &str, version: u32) -> RankingProfile {
        RankingProfile {
            name: name.to_owned(),
            version,
            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 register_and_get() {
        let mut registry = ProfileRegistry::new();
        registry.register(minimal_profile("trending", 1)).unwrap();
        let profile = registry.get("trending").unwrap();
        assert_eq!(profile.name, "trending");
    }

    #[test]
    fn version_monotonicity() {
        let mut registry = ProfileRegistry::new();
        registry.register(minimal_profile("trending", 1)).unwrap();
        let result = registry.register(minimal_profile("trending", 1));
        assert!(matches!(result, Err(ProfileError::VersionConflict { .. })));
    }

    #[test]
    fn invalid_name_rejected() {
        let mut registry = ProfileRegistry::new();
        let result = registry.register(minimal_profile("has spaces", 1));
        assert!(matches!(result, Err(ProfileError::InvalidName(_))));
    }

    #[test]
    fn exploration_out_of_range() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("explore", 1);
        profile.exploration = 0.9;
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::ExplorationOutOfRange(_))
        ));
    }

    #[test]
    fn gate_threshold_out_of_range_decay_score() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("gated", 1);
        profile.gates.push(Gate {
            signal: "view".into(),
            agg: SignalAgg::DecayScore,
            window: Window::AllTime,
            min_threshold: 1.5,
        });
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::GateThresholdOutOfRange(_))
        ));
    }

    #[test]
    fn gate_threshold_value_above_one_allowed() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("gated_value", 1);
        profile.gates.push(Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            min_threshold: 100.0,
        });
        let result = registry.register(profile);
        assert!(
            result.is_ok(),
            "Value gate with threshold > 1.0 should be allowed"
        );
    }

    #[test]
    fn gate_threshold_negative_rejected() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("gated_neg", 1);
        profile.gates.push(Gate {
            signal: "view".into(),
            agg: SignalAgg::Value,
            window: Window::AllTime,
            min_threshold: -1.0,
        });
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::GateThresholdOutOfRange(_))
        ));
    }

    #[test]
    fn get_not_found() {
        let registry = ProfileRegistry::new();
        let result = registry.get("nonexistent");
        assert!(matches!(result, Err(ProfileError::NotFound(_))));
    }
}
