//! 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
//! - Every referenced aggregation is implemented (rejects `Ratio` /
//!   `RelativeVelocity`, which the executor does not yet compute)
//! - When a schema is bound (see [`ProfileRegistry::with_schema`]), every
//!   signal name referenced by a non-builtin profile resolves in that schema

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

use super::profile::{RankingProfile, SignalAgg};
use crate::schema::Schema;

// ── 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 '{profile}' references signal '{signal}' which is not defined in the schema")]
    UnknownSignalType { signal: String, profile: String },
    #[error(
        "profile '{profile}' uses aggregation '{agg}' which the executor does not implement \
         (Ratio / RelativeVelocity are reserved for a future milestone)"
    )]
    UnsupportedAggregation { agg: String, profile: String },
    #[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 == '_')
}

/// Yield every `(signal name, aggregation)` term a profile reads from the
/// signal ledger: gates, boosts, penalties, and excludes. Sort modes read
/// fixed, executor-internal signals (`view`, `like`, ...) and are not
/// user-declared signal references, so they are not validated here.
fn profile_signal_terms(profile: &RankingProfile) -> impl Iterator<Item = (&str, &SignalAgg)> {
    profile
        .gates
        .iter()
        .map(|g| (g.signal.as_str(), &g.agg))
        .chain(profile.boosts.iter().map(|b| (b.signal.as_str(), &b.agg)))
        .chain(
            profile
                .penalties
                .iter()
                .map(|p| (p.signal.as_str(), &p.agg)),
        )
        .chain(profile.excludes.iter().map(|e| (e.signal.as_str(), &e.agg)))
}

