pub mod builders;
pub mod policies;
pub mod text;

use std::collections::HashMap;

pub use builders::{SchemaBuilder, SignalBuilder};
pub use policies::AgentPolicy;
pub use text::{EmbeddingSlotDef, TextFieldDef, TextFieldType};

use super::SignalTypeDef;

/// User-facing decay specification (before validation computes lambda).
///
/// Users specify `DecaySpec::Exponential { half_life }` — no lambda.
/// The `SchemaBuilder` validates the duration and computes `DecayModel::Exponential { half_life, lambda }`.
#[derive(Debug, Clone)]
pub enum DecaySpec {
    /// Weight halves every `half_life`.
    Exponential { half_life: std::time::Duration },
    /// Weight drops linearly to zero over `lifetime`.
    Linear { lifetime: std::time::Duration },
    /// Never decays. Used for permanent flags: hide, block, follow.
    Permanent,
}

/// A validated, immutable schema.
///
/// Constructed exclusively through `SchemaBuilder`. Once built, the schema
/// is frozen — signal type definitions cannot be added or modified.
#[derive(Debug, Clone)]
pub struct Schema {
    signals: HashMap<String, SignalTypeDef>,
    embedding_slots: Vec<EmbeddingSlotDef>,
    text_fields: Vec<TextFieldDef>,
    creator_text_fields: Vec<TextFieldDef>,
    policies: HashMap<String, AgentPolicy>,
}

impl Schema {
    /// Construct a `Schema` from validated components.
    ///
    /// This is `pub(super)` so only the builder (within the validation module)
    /// can construct it -- external callers must go through `SchemaBuilder`.
    #[allow(clippy::missing_const_for_fn)] // HashMap prevents const
    pub(super) fn new(
        signals: HashMap<String, SignalTypeDef>,
        embedding_slots: Vec<EmbeddingSlotDef>,
        text_fields: Vec<TextFieldDef>,
        creator_text_fields: Vec<TextFieldDef>,
        policies: HashMap<String, AgentPolicy>,
    ) -> Self {
        Self {
            signals,
            embedding_slots,
            text_fields,
            creator_text_fields,
            policies,
        }
    }

    /// Look up a signal type definition by name.
    #[must_use]
    pub fn signal(&self, name: &str) -> Option<&SignalTypeDef> {
        self.signals.get(name)
    }

    /// Iterate over all signal type definitions.
    pub fn signals(&self) -> impl Iterator<Item = &SignalTypeDef> {
        self.signals.values()
    }

    /// Number of signal types defined.
    #[must_use]
    pub fn signal_count(&self) -> usize {
        self.signals.len()
    }

    /// Whether this schema declares at least one positive-engagement signal.
    ///
    /// When `true`, the engine treats the schema's `is_positive_engagement()`
    /// flags as the authoritative source for preference-vector folding. When
    /// `false` (no signal opted in), the engine falls back to its built-in
    /// default set so apps predating the flag keep their behavior. See
    /// `db::signals::signal_with_context`.
    #[must_use]
    pub fn positive_engagement_enabled(&self) -> bool {
        self.signals
            .values()
            .any(SignalTypeDef::is_positive_engagement)
    }

    /// Embedding slot definitions declared in this schema.
    #[must_use]
    pub fn embedding_slots(&self) -> &[EmbeddingSlotDef] {
        &self.embedding_slots
    }

