use std::collections::HashMap;

use super::{
    DecaySpec, Schema,
    policies::{AgentPolicy, PolicyEntry},
    text::{EmbeddingSlotDef, TextFieldDef, TextFieldType},
};
use crate::schema::{DecayModel, EntityKind, SignalTypeDef, Window, WindowSet, error::SchemaError};

/// Smallest embedding dimensionality the schema accepts.
///
/// A 0-dimension slot cannot hold a vector and a 1-dimension slot is a
/// degenerate index (every vector is colinear), so the documented contract
/// (`EmbeddingSlot` spec, `11-schema.md` V-E04) requires at least 2.
pub(crate) const MIN_EMBEDDING_DIMENSIONS: usize = 2;

/// Largest embedding dimensionality the schema accepts.
///
/// Bounds the per-vector allocation at define-time rather than letting an
/// arbitrarily huge `usize` surface as an allocation failure on first insert,
/// far from the schema call that caused it. 4096 covers every embedding model
/// in current use; lift this deliberately (and update the spec) if a larger
/// model is needed.
pub(crate) const MAX_EMBEDDING_DIMENSIONS: usize = 4096;

/// Internal entry for a signal being built.
#[derive(Debug)]
struct SignalEntry {
    name: String,
    target: EntityKind,
    decay: DecaySpec,
    windows: Vec<Window>,
    velocity: bool,
    positive_engagement: bool,
}

/// Builder for constructing a validated `Schema`.
///
/// # Example
///
/// ```ignore
/// let mut builder = SchemaBuilder::new();
/// builder.signal("view", EntityKind::Item, DecaySpec::Exponential {
///     half_life: Duration::from_secs(604_800),
/// })
///     .windows(&[Window::OneHour, Window::TwentyFourHours])
///     .velocity(true)
///     .add();
/// let schema = builder.build()?;
/// ```
#[derive(Debug)]
pub struct SchemaBuilder {
    entries: Vec<SignalEntry>,
    embedding_slots: Vec<EmbeddingSlotDef>,
    text_fields: Vec<TextFieldDef>,
    creator_text_fields: Vec<TextFieldDef>,
    policies: Vec<PolicyEntry>,
}