// ── 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, gate thresholds, aggregation support, and -- when a
/// schema is bound -- signal-name resolution.
pub struct ProfileRegistry {
    /// name -> version -> profile
    profiles: HashMap<String, BTreeMap<u32, RankingProfile>>,
    /// Schema used to validate that signal names referenced by non-builtin
    /// profiles actually exist. `None` until [`Self::with_schema`] is called;
    /// while it is `None`, signal-name resolution is skipped entirely. Built-in
    /// profiles are always exempt -- they reference a superset of any individual
    /// schema's signals by design.
    schema: Option<Schema>,
}

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

    /// Bind a schema so subsequent registrations validate signal-name
    /// resolution for non-builtin profiles.
    ///
    /// Built-in profiles ([`RankingProfile::is_builtin`]) are exempt: they
    /// reference the full generic signal vocabulary (`view`, `like`, `share`,
    /// ...) and a given application schema legitimately defines only a subset.
    /// Bind the schema *after* registering builtins and *before* registering
    /// schema-defined profiles via [`Self::override_register`].
    pub fn with_schema(&mut self, schema: Schema) {
        self.schema = Some(schema);
    }

    /// Validate the parts of a profile that are independent of version /
    /// override semantics: name format, exploration bounds, gate thresholds,
    /// aggregation support, and (when a schema is bound) signal-name
    /// resolution.
    fn validate_profile(&self, profile: &RankingProfile) -> Result<(), ProfileError> {
        if !is_valid_name(&profile.name) {
            return Err(ProfileError::InvalidName(profile.name.clone()));
        }

        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));
            }
        }

        // Every signal-bearing term must reference an implemented aggregation
        // and (when a schema is bound, for non-builtin profiles) a signal that
        // resolves in the schema. A typo'd signal name or an unimplemented
        // aggregation would otherwise register cleanly and then silently
        // exclude every candidate at query time.
        for (signal, agg) in profile_signal_terms(profile) {
            if !agg.is_implemented() {
                return Err(ProfileError::UnsupportedAggregation {
                    agg: agg.label().to_owned(),
                    profile: profile.name.clone(),
                });
            }
            if !profile.is_builtin
                && let Some(schema) = &self.schema
                && schema.signal(signal).is_none()
            {
                return Err(ProfileError::UnknownSignalType {
                    signal: signal.to_owned(),
                    profile: profile.name.clone(),
                });
            }
        }

        Ok(())
    }

    /// 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
    /// - `UnsupportedAggregation` if any term uses `Ratio` / `RelativeVelocity`
    /// - `UnknownSignalType` if a schema is bound and a non-builtin profile
    ///   references a signal not in that schema
    pub fn register(&mut self, profile: RankingProfile) -> Result<(), ProfileError> {
        self.validate_profile(&profile)?;

        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(())
    }

    /// Override-register a profile, replacing all existing versions for that name.
    ///
    /// Clears any previously registered versions (including builtins) and inserts
    /// the new profile as the sole version. Applies the same validation as
    /// [`register`](Self::register) (name format, exploration bounds, gate
    /// thresholds, aggregation support, schema signal-name resolution) but
    /// skips version monotonicity since all prior versions are removed.
    ///
    /// This is used by schema-defined profiles to cleanly replace built-in
    /// defaults without version gymnastics.
    ///
    /// # Errors
    ///
    /// Same as [`register`](Self::register) except `VersionConflict` is impossible.
    pub fn override_register(&mut self, profile: RankingProfile) -> Result<(), ProfileError> {
        self.validate_profile(&profile)?;

        // Clear all existing versions for this name, then insert the new one.
        let versions = self.profiles.entry(profile.name.clone()).or_default();
        versions.clear();
        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 std::time::Duration;

    use super::*;
    use crate::{
        ranking::{
            profile::{Boost, Gate, SignalAgg},
            // Shared profile skeleton (DRY-S, M0-M10 review): the per-module
            // `minimal_profile(name, version)` literal now lives in
            // `ranking::test_fixtures` as `minimal_profile_versioned`.
            test_fixtures::minimal_profile_versioned as minimal_profile,
        },
        schema::{DecaySpec, EntityKind, SchemaBuilder, Window},
    };

    /// Build a single-signal schema for signal-name resolution tests.
    fn schema_with(signal: &str) -> Schema {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                signal,
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .windows(&[Window::ThirtyDays, Window::AllTime])
            .velocity(false)
            .add();
        builder.build().unwrap()
    }

    #[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(_))));
    }

    #[test]
    fn override_register_replaces_builtin() {
        let mut registry = ProfileRegistry::new();
        let mut builtin = minimal_profile("for_you", 1);
        builtin.is_builtin = true;
        registry.register(builtin).unwrap();

        // Override with a schema-defined version.
        let mut override_profile = minimal_profile("for_you", 1);
        override_profile.exploration = 0.1;
        registry.override_register(override_profile).unwrap();

        let profile = registry.get("for_you").unwrap();
        assert!(!profile.is_builtin);
        assert!((profile.exploration - 0.1).abs() < f64::EPSILON);
    }

    #[test]
    fn override_register_clears_all_versions() {
        let mut registry = ProfileRegistry::new();
        registry.register(minimal_profile("trending", 1)).unwrap();
        registry.register(minimal_profile("trending", 2)).unwrap();

        // Override clears both v1 and v2.
        let override_profile = minimal_profile("trending", 1);
        registry.override_register(override_profile).unwrap();

        // Only v1 exists now.
        assert!(registry.get_version("trending", 2).is_err());
        assert!(registry.get_version("trending", 1).is_ok());
    }

    #[test]
    fn override_register_validates_constraints() {
        let mut registry = ProfileRegistry::new();

        // Invalid name.
        let result = registry.override_register(minimal_profile("BAD NAME", 1));
        assert!(matches!(result, Err(ProfileError::InvalidName(_))));

        // Exploration out of range.
        let mut profile = minimal_profile("bad_explore", 1);
        profile.exploration = 0.9;
        let result = registry.override_register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::ExplorationOutOfRange(_))
        ));

        // Gate threshold out of range.
        let mut profile = minimal_profile("bad_gate", 1);
        profile.gates.push(Gate {
            signal: "view".into(),
            agg: SignalAgg::DecayScore,
            window: Window::AllTime,
            min_threshold: 1.5,
        });
        let result = registry.override_register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::GateThresholdOutOfRange(_))
        ));
    }

    #[test]
    fn register_rejects_ratio_aggregation_in_gate() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("ratio_gate", 1);
        profile.gates.push(Gate {
            signal: "view".into(),
            agg: SignalAgg::Ratio,
            window: Window::AllTime,
            min_threshold: 0.5,
        });
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::UnsupportedAggregation { ref agg, .. }) if agg == "Ratio"
        ));
    }

    #[test]
    fn register_rejects_relative_velocity_aggregation_in_boost() {
        let mut registry = ProfileRegistry::new();
        let mut profile = minimal_profile("rel_vel_boost", 1);
        profile.boosts.push(Boost {
            signal: "view".into(),
            agg: SignalAgg::RelativeVelocity,
            window: Window::AllTime,
            weight: 1.0,
        });
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::UnsupportedAggregation { ref agg, .. }) if agg == "RelativeVelocity"
        ));
    }

    #[test]
    fn register_rejects_unknown_signal_when_schema_bound() {
        let mut registry = ProfileRegistry::new();
        registry.with_schema(schema_with("digest_open"));

        let mut profile = minimal_profile("typo", 1);
        profile.boosts.push(Boost {
            // Typo'd signal name -- not in the bound schema.
            signal: "digest_opne".into(),
            agg: SignalAgg::DecayScore,
            window: Window::ThirtyDays,
            weight: 1.0,
        });
        let result = registry.register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::UnknownSignalType { ref signal, .. }) if signal == "digest_opne"
        ));
    }

    #[test]
    fn register_accepts_known_signal_and_thirty_days_window_when_schema_bound() {
        let mut registry = ProfileRegistry::new();
        registry.with_schema(schema_with("digest_open"));

        let mut profile = minimal_profile("good", 1);
        profile.boosts.push(Boost {
            signal: "digest_open".into(),
            agg: SignalAgg::DecayScore,
            window: Window::ThirtyDays,
            weight: 1.0,
        });
        // Known signal + ThirtyDays window registers cleanly (mirrors the
        // built-in `for_you` profile shape).
        assert!(registry.register(profile).is_ok());
    }

    #[test]
    fn register_exempts_builtin_from_schema_signal_resolution() {
        let mut registry = ProfileRegistry::new();
        registry.with_schema(schema_with("digest_open"));

        // A builtin references the generic vocabulary (`view`) which this
        // schema does not define; builtins are exempt from name resolution.
        let mut builtin = minimal_profile("trending", 1);
        builtin.is_builtin = true;
        builtin.boosts.push(Boost {
            signal: "view".into(),
            agg: SignalAgg::Velocity,
            window: Window::AllTime,
            weight: 1.0,
        });
        assert!(registry.register(builtin).is_ok());
    }

    #[test]
    fn override_register_rejects_unknown_signal_when_schema_bound() {
        let mut registry = ProfileRegistry::new();
        registry.with_schema(schema_with("digest_open"));

        let mut profile = minimal_profile("for_you", 1);
        profile.boosts.push(Boost {
            signal: "not_a_signal".into(),
            agg: SignalAgg::DecayScore,
            window: Window::AllTime,
            weight: 1.0,
        });
        let result = registry.override_register(profile);
        assert!(matches!(
            result,
            Err(ProfileError::UnknownSignalType { ref signal, .. }) if signal == "not_a_signal"
        ));
    }
}