    /// Embedding slots in a stable, fingerprint-friendly form.
    ///
    /// Returns `(slot_name, entity_kind, dimensions)` tuples sorted
    /// deterministically by `(name, entity_kind)`. The schema fingerprint
    /// (`db::schema_fingerprint`) MUST hash these so that a dimension change on
    /// reopen is rejected as a `SchemaMismatch` rather than silently accepted
    /// while persisted vectors keep the old dimensionality. `(name, kind)` is
    /// the slot uniqueness key (enforced at `build()`), so the sort key is
    /// total and the ordering is reproducible across runs and platforms.
    #[must_use]
    pub fn embedding_slot_fingerprint(&self) -> Vec<(&str, super::EntityKind, usize)> {
        let mut slots: Vec<(&str, super::EntityKind, usize)> = self
            .embedding_slots
            .iter()
            .map(|s| (s.name.as_str(), s.entity_kind, s.dimensions))
            .collect();
        // Sort by (name, entity_kind discriminant). entity_kind is part of the
        // uniqueness key, so this comparator is a total order with no ties. The
        // discriminant comes from the single canonical
        // `EntityKind::fingerprint_byte`, so this sort and the hashed fingerprint
        // bytes can never drift.
        slots.sort_by(|a, b| {
            a.0.cmp(b.0)
                .then_with(|| a.1.fingerprint_byte().cmp(&b.1.fingerprint_byte()))
        });
        slots
    }

    /// Text field definitions declared in this schema for full-text search (items).
    #[must_use]
    pub fn text_fields(&self) -> &[TextFieldDef] {
        &self.text_fields
    }

    /// Text field definitions declared in this schema for creator full-text search.
    #[must_use]
    pub fn creator_text_fields(&self) -> &[TextFieldDef] {
        &self.creator_text_fields
    }

    /// Look up a session policy by name.
    #[must_use]
    pub fn session_policy(&self, name: &str) -> Option<&AgentPolicy> {
        self.policies.get(name)
    }

    /// Iterate over all session policies.
    pub fn session_policies(&self) -> impl Iterator<Item = (&str, &AgentPolicy)> {
        self.policies.iter().map(|(k, v)| (k.as_str(), v))
    }
}

/// Check if a signal name is a valid identifier.
///
/// Must be non-empty, ASCII, lowercase alphanumeric + underscore,
/// and start with a letter. Safe for use in storage keys
/// (`SIG:{name}:{window}`) and the query language.
fn is_valid_signal_name(name: &str) -> bool {
    !name.is_empty()
        && name.is_ascii()
        && name
            .bytes()
            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
        && name.as_bytes()[0].is_ascii_lowercase()
}