impl SchemaBuilder {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            entries: Vec::new(),
            embedding_slots: Vec::new(),
            text_fields: Vec::new(),
            creator_text_fields: Vec::new(),
            policies: Vec::new(),
        }
    }

    /// Declare an embedding vector slot.
    pub fn embedding_slot(
        &mut self,
        name: &str,
        entity_kind: EntityKind,
        dimensions: usize,
    ) -> &mut Self {
        self.embedding_slots.push(EmbeddingSlotDef {
            name: name.to_owned(),
            entity_kind,
            dimensions,
        });
        self
    }

    /// Declare a text field for full-text search indexing (items).
    ///
    /// Each text field maps a metadata key to a Tantivy indexing mode.
    /// `TextFieldType::Text` enables tokenized full-text search;
    /// `TextFieldType::Keyword` enables exact-match filtering.
    pub fn text_field(&mut self, key: &str, field_type: TextFieldType) -> &mut Self {
        self.text_fields.push(TextFieldDef {
            key: key.to_owned(),
            field_type,
        });
        self
    }

    /// Declare a text field for creator full-text search indexing.
    ///
    /// Same semantics as `text_field()` but populates the creator Tantivy index.
    /// Use for creator metadata keys like "name", "handle", "bio".
    pub fn creator_text_field(&mut self, key: &str, field_type: TextFieldType) -> &mut Self {
        self.creator_text_fields.push(TextFieldDef {
            key: key.to_owned(),
            field_type,
        });
        self
    }

    /// Declare a session policy for agent sessions.
    ///
    /// Policies are validated at `build()` time: all signal names in
    /// `allowed_signals` and `denied_signals` must exist in the schema.
    ///
    /// Call `session_policy()` after all `signal()` declarations, or validation
    /// at `build()` time will catch any missing signal references.
    pub fn session_policy(&mut self, name: &str, policy: AgentPolicy) -> &mut Self {
        self.policies.push(PolicyEntry {
            name: name.to_owned(),
            policy,
        });
        self
    }

    /// Register the `viewer_count` signal for live content ranking.
    ///
    /// Exponential decay with 5-minute half-life, no windowed aggregation,
    /// no velocity. Used by the `LiveViewerCount` sort mode to rank items
    /// by current live audience size.
    ///
    /// This is a convenience method; the signal can also be registered
    /// manually via `signal("viewer_count", ...)`.
    pub fn with_viewer_count_signal(&mut self) -> &mut Self {
        let _ = self
            .signal(
                "viewer_count",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: std::time::Duration::from_secs(300), // 5 minutes
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        self
    }

    /// Begin defining a signal type. Returns a `SignalBuilder` for configuring
    /// windows and velocity before calling `.add()`.
    pub fn signal(
        &mut self,
        name: &str,
        target: EntityKind,
        decay: DecaySpec,
    ) -> SignalBuilder<'_> {
        SignalBuilder {
            builder: self,
            entry: SignalEntry {
                name: name.to_owned(),
                target,
                decay,
                windows: Vec::new(),
                velocity: false,
                positive_engagement: false,
            },
        }
    }

    /// Validate all entries and produce an immutable `Schema`.
    ///
    /// # Errors
    ///
    /// Returns `SchemaError` if any validation rule fails:
    /// - `NoSignalsDefined` if no signals were added
    /// - `InvalidSignalName` if a name is not a valid identifier
    /// - `DuplicateSignalName` if two signals share a name
    /// - `InvalidHalfLife` if exponential decay has zero/negative half-life
    /// - `InvalidLifetime` if linear decay has zero/negative lifetime
    /// - `EmptyWindows` if a non-permanent signal has no windows
    /// - `VelocityWithoutWindows` if velocity is enabled without windows
    /// - `InvalidEmbeddingDimensions` if an embedding slot's dimensions are
    ///   outside the documented `[2, 4096]` range
    /// - `EmptySlotName` if an embedding slot name is empty
    /// - `InvalidSlotName` if an embedding slot name is not a valid identifier
    /// - `DuplicateEmbeddingSlot` if a `(name, kind)` pair is declared twice
    /// - `InvalidPolicyLimit` if a session policy has a zero `max_session_duration`
    /// - `InvalidTextField` if a text-field key is empty
    /// - `DuplicateTextFieldKey` if two text fields in the same set share a key
    /// - `ReservedTextFieldKey` if a text field uses the reserved `entity_id` key
    #[allow(clippy::too_many_lines)] // linear validation passes (signals, policies, embeddings, text fields)
    pub fn build(self) -> Result<Schema, SchemaError> {
        if self.entries.is_empty() {
            return Err(SchemaError::NoSignalsDefined);
        }

        let mut signals = HashMap::with_capacity(self.entries.len());

        for entry in self.entries {
            // Name validation
            if !super::is_valid_signal_name(&entry.name) {
                return Err(SchemaError::InvalidSignalName(entry.name));
            }

            // Duplicate check
            if signals.contains_key(&entry.name) {
                return Err(SchemaError::DuplicateSignalName(entry.name));
            }

            // Decay-specific validation and conversion
            let decay_model = match &entry.decay {
                DecaySpec::Exponential { half_life } => {
                    // `Duration::as_secs_f64` is always finite and non-negative,
                    // so `secs == 0.0` (a zero half-life) is the only reachable
                    // rejection here; no `is_finite` guard is needed.
                    let secs = half_life.as_secs_f64();
                    if secs <= 0.0 {
                        return Err(SchemaError::InvalidHalfLife {
                            signal_name: entry.name,
                            half_life_secs: secs,
                        });
                    }
                    DecayModel::exponential(*half_life)
                }
                DecaySpec::Linear { lifetime } => {
                    // `Duration::as_secs_f64` is always finite and non-negative,
                    // so `secs == 0.0` (a zero lifetime) is the only reachable
                    // rejection here; no `is_finite` guard is needed.
                    let secs = lifetime.as_secs_f64();
                    if secs <= 0.0 {
                        return Err(SchemaError::InvalidLifetime {
                            signal_name: entry.name,
                            lifetime_secs: secs,
                        });
                    }
                    DecayModel::linear(*lifetime)
                }
                DecaySpec::Permanent => DecayModel::Permanent,
            };

            // Window check for non-permanent signals
            let is_permanent = matches!(entry.decay, DecaySpec::Permanent);
            if !is_permanent && entry.windows.is_empty() {
                return Err(SchemaError::EmptyWindows {
                    signal_name: entry.name,
                });
            }

            // Velocity check
            if entry.velocity && entry.windows.is_empty() {
                return Err(SchemaError::VelocityWithoutWindows {
                    signal_name: entry.name,
                });
            }

            // Construct validated types
            let windows = WindowSet::new(&entry.windows);
            let signal_def = SignalTypeDef::new(
                entry.name.clone(),
                entry.target,
                decay_model,
                windows,
                entry.velocity,
                entry.positive_engagement,
            );

            signals.insert(entry.name, signal_def);
        }

        // Validate and collect session policies.
        let mut seen_policy_names = std::collections::HashSet::new();
        let mut policies = HashMap::new();
        for entry in self.policies {
            if !super::is_valid_signal_name(&entry.name) {
                return Err(SchemaError::InvalidPolicyName(entry.name));
            }
            if !seen_policy_names.insert(entry.name.clone()) {
                return Err(SchemaError::DuplicatePolicyName(entry.name));
            }
            // Validate all signal names referenced in the policy exist in schema.
            for sig in entry
                .policy
                .allowed_signals
                .iter()
                .chain(entry.policy.denied_signals.iter())
            {
                if !signals.contains_key(sig) {
                    return Err(SchemaError::PolicySignalNotInSchema {
                        policy: entry.name.clone(),
                        signal: sig.clone(),
                    });
                }
            }
            // Validate no signal appears in both allow and deny lists.
            for sig in &entry.policy.allowed_signals {
                if entry.policy.denied_signals.contains(sig) {
                    return Err(SchemaError::PolicySignalConflict {
                        policy: entry.name.clone(),
                        signal: sig.clone(),
                    });
                }
            }
            // Validate numeric limits. A `Duration::ZERO` session duration would
            // expire every session the instant it starts (see
            // `session::policy::check`), silently making the policy unusable.
            // `max_signals_per_session == 0` is intentionally NOT rejected: it
            // is the documented "unlimited" sentinel (`AgentPolicy` field doc),
            // honored by the count-cap check (`if max_signals_per_session > 0`).
            if entry.policy.max_session_duration.is_zero() {
                return Err(SchemaError::InvalidPolicyLimit {
                    policy: entry.name,
                    field: "max_session_duration",
                    reason: "must be greater than zero; a zero duration expires \
                             every session immediately"
                        .to_owned(),
                });
            }
            policies.insert(entry.name, entry.policy);
        }

        // Validate embedding slots. The vector registry keys slots by
        // `(name, entity_kind)`, so an empty name (unaddressable) or a duplicate
        // pair would silently shadow an earlier slot's dimensions; a
        // dimension outside [MIN_EMBEDDING_DIMENSIONS, MAX_EMBEDDING_DIMENSIONS]
        // cannot hold a usable vector. These are developer errors that must
        // surface here as typed `SchemaError`s rather than as broken-but-silent
        // state discovered at the first ANN query.
        let mut seen_slots = std::collections::HashSet::new();
        for slot in &self.embedding_slots {
            if slot.name.is_empty() {
                return Err(SchemaError::EmptySlotName {
                    entity_kind: slot.entity_kind,
                });
            }
            // Slot names share the slot fingerprint's byte stream with a
            // fixed-width `[kind:1][dims:8]` trailer; an unrestricted name could
            // embed the kind byte and (in principle) let two distinct
            // (name, kind) pairs hash to the same concatenated bytes. Hold slot
            // names to the same identifier rule as signal/policy names so the
            // name is a guaranteed-non-control `[a-z0-9_]` token and the
            // fingerprint split is unambiguous.
            if !super::is_valid_signal_name(&slot.name) {
                return Err(SchemaError::InvalidSlotName {
                    slot: slot.name.clone(),
                    entity_kind: slot.entity_kind,
                });
            }
            // Eager, complete range gate: a definition that passes validation is
            // guaranteed self-consistent. Dimension 0/1 is a degenerate index and
            // an enormous dimension only surfaces as an allocation/insert failure
            // deep in the ANN path; reject both at the schema boundary instead.
            if !(MIN_EMBEDDING_DIMENSIONS..=MAX_EMBEDDING_DIMENSIONS).contains(&slot.dimensions) {
                return Err(SchemaError::InvalidEmbeddingDimensions {
                    slot: slot.name.clone(),
                    dimensions: slot.dimensions,
                });
            }
            if !seen_slots.insert((slot.name.as_str(), slot.entity_kind)) {
                return Err(SchemaError::DuplicateEmbeddingSlot {
                    slot: slot.name.clone(),
                    entity_kind: slot.entity_kind,
                });
            }
        }

        // Validate text fields for both the item and creator sets. A duplicate
        // key within a set, or the reserved `entity_id` key, makes Tantivy's
        // `SchemaBuilder::build()` panic at DB open (`add_field` panics on a
        // name collision). Catch these at define-time so the failure is a typed
        // error here, never a panic deep inside the text index.
        for set in [&self.text_fields, &self.creator_text_fields] {
            validate_text_field_set(set)?;
        }

        Ok(Schema::new(
            signals,
            self.embedding_slots,
            self.text_fields,
            self.creator_text_fields,
            policies,
        ))
    }
}