#[cfg(test)]
#[allow(unused_must_use, clippy::unwrap_used)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::schema::{EntityKind, Window};

    // === Signal name validation ===

    #[test]
    fn is_valid_signal_name_unit() {
        assert!(is_valid_signal_name("view"));
        assert!(is_valid_signal_name("a"));
        assert!(is_valid_signal_name("view_count"));
        assert!(is_valid_signal_name("signal_24h"));

        assert!(!is_valid_signal_name(""));
        assert!(!is_valid_signal_name("View"));
        assert!(!is_valid_signal_name("1view"));
        assert!(!is_valid_signal_name("_view"));
        assert!(!is_valid_signal_name("view count"));
        assert!(!is_valid_signal_name("view-count"));
        assert!(!is_valid_signal_name("view!"));
    }

    // === UAT-style integration test ===

    #[test]
    fn milestone_1_uat_schema() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600), // 7 days
                },
            )
            .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
            .velocity(true)
            .add();
        builder
            .signal(
                "like",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(14 * 24 * 3600), // 14 days
                },
            )
            .windows(&[Window::TwentyFourHours, Window::SevenDays, Window::AllTime])
            .velocity(true)
            .add();
        builder
            .signal(
                "skip",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(24 * 3600), // 1 day
                },
            )
            .windows(&[Window::OneHour, Window::TwentyFourHours])
            .velocity(false)
            .add();

        let schema = builder.build().expect("UAT schema should be valid");
        assert_eq!(schema.signal_count(), 3);

        // Verify view signal
        let view = schema.signal("view").unwrap();
        assert_eq!(view.windows().len(), 3);
        assert!(view.velocity_enabled());
        let lambda = view.decay().lambda().unwrap();
        let expected_lambda = std::f64::consts::LN_2 / (7.0 * 24.0 * 3600.0);
        assert!((lambda - expected_lambda).abs() < 1e-15);

        // Verify like signal
        let like = schema.signal("like").unwrap();
        assert_eq!(like.windows().len(), 3);
        assert!(like.windows().contains(&Window::AllTime));

        // Verify skip signal
        let skip = schema.signal("skip").unwrap();
        assert!(!skip.velocity_enabled());
        let skip_lambda = skip.decay().lambda().unwrap();
        let expected_skip_lambda = std::f64::consts::LN_2 / (24.0 * 3600.0);
        assert!((skip_lambda - expected_skip_lambda).abs() < 1e-15);
    }

    // === Schema query API ===

    #[test]
    fn schema_signal_returns_none_for_missing() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal("view", EntityKind::Item, DecaySpec::Permanent)
            .add();
        let schema = builder.build().unwrap();
        assert!(schema.signal("nonexistent").is_none());
    }

    // === Embedding-slot fingerprint accessor ===

    #[test]
    fn embedding_slot_fingerprint_is_sorted_and_includes_dimensions() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal("view", EntityKind::Item, DecaySpec::Permanent)
            .add();
        // Declare slots out of order; the accessor must sort deterministically.
        builder.embedding_slot("video", EntityKind::Item, 512);
        builder.embedding_slot("content", EntityKind::Creator, 768);
        builder.embedding_slot("content", EntityKind::Item, 1024);
        let schema = builder.build().unwrap();

        let fp = schema.embedding_slot_fingerprint();
        assert_eq!(
            fp,
            vec![
                ("content", EntityKind::Item, 1024),
                ("content", EntityKind::Creator, 768),
                ("video", EntityKind::Item, 512),
            ]
        );
    }

    #[test]
    fn embedding_slot_fingerprint_changes_with_dimensions() {
        let build_with_dim = |dim: usize| {
            let mut b = SchemaBuilder::new();
            b.signal("view", EntityKind::Item, DecaySpec::Permanent)
                .add();
            b.embedding_slot("content", EntityKind::Item, dim);
            b.build().unwrap()
        };
        let a = build_with_dim(768);
        let b = build_with_dim(512);
        // The whole point: a dimension change is visible to the fingerprint.
        assert_ne!(
            a.embedding_slot_fingerprint(),
            b.embedding_slot_fingerprint()
        );
    }

    #[test]
    fn embedding_slot_fingerprint_order_independent() {
        let schema_a = {
            let mut b = SchemaBuilder::new();
            b.signal("view", EntityKind::Item, DecaySpec::Permanent)
                .add();
            b.embedding_slot("a", EntityKind::Item, 64);
            b.embedding_slot("b", EntityKind::Item, 128);
            b.build().unwrap()
        };
        let schema_b = {
            let mut b = SchemaBuilder::new();
            b.signal("view", EntityKind::Item, DecaySpec::Permanent)
                .add();
            b.embedding_slot("b", EntityKind::Item, 128);
            b.embedding_slot("a", EntityKind::Item, 64);
            b.build().unwrap()
        };
        // Declaration order must not change the fingerprint form.
        assert_eq!(
            schema_a.embedding_slot_fingerprint(),
            schema_b.embedding_slot_fingerprint()
        );
    }

    // === Property tests ===

    mod proptests {
        use proptest::prelude::*;

        use super::*;

        proptest! {
            #[test]
            fn signal_name_validation_consistent(name in "\\PC{0,100}") {
                let valid = is_valid_signal_name(&name);
                let expected = !name.is_empty()
                    && name.is_ascii()
                    && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
                    && name.as_bytes()[0].is_ascii_lowercase();
                prop_assert_eq!(valid, expected);
            }

            #[test]
            fn schema_contains_all_defined_signals(count in 1usize..10) {
                let mut builder = SchemaBuilder::new();
                let names: Vec<String> = (0..count)
                    .map(|i| format!("signal_{i}"))
                    .collect();

                for name in &names {
                    builder.signal(name, EntityKind::Item, DecaySpec::Permanent).add();
                }

                let schema = builder.build().unwrap();
                prop_assert_eq!(schema.signal_count(), count);
                for name in &names {
                    prop_assert!(schema.signal(name).is_some());
                }
            }
        }
    }
}