/// Validate a single text-field set (item or creator).
///
/// Rejects empty keys, duplicate keys within the set, and the reserved
/// `entity_id` key (which collides with the internal u64 field that every
/// Tantivy text index carries and would otherwise panic at index build).
fn validate_text_field_set(fields: &[TextFieldDef]) -> Result<(), SchemaError> {
    let mut seen_keys = std::collections::HashSet::new();
    for field in fields {
        if field.key.is_empty() {
            return Err(SchemaError::InvalidTextField(field.key.clone()));
        }
        // Compare against the single source of truth for the internal Tantivy
        // field name. Referencing `text::index::ENTITY_ID_FIELD` directly (not a
        // copied literal) means a rename there cannot leave this guard protecting
        // a stale name while the new name silently becomes user-declarable.
        if field.key == crate::text::index::ENTITY_ID_FIELD {
            return Err(SchemaError::ReservedTextFieldKey(field.key.clone()));
        }
        if !seen_keys.insert(field.key.as_str()) {
            return Err(SchemaError::DuplicateTextFieldKey(field.key.clone()));
        }
    }
    Ok(())
}

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

/// Intermediate builder for configuring a single signal type.
///
/// Created by `SchemaBuilder::signal()`. Call `.windows()` and `.velocity()`
/// to configure, then `.add()` to finalize and return to the schema builder.
#[derive(Debug)]
#[must_use = "call .add() to include this signal in the schema"]
pub struct SignalBuilder<'a> {
    builder: &'a mut SchemaBuilder,
    entry: SignalEntry,
}

impl<'a> SignalBuilder<'a> {
    /// Set the time windows for this signal.
    pub fn windows(mut self, windows: &[Window]) -> Self {
        self.entry.windows = windows.to_vec();
        self
    }

    /// Enable or disable velocity computation.
    pub const fn velocity(mut self, enabled: bool) -> Self {
        self.entry.velocity = enabled;
        self
    }

    /// Mark this signal as a *positive engagement* signal.
    ///
    /// When set, the arrival of this signal (via `signal_with_context` with a
    /// known user) folds the signalled item's content embedding into the user's
    /// preference vector. Leave `false` (the default) for neutral or negative
    /// signals — e.g. a "dismiss" must NOT pull taste toward the dismissed item.
    ///
    /// If a schema declares at least one positive-engagement signal, the engine
    /// uses these declarations exclusively. If a schema declares none, the engine
    /// falls back to a built-in default set for back-compat
    /// (`db::metadata::is_positive_engagement_signal`).
    pub const fn positive_engagement(mut self, enabled: bool) -> Self {
        self.entry.positive_engagement = enabled;
        self
    }

    /// Finalize this signal definition and return to the schema builder.
    #[must_use]
    pub fn add(self) -> &'a mut SchemaBuilder {
        self.builder.entries.push(self.entry);
        self.builder
    }
}

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

    use super::*;
    use crate::schema::Window;

    // === Validation: valid schemas ===

    #[test]
    fn valid_schema_round_trip() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(604_800),
                },
            )
            .windows(&[
                Window::OneHour,
                Window::TwentyFourHours,
                Window::SevenDays,
                Window::AllTime,
            ])
            .velocity(true)
            .add();
        builder
            .signal("hide", EntityKind::Item, DecaySpec::Permanent)
            .add();

        let schema = builder.build().expect("valid schema");

        assert_eq!(schema.signal_count(), 2);

        let view = schema.signal("view").unwrap();
        assert_eq!(view.name(), "view");
        assert_eq!(view.target(), EntityKind::Item);
        assert!(view.velocity_enabled());
        assert_eq!(view.windows().len(), 4);
        assert!(view.decay().lambda().is_some());

        let hide = schema.signal("hide").unwrap();
        assert_eq!(hide.windows().len(), 0);
        assert!(!hide.velocity_enabled());
        assert_eq!(*hide.decay(), DecayModel::Permanent);
    }

    #[test]
    fn accepts_permanent_with_empty_windows() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal("hide", EntityKind::Item, DecaySpec::Permanent)
            .add();
        let result = builder.build();
        assert!(result.is_ok());
    }

    #[test]
    fn accepts_valid_signal_names() {
        let names = [
            "view",
            "like",
            "skip",
            "hide",
            "search_click",
            "autoplay_accept",
            "view_24h",
        ];
        for name in names {
            let mut builder = SchemaBuilder::new();
            builder
                .signal(name, EntityKind::Item, DecaySpec::Permanent)
                .add();
            let r = builder.build();
            assert!(r.is_ok(), "should accept signal name '{name}'");
        }
    }

    #[test]
    fn accepts_linear_decay() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "impression",
                EntityKind::Item,
                DecaySpec::Linear {
                    lifetime: Duration::from_secs(86_400),
                },
            )
            .windows(&[Window::TwentyFourHours])
            .add();
        let schema = builder.build().expect("valid linear decay schema");
        let sig = schema.signal("impression").unwrap();
        assert!(sig.decay().lambda().is_none());
    }

    // === Validation: rejections ===

    #[test]
    fn rejects_duplicate_signal_name() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(604_800),
                },
            )
            .windows(&[Window::AllTime])
            .add();
        builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(86_400),
                },
            )
            .windows(&[Window::AllTime])
            .add();
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::DuplicateSignalName(ref name)) if name == "view"
        ));
    }

    #[test]
    fn rejects_zero_half_life() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "bad",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::ZERO,
                },
            )
            .windows(&[Window::AllTime])
            .add();
        let result = builder.build();
        assert!(matches!(result, Err(SchemaError::InvalidHalfLife { .. })));
    }

    #[test]
    fn rejects_zero_linear_lifetime() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "bad",
                EntityKind::Item,
                DecaySpec::Linear {
                    lifetime: Duration::ZERO,
                },
            )
            .windows(&[Window::AllTime])
            .add();
        let result = builder.build();
        assert!(matches!(result, Err(SchemaError::InvalidLifetime { .. })));
    }

    #[test]
    fn rejects_empty_windows_on_exponential() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "bad",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(3600),
                },
            )
            .add(); // no windows
        let result = builder.build();
        assert!(matches!(result, Err(SchemaError::EmptyWindows { .. })));
    }

    #[test]
    fn rejects_empty_windows_on_linear() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal(
                "bad",
                EntityKind::Item,
                DecaySpec::Linear {
                    lifetime: Duration::from_secs(3600),
                },
            )
            .add(); // no windows
        let result = builder.build();
        assert!(matches!(result, Err(SchemaError::EmptyWindows { .. })));
    }

    #[test]
    fn rejects_velocity_without_windows() {
        let mut builder = SchemaBuilder::new();
        builder
            .signal("bad", EntityKind::Item, DecaySpec::Permanent)
            .velocity(true)
            .add();
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::VelocityWithoutWindows { .. })
        ));
    }

    #[test]
    fn rejects_empty_schema() {
        let result = SchemaBuilder::new().build();
        assert!(matches!(result, Err(SchemaError::NoSignalsDefined)));
    }

    // === Validation: embedding slots ===

    fn item_signal(builder: &mut SchemaBuilder) {
        builder
            .signal("view", EntityKind::Item, DecaySpec::Permanent)
            .add();
    }

    #[test]
    fn accepts_valid_embedding_slot() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, 768);
        let schema = builder.build().expect("valid embedding slot");
        assert_eq!(schema.embedding_slots().len(), 1);
    }

    #[test]
    fn rejects_zero_dimension_embedding_slot() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, 0);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidEmbeddingDimensions { ref slot, dimensions: 0 })
                if slot == "content"
        ));
    }

    #[test]
    fn rejects_dimension_one_embedding_slot() {
        // dim=1 is a degenerate index; the documented contract is [2, 4096].
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, 1);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidEmbeddingDimensions { ref slot, dimensions: 1 })
                if slot == "content"
        ));
    }

    #[test]
    fn rejects_dimension_above_ceiling_embedding_slot() {
        // One past the documented ceiling must be rejected eagerly at the schema
        // boundary, not surface as an allocation failure on first insert.
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, MAX_EMBEDDING_DIMENSIONS + 1);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidEmbeddingDimensions { ref slot, dimensions })
                if slot == "content" && dimensions == MAX_EMBEDDING_DIMENSIONS + 1
        ));
    }

    #[test]
    fn accepts_boundary_embedding_dimensions() {
        // Both ends of the inclusive [2, 4096] range are accepted.
        for dim in [MIN_EMBEDDING_DIMENSIONS, MAX_EMBEDDING_DIMENSIONS] {
            let mut builder = SchemaBuilder::new();
            item_signal(&mut builder);
            builder.embedding_slot("content", EntityKind::Item, dim);
            let schema = builder
                .build()
                .unwrap_or_else(|e| panic!("dim {dim} must be accepted: {e}"));
            assert_eq!(schema.embedding_slots().len(), 1);
        }
    }

    #[test]
    fn rejects_invalid_slot_name() {
        // Slot names are held to the identifier rule applied to signal/policy
        // names: control bytes, uppercase, and leading digits are all rejected.
        let invalid = [
            "Content",
            "1content",
            "con tent",
            "con-tent",
            "con\u{0}tent",
        ];
        for name in invalid {
            let mut builder = SchemaBuilder::new();
            item_signal(&mut builder);
            builder.embedding_slot(name, EntityKind::Item, 768);
            let result = builder.build();
            assert!(
                matches!(
                    result,
                    Err(SchemaError::InvalidSlotName {
                        entity_kind: EntityKind::Item,
                        ..
                    })
                ),
                "should reject slot name {name:?}"
            );
        }
    }

    #[test]
    fn rejects_empty_embedding_slot_name() {
        // An empty slot name is its own error, not the misleading
        // `DuplicateEmbeddingSlot` it was previously reported as.
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("", EntityKind::Item, 768);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::EmptySlotName {
                entity_kind: EntityKind::Item
            })
        ));
    }

    #[test]
    fn rejects_duplicate_embedding_slot_same_kind() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, 768);
        builder.embedding_slot("content", EntityKind::Item, 512);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::DuplicateEmbeddingSlot { ref slot, entity_kind: EntityKind::Item })
                if slot == "content"
        ));
    }

    #[test]
    fn accepts_same_slot_name_different_entity_kinds() {
        // (name, entity_kind) is the uniqueness key, so the same name on a
        // different kind is allowed.
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.embedding_slot("content", EntityKind::Item, 768);
        builder.embedding_slot("content", EntityKind::Creator, 768);
        let schema = builder.build().expect("distinct kinds are not duplicates");
        assert_eq!(schema.embedding_slots().len(), 2);
    }

    // === Validation: text fields ===

    #[test]
    fn accepts_valid_text_fields() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field("title", TextFieldType::Text);
        builder.text_field("category", TextFieldType::Keyword);
        builder.creator_text_field("handle", TextFieldType::Keyword);
        let schema = builder.build().expect("valid text fields");
        assert_eq!(schema.text_fields().len(), 2);
        assert_eq!(schema.creator_text_fields().len(), 1);
    }

    #[test]
    fn rejects_empty_text_field_key() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field("", TextFieldType::Text);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidTextField(ref k)) if k.is_empty()
        ));
    }

    #[test]
    fn rejects_duplicate_text_field_key() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field("title", TextFieldType::Text);
        builder.text_field("title", TextFieldType::Keyword);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::DuplicateTextFieldKey(ref k)) if k == "title"
        ));
    }

    #[test]
    fn rejects_duplicate_creator_text_field_key() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.creator_text_field("bio", TextFieldType::Text);
        builder.creator_text_field("bio", TextFieldType::Text);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::DuplicateTextFieldKey(ref k)) if k == "bio"
        ));
    }

    #[test]
    fn rejects_reserved_entity_id_text_field() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field("entity_id", TextFieldType::Keyword);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::ReservedTextFieldKey(ref k)) if k == "entity_id"
        ));
    }

    #[test]
    fn rejects_reserved_entity_id_creator_text_field() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.creator_text_field("entity_id", TextFieldType::Keyword);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::ReservedTextFieldKey(ref k)) if k == "entity_id"
        ));
    }

    #[test]
    fn rejects_reserved_field_sourced_from_text_ssot() {
        // The guard must reject whatever name the text index actually reserves
        // (text::index::ENTITY_ID_FIELD), not a copied literal. Driving the test
        // from the SSOT means a future rename of the Tantivy field keeps the
        // guard honest without a second edit here.
        let reserved = crate::text::index::ENTITY_ID_FIELD;
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field(reserved, TextFieldType::Keyword);
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::ReservedTextFieldKey(ref k)) if k == reserved
        ));
    }

    #[test]
    fn item_and_creator_sets_validate_independently() {
        // The same key in the item set and the creator set is NOT a duplicate;
        // each set maps to its own Tantivy index.
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.text_field("name", TextFieldType::Text);
        builder.creator_text_field("name", TextFieldType::Text);
        let schema = builder.build().expect("cross-set keys are independent");
        assert_eq!(schema.text_fields().len(), 1);
        assert_eq!(schema.creator_text_fields().len(), 1);
    }

    // === Validation: session policies ===

    /// Helper: a minimal `AgentPolicy` with the given allow/deny signal lists.
    fn policy_with(allowed: &[&str], denied: &[&str]) -> super::AgentPolicy {
        super::AgentPolicy {
            allowed_signals: allowed.iter().map(|s| (*s).to_owned()).collect(),
            denied_signals: denied.iter().map(|s| (*s).to_owned()).collect(),
            max_session_duration: Duration::from_secs(3600),
            max_signals_per_session: 10_000,
        }
    }

    #[test]
    fn accepts_valid_session_policy() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder); // declares "view"
        builder
            .signal("reward", EntityKind::Item, DecaySpec::Permanent)
            .add();
        builder.session_policy("agent", policy_with(&["view"], &["reward"]));
        let schema = builder.build().expect("valid policy should survive build");
        assert!(schema.session_policy("agent").is_some());
    }

    #[test]
    fn rejects_invalid_policy_name() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        // "Bad-Name" violates the lowercase-identifier rule.
        builder.session_policy("Bad-Name", policy_with(&[], &[]));
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidPolicyName(ref n)) if n == "Bad-Name"
        ));
    }

    #[test]
    fn rejects_duplicate_policy_name() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.session_policy("agent", policy_with(&[], &[]));
        builder.session_policy("agent", policy_with(&[], &[]));
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::DuplicatePolicyName(ref n)) if n == "agent"
        ));
    }

    #[test]
    fn rejects_policy_signal_not_in_schema() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder); // only "view" exists
        builder.session_policy("agent", policy_with(&["nonexistent"], &[]));
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::PolicySignalNotInSchema { ref policy, ref signal })
                if policy == "agent" && signal == "nonexistent"
        ));
    }

    #[test]
    fn rejects_policy_signal_conflict() {
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder); // declares "view"
        // "view" appears in both the allow and deny lists.
        builder.session_policy("agent", policy_with(&["view"], &["view"]));
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::PolicySignalConflict { ref policy, ref signal })
                if policy == "agent" && signal == "view"
        ));
    }

    #[test]
    fn rejects_zero_session_duration_policy() {
        // A `Duration::ZERO` session duration expires every session the instant
        // it starts, silently making the policy unusable — reject at build().
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.session_policy(
            "agent",
            super::AgentPolicy {
                allowed_signals: vec![],
                denied_signals: vec![],
                max_session_duration: Duration::ZERO,
                max_signals_per_session: 10_000,
            },
        );
        let result = builder.build();
        assert!(matches!(
            result,
            Err(SchemaError::InvalidPolicyLimit { ref policy, field, .. })
                if policy == "agent" && field == "max_session_duration"
        ));
    }

    #[test]
    fn accepts_zero_max_signals_as_unlimited() {
        // `max_signals_per_session == 0` is the documented "unlimited" sentinel,
        // not a degenerate limit — it must survive build().
        let mut builder = SchemaBuilder::new();
        item_signal(&mut builder);
        builder.session_policy(
            "agent",
            super::AgentPolicy {
                allowed_signals: vec![],
                denied_signals: vec![],
                max_session_duration: Duration::from_secs(3600),
                max_signals_per_session: 0,
            },
        );
        let schema = builder
            .build()
            .expect("zero max_signals_per_session means unlimited, not invalid");
        assert!(schema.session_policy("agent").is_some());
    }

    #[test]
    fn rejects_invalid_signal_names() {
        let invalid = [
            "",           // empty
            "View",       // uppercase
            "1view",      // starts with digit
            "view count", // space
            "view-count", // hyphen
            "_view",      // starts with underscore
            "view!",      // special character
        ];
        for name in invalid {
            let mut builder = SchemaBuilder::new();
            builder
                .signal(name, EntityKind::Item, DecaySpec::Permanent)
                .add();
            let r = builder.build();
            assert!(
                matches!(r, Err(SchemaError::InvalidSignalName(_))),
                "should reject signal name '{name}'"
            );
        }
    }
}
