use std::{
    fs,
    path::{Path, PathBuf},
    time::Duration,
};

use serde::Deserialize;
use tidaldb::{
    ranking::profile::{
        Boost, CandidateStrategy, DiversitySpec, Exclude, Gate, Penalty, ProfileDecay,
        RankingProfile, SignalAgg, Sort,
    },
    schema::{DecaySpec, EntityKind, Schema, SchemaBuilder, TextFieldType, Window},
};

use crate::error::{Result, ServerError};

const DEFAULT_SCHEMA_YAML: &str = include_str!("../config/default-schema.yaml");

/// Default schema file name expected inside a `TIDAL_CONFIG` directory.
pub const CONFIG_DIR_SCHEMA_FILE: &str = "default-schema.yaml";
/// Default topology file name expected inside a `TIDAL_CONFIG` directory.
pub const CONFIG_DIR_TOPOLOGY_FILE: &str = "default-cluster.yaml";

/// Resolve a config file path against an optional `TIDAL_CONFIG` directory.
///
/// Resolution order:
/// 1. An explicit `--schema` / `--topology` path always wins.
/// 2. Otherwise, if a config dir is set (via `TIDAL_CONFIG`), look for
///    `<dir>/<file_name>` and use it when present.
/// 3. Otherwise return `None`, so the caller falls back to the compiled-in
///    default YAML.
///
/// # Errors
///
/// Returns an error if a config dir is set and names `<dir>/<file_name>` but
/// the path is not a readable file, so a misconfigured container fails loudly
/// instead of silently serving the compiled-in defaults.
pub fn resolve_config_path(
    explicit: Option<&Path>,
    config_dir: Option<&Path>,
    file_name: &str,
) -> Result<Option<PathBuf>> {
    if let Some(path) = explicit {
        return Ok(Some(path.to_path_buf()));
    }
    config_dir.map_or(Ok(None), |dir| {
        let candidate = dir.join(file_name);
        if candidate.is_file() {
            Ok(Some(candidate))
        } else {
            Err(ServerError::SchemaConfig(format!(
                "TIDAL_CONFIG dir '{}' does not contain '{file_name}'",
                dir.display()
            )))
        }
    })
}

/// Load the tidalDB schema and ranking profiles from YAML (or the compiled-in
/// default when `path` is `None`).
///
/// # Errors
///
/// Returns [`ServerError`] if the config cannot be read, the YAML fails to
/// parse, or the schema declares no signals.
pub fn load_schema(path: Option<&Path>) -> Result<(Schema, Vec<RankingProfile>)> {
    let raw = read_config(path, DEFAULT_SCHEMA_YAML)?;
    let spec: SchemaSpec = serde_yml::from_str(&raw)
        .map_err(|e| ServerError::SchemaConfig(format!("parse schema yaml: {e}")))?;

    if spec.signals.is_empty() {
        return Err(ServerError::SchemaConfig(
            "at least one signal must be defined".into(),
        ));
    }

    // Collect signal names for profile validation.
    let signal_names: Vec<String> = spec.signals.iter().map(|s| s.name.clone()).collect();

    let mut builder = SchemaBuilder::new();
    for signal in &spec.signals {
        let mut sig = builder.signal(
            &signal.name,
            parse_entity_kind(&signal.entity)?,
            signal.decay.to_decay_spec()?,
        );
        if let Some(ref windows) = signal.windows {
            let parsed: Result<Vec<Window>> = windows.iter().map(|w| parse_window(w)).collect();
            sig = sig.windows(&parsed?);
        }
        if let Some(velocity) = signal.velocity {
            sig = sig.velocity(velocity);
        }
        if let Some(positive_engagement) = signal.positive_engagement {
            sig = sig.positive_engagement(positive_engagement);
        }
        let _ = sig.add();
    }

    if let Some(ref text_fields) = spec.text_fields {
        for field in text_fields {
            builder.text_field(&field.name, parse_text_field_type(&field.kind)?);
        }
    }

    if let Some(ref embeddings) = spec.embedding_slots {
        for slot in embeddings {
            builder.embedding_slot(
                &slot.name,
                parse_entity_kind(&slot.entity)?,
                slot.dimensions,
            );
        }
    }

    let schema = builder.build().map_err(ServerError::SchemaBuild)?;

    // Parse profiles and validate signal references.
    let profiles = if let Some(profile_specs) = spec.profiles {
        let mut parsed = Vec::with_capacity(profile_specs.len());
        for spec in profile_specs {
            let profile = spec.to_ranking_profile()?;
            validate_profile_signals(&profile, &signal_names)?;
            parsed.push(profile);
        }
        parsed
    } else {
        Vec::new()
    };

    Ok((schema, profiles))
}

fn read_config(path: Option<&Path>, fallback: &str) -> Result<String> {
    path.map_or_else(
        || Ok(fallback.to_string()),
        |p| fs::read_to_string(p).map_err(|e| ServerError::io(p, e)),
    )
}

#[derive(Debug, Deserialize)]
struct SchemaSpec {
    signals: Vec<SignalSpec>,
    #[serde(default)]
    text_fields: Option<Vec<TextFieldSpec>>,
    #[serde(default)]
    embedding_slots: Option<Vec<EmbeddingSpec>>,
    #[serde(default)]
    profiles: Option<Vec<ProfileSpec>>,
}

#[derive(Debug, Deserialize)]
struct SignalSpec {
    name: String,
    entity: String,
    decay: DecaySpecConfig,
    #[serde(default)]
    windows: Option<Vec<String>>,
    #[serde(default)]
    velocity: Option<bool>,
    /// Whether engaging with an item via this signal should fold the item's
    /// embedding into the user's preference vector (personalization). Mirrors
    /// `SignalBuilder::positive_engagement`. When omitted, the engine falls back
    /// to name-based detection; declare it explicitly for predictable taste
    /// updates (see docs/guides/build-a-feed-app.md).
    #[serde(default)]
    positive_engagement: Option<bool>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct DecaySpecConfig {
    #[serde(default)]
    exponential: Option<ExponentialDecay>,
    #[serde(default)]
    linear: Option<LinearDecay>,
    #[serde(default)]
    permanent: Option<bool>,
}

impl DecaySpecConfig {
    fn to_decay_spec(&self) -> Result<DecaySpec> {
        if let Some(exp) = &self.exponential {
            return Ok(DecaySpec::Exponential {
                half_life: Duration::from_secs_f64(exp.half_life_seconds),
            });
        }
        if let Some(linear) = &self.linear {
            return Ok(DecaySpec::Linear {
                lifetime: Duration::from_secs_f64(linear.lifetime_seconds),
            });
        }
        if self.permanent.unwrap_or(false) {
            return Ok(DecaySpec::Permanent);
        }
        Err(ServerError::SchemaConfig(
            "decay must specify exponential, linear, or permanent".into(),
        ))
    }
}

#[derive(Debug, Deserialize)]
struct ExponentialDecay {
    half_life_seconds: f64,
}

#[derive(Debug, Deserialize)]
struct LinearDecay {
    lifetime_seconds: f64,
}

#[derive(Debug, Deserialize)]
struct TextFieldSpec {
    name: String,
    kind: String,
}

#[derive(Debug, Deserialize)]
struct EmbeddingSpec {
    name: String,
    entity: String,
    dimensions: usize,
}

fn parse_entity_kind(input: &str) -> Result<EntityKind> {
    match input.trim().to_lowercase().as_str() {
        "item" | "items" => Ok(EntityKind::Item),
        "user" | "users" => Ok(EntityKind::User),
        "creator" | "creators" => Ok(EntityKind::Creator),
        other => Err(ServerError::SchemaConfig(format!(
            "unknown entity kind '{other}'"
        ))),
    }
}

fn parse_window(input: &str) -> Result<Window> {
    match input.trim().to_lowercase().as_str() {
        "one_hour" | "1h" => Ok(Window::OneHour),
        "twenty_four_hours" | "24h" => Ok(Window::TwentyFourHours),
        "seven_days" | "7d" => Ok(Window::SevenDays),
        "thirty_days" | "30d" => Ok(Window::ThirtyDays),
        "all_time" | "alltime" => Ok(Window::AllTime),
        other => Err(ServerError::SchemaConfig(format!(
            "unknown window '{other}'"
        ))),
    }
}

fn parse_text_field_type(input: &str) -> Result<TextFieldType> {
    match input.trim().to_lowercase().as_str() {
        "text" => Ok(TextFieldType::Text),
        "keyword" => Ok(TextFieldType::Keyword),
        other => Err(ServerError::SchemaConfig(format!(
            "unknown text field type '{other}'"
        ))),
    }
}

// ── Profile YAML types ──────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
struct ProfileSpec {
    name: String,
    #[serde(default = "default_version")]
    version: u32,
    #[serde(default)]
    candidate_strategy: Option<CandidateStrategySpec>,
    #[serde(default)]
    sort: Option<SortSpec>,
    #[serde(default)]
    boosts: Vec<BoostSpec>,
    #[serde(default)]
    decay: Option<ProfileDecaySpec>,
    #[serde(default)]
    gates: Vec<GateSpec>,
    #[serde(default)]
    penalties: Vec<PenaltySpec>,
    #[serde(default)]
    excludes: Vec<ExcludeSpec>,
    #[serde(default)]
    diversity: Option<DiversitySpecConfig>,
    #[serde(default)]
    exploration: f64,
}

const fn default_version() -> u32 {
    1
}

#[derive(Debug, Deserialize)]
struct BoostSpec {
    signal: String,
    agg: String,
    window: String,
    weight: f64,
}

#[derive(Debug, Deserialize)]
struct GateSpec {
    signal: String,
    agg: String,
    window: String,
    min_threshold: f64,
}

#[derive(Debug, Deserialize)]
struct PenaltySpec {
    signal: String,
    agg: String,
    window: String,
    weight: f64,
}

#[derive(Debug, Deserialize)]
struct ExcludeSpec {
    signal: String,
    agg: String,
    window: String,
    above: f64,
}

#[derive(Debug, Deserialize)]
struct ProfileDecaySpec {
    signal: String,
    half_life_secs: u64,
    weight: f64,
}

#[derive(Debug, Deserialize)]
struct DiversitySpecConfig {
    #[serde(default)]
    max_per_creator: Option<usize>,
    #[serde(default)]
    format_mix_max_fraction: Option<f64>,
}

/// Sort can be a simple string (`"trending"`, `"new"`) or a map with parameters.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SortSpec {
    Simple(String),
    Parameterized(std::collections::HashMap<String, serde_yml::Value>),
}

/// Candidate strategy can be a simple string or a map with parameters.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CandidateStrategySpec {
    Simple(String),
    Parameterized(std::collections::HashMap<String, serde_yml::Value>),
}

// ── Profile conversion ──────────────────────────────────────────────────────

impl ProfileSpec {
    fn to_ranking_profile(&self) -> Result<RankingProfile> {
        let candidate_strategy = match &self.candidate_strategy {
            Some(spec) => parse_candidate_strategy(spec)?,
            None => CandidateStrategy::Scan {
                sort_field: "created_at".into(),
            },
        };

        let sort = match &self.sort {
            Some(spec) => Some(parse_sort(spec)?),
            None => None,
        };

        let boosts = self
            .boosts
            .iter()
            .map(|b| {
                Ok(Boost {
                    signal: b.signal.clone(),
                    agg: parse_agg(&b.agg)?,
                    window: parse_window(&b.window)?,
                    weight: b.weight,
                })
            })
            .collect::<Result<Vec<_>>>()?;

        let gates = self
            .gates
            .iter()
            .map(|g| {
                Ok(Gate {
                    signal: g.signal.clone(),
                    agg: parse_agg(&g.agg)?,
                    window: parse_window(&g.window)?,
                    min_threshold: g.min_threshold,
                })
            })
            .collect::<Result<Vec<_>>>()?;

        let penalties = self
            .penalties
            .iter()
            .map(|p| {
                Ok(Penalty {
                    signal: p.signal.clone(),
                    agg: parse_agg(&p.agg)?,
                    window: parse_window(&p.window)?,
                    weight: p.weight,
                })
            })
            .collect::<Result<Vec<_>>>()?;

        let excludes = self
            .excludes
            .iter()
            .map(|e| {
                Ok(Exclude {
                    signal: e.signal.clone(),
                    agg: parse_agg(&e.agg)?,
                    window: parse_window(&e.window)?,
                    above: e.above,
                })
            })
            .collect::<Result<Vec<_>>>()?;

        let decay = self.decay.as_ref().map(|d| ProfileDecay {
            signal: d.signal.clone(),
            half_life_secs: d.half_life_secs,
            weight: d.weight,
        });

        let diversity = self
            .diversity
            .as_ref()
            .map_or_else(DiversitySpec::default, |d| DiversitySpec {
                max_per_creator: d.max_per_creator,
                format_mix_max_fraction: d.format_mix_max_fraction,
            });

        Ok(RankingProfile {
            name: self.name.clone(),
            version: self.version,
            candidate_strategy,
            boosts,
            decay,
            gates,
            penalties,
            excludes,
            diversity,
            exploration: self.exploration,
            sort,
            is_builtin: false,
        })
    }
}

fn parse_agg(input: &str) -> Result<SignalAgg> {
    match input.trim().to_lowercase().as_str() {
        "value" => Ok(SignalAgg::Value),
        "velocity" => Ok(SignalAgg::Velocity),
        "decay_score" => Ok(SignalAgg::DecayScore),
        "ratio" => Ok(SignalAgg::Ratio),
        "relative_velocity" => Ok(SignalAgg::RelativeVelocity),
        other => Err(ServerError::SchemaConfig(format!(
            "unknown signal aggregation '{other}'"
        ))),
    }
}

fn parse_sort(spec: &SortSpec) -> Result<Sort> {
    match spec {
        SortSpec::Simple(s) => match s.trim().to_lowercase().as_str() {
            "trending" => Ok(Sort::Trending),
            "rising" => Ok(Sort::Rising),
            "controversial" => Ok(Sort::Controversial),
            "hidden_gems" => Ok(Sort::HiddenGems),
            "shuffle" => Ok(Sort::Shuffle),
            "new" => Ok(Sort::New),
            "most_followed" => Ok(Sort::MostFollowed),
            "creator_engagement_rate" => Ok(Sort::CreatorEngagementRate),
            "alphabetical_asc" => Ok(Sort::AlphabeticalAsc),
            "alphabetical_desc" => Ok(Sort::AlphabeticalDesc),
            "shortest" => Ok(Sort::Shortest),
            "longest" => Ok(Sort::Longest),
            "live_viewer_count" => Ok(Sort::LiveViewerCount),
            "date_saved" => Ok(Sort::DateSaved),
            other => Err(ServerError::SchemaConfig(format!(
                "unknown sort mode '{other}'"
            ))),
        },
        SortSpec::Parameterized(map) => {
            if let Some(val) = map.get("hot") {
                let gravity = extract_f64(val, "gravity")?.unwrap_or(1.8);
                Ok(Sort::Hot { gravity })
            } else if let Some(val) = map.get("top_window") {
                let window = extract_window(val, "window")?;
                Ok(Sort::TopWindow { window })
            } else if let Some(val) = map.get("most_viewed") {
                let window = extract_window(val, "window")?;
                Ok(Sort::MostViewed { window })
            } else if let Some(val) = map.get("most_liked") {
                let window = extract_window(val, "window")?;
                Ok(Sort::MostLiked { window })
            } else if let Some(val) = map.get("most_commented") {
                let window = extract_window(val, "window")?;
                Ok(Sort::MostCommented { window })
            } else if let Some(val) = map.get("most_shared") {
                let window = extract_window(val, "window")?;
                Ok(Sort::MostShared { window })
            } else {
                Err(ServerError::SchemaConfig(format!(
                    "unknown parameterized sort: {map:?}"
                )))
            }
        }
    }
}

fn parse_candidate_strategy(spec: &CandidateStrategySpec) -> Result<CandidateStrategy> {
    match spec {
        CandidateStrategySpec::Simple(s) => match s.trim().to_lowercase().as_str() {
            "scan" => Ok(CandidateStrategy::Scan {
                sort_field: "created_at".into(),
            }),
            "relationship" => Ok(CandidateStrategy::Relationship),
            "hybrid" => Ok(CandidateStrategy::Hybrid),
            "cohort_trending" => Ok(CandidateStrategy::CohortTrending),
            other => Err(ServerError::SchemaConfig(format!(
                "unknown candidate strategy '{other}'"
            ))),
        },
        CandidateStrategySpec::Parameterized(map) => {
            if let Some(val) = map.get("ann") {
                let slot = extract_string(val, "slot")?;
                let limit = extract_u64(val, "limit")?.unwrap_or(100) as usize;
                Ok(CandidateStrategy::Ann { slot, limit })
            } else if let Some(val) = map.get("signal_ranked") {
                let signal = extract_string(val, "signal")?;
                let window_str = extract_string_field(val, "window")?;
                let window = parse_window(&window_str)?;
                Ok(CandidateStrategy::SignalRanked { signal, window })
            } else if let Some(val) = map.get("scan") {
                let sort_field =
                    extract_string_field(val, "sort_field").unwrap_or_else(|_| "created_at".into());
                Ok(CandidateStrategy::Scan { sort_field })
            } else {
                Err(ServerError::SchemaConfig(format!(
                    "unknown parameterized candidate strategy: {map:?}"
                )))
            }
        }
    }
}

// ── YAML value extraction helpers ───────────────────────────────────────────

fn extract_f64(val: &serde_yml::Value, field: &str) -> Result<Option<f64>> {
    match val {
        serde_yml::Value::Mapping(map) => map
            .get(serde_yml::Value::String(field.to_owned()))
            .map_or(Ok(None), |v| {
                v.as_f64()
                    // Config integers are small; the i64→f64 widening is exact here.
                    .or_else(|| {
                        #[allow(clippy::cast_precision_loss)]
                        v.as_i64().map(|i| i as f64)
                    })
                    .map(Some)
                    .ok_or_else(|| ServerError::SchemaConfig(format!("'{field}' must be a number")))
            }),
        _ => Ok(None),
    }
}

fn extract_u64(val: &serde_yml::Value, field: &str) -> Result<Option<u64>> {
    match val {
        serde_yml::Value::Mapping(map) => map
            .get(serde_yml::Value::String(field.to_owned()))
            .map_or(Ok(None), |v| {
                v.as_u64().map(Some).ok_or_else(|| {
                    ServerError::SchemaConfig(format!("'{field}' must be a positive integer"))
                })
            }),
        _ => Ok(None),
    }
}

fn extract_string(val: &serde_yml::Value, field: &str) -> Result<String> {
    extract_string_field(val, field)
}

fn extract_string_field(val: &serde_yml::Value, field: &str) -> Result<String> {
    match val {
        serde_yml::Value::Mapping(map) => map
            .get(serde_yml::Value::String(field.to_owned()))
            .map_or_else(
                || {
                    Err(ServerError::SchemaConfig(format!(
                        "missing required field '{field}'"
                    )))
                },
                |v| {
                    v.as_str()
                        .map(std::borrow::ToOwned::to_owned)
                        .ok_or_else(|| {
                            ServerError::SchemaConfig(format!("'{field}' must be a string"))
                        })
                },
            ),
        _ => Err(ServerError::SchemaConfig(format!(
            "expected mapping with field '{field}'"
        ))),
    }
}

fn extract_window(val: &serde_yml::Value, field: &str) -> Result<Window> {
    let s = extract_string_field(val, field)?;
    parse_window(&s)
}

// ── Signal validation ───────────────────────────────────────────────────────

fn validate_profile_signals(profile: &RankingProfile, signal_names: &[String]) -> Result<()> {
    let check = |signal: &str, context: &str| -> Result<()> {
        if signal_names.iter().any(|s| s == signal) {
            Ok(())
        } else {
            Err(ServerError::SchemaConfig(format!(
                "profile '{}': {} references unknown signal '{signal}'",
                profile.name, context
            )))
        }
    };

    for boost in &profile.boosts {
        check(&boost.signal, "boost")?;
    }
    for gate in &profile.gates {
        check(&gate.signal, "gate")?;
    }
    for penalty in &profile.penalties {
        check(&penalty.signal, "penalty")?;
    }
    for exclude in &profile.excludes {
        check(&exclude.signal, "exclude")?;
    }
    if let Some(ref decay) = profile.decay {
        check(&decay.signal, "decay")?;
    }
    Ok(())
}

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

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

    #[test]
    fn parse_schema_with_profiles() {
        let yaml = r"
signals:
  - name: chat
    entity: item
    decay:
      exponential:
        half_life_seconds: 1209600
    windows: [24h, 7d]
  - name: favorite
    entity: item
    decay:
      exponential:
        half_life_seconds: 2592000
    windows: [7d, 30d]

profiles:
  - name: for_you
    sort:
      hot:
        gravity: 1.5
    boosts:
      - signal: chat
        agg: decay_score
        window: all_time
        weight: 1.5
      - signal: favorite
        agg: decay_score
        window: all_time
        weight: 2.5
    diversity:
      max_per_creator: 2
    exploration: 0.1
";
        let spec: SchemaSpec = serde_yml::from_str(yaml).unwrap();
        assert!(spec.profiles.is_some());
        let profiles = spec.profiles.unwrap();
        assert_eq!(profiles.len(), 1);
        let profile = profiles[0].to_ranking_profile().unwrap();
        assert_eq!(profile.name, "for_you");
        assert_eq!(profile.boosts.len(), 2);
        assert!(!profile.is_builtin);
        assert!((profile.exploration - 0.1).abs() < f64::EPSILON);
        assert!(
            matches!(profile.sort, Some(Sort::Hot { gravity }) if (gravity - 1.5).abs() < f64::EPSILON)
        );
        assert_eq!(profile.diversity.max_per_creator, Some(2));
    }

    #[test]
    fn parse_signal_positive_engagement() {
        // `positive_engagement` is now a first-class YAML field so server-mode
        // schemas get the same preference-vector control as the embedded
        // SchemaBuilder. When omitted it stays None (engine name-based fallback).
        let yaml = r"
signals:
  - name: like
    entity: item
    decay:
      exponential:
        half_life_seconds: 1209600
    windows: [all_time]
    positive_engagement: true
  - name: skip
    entity: item
    decay:
      permanent: true
";
        let spec: SchemaSpec = serde_yml::from_str(yaml).unwrap();
        assert_eq!(spec.signals[0].positive_engagement, Some(true));
        assert_eq!(spec.signals[1].positive_engagement, None);
    }

    #[test]
    fn parse_simple_sort_modes() {
        let cases = vec![
            ("trending", true),
            ("new", true),
            ("shuffle", true),
            ("rising", true),
            ("controversial", true),
            ("hidden_gems", true),
            ("bad_sort", false),
        ];
        for (input, should_ok) in cases {
            let result = parse_sort(&SortSpec::Simple(input.into()));
            assert_eq!(result.is_ok(), should_ok, "sort '{input}'");
        }
    }

    #[test]
    fn parse_parameterized_sort() {
        let mut map = std::collections::HashMap::new();
        let mut inner = serde_yml::Mapping::new();
        inner.insert(
            serde_yml::Value::String("window".into()),
            serde_yml::Value::String("7d".into()),
        );
        map.insert("top_window".into(), serde_yml::Value::Mapping(inner));
        let result = parse_sort(&SortSpec::Parameterized(map)).unwrap();
        assert!(matches!(
            result,
            Sort::TopWindow {
                window: Window::SevenDays
            }
        ));
    }

    #[test]
    fn parse_agg_variants() {
        assert!(matches!(parse_agg("value").unwrap(), SignalAgg::Value));
        assert!(matches!(
            parse_agg("velocity").unwrap(),
            SignalAgg::Velocity
        ));
        assert!(matches!(
            parse_agg("decay_score").unwrap(),
            SignalAgg::DecayScore
        ));
        assert!(matches!(parse_agg("ratio").unwrap(), SignalAgg::Ratio));
        assert!(matches!(
            parse_agg("relative_velocity").unwrap(),
            SignalAgg::RelativeVelocity
        ));
        assert!(parse_agg("unknown").is_err());
    }

    #[test]
    fn parse_candidate_strategy_variants() {
        let scan = parse_candidate_strategy(&CandidateStrategySpec::Simple("scan".into())).unwrap();
        assert!(matches!(scan, CandidateStrategy::Scan { .. }));

        let rel = parse_candidate_strategy(&CandidateStrategySpec::Simple("relationship".into()))
            .unwrap();
        assert!(matches!(rel, CandidateStrategy::Relationship));
    }

    #[test]
    fn unknown_signal_in_profile_rejected() {
        let profile = RankingProfile {
            name: "test".into(),
            version: 1,
            candidate_strategy: CandidateStrategy::Scan {
                sort_field: "created_at".into(),
            },
            boosts: vec![Boost {
                signal: "nonexistent".into(),
                agg: SignalAgg::Value,
                window: Window::AllTime,
                weight: 1.0,
            }],
            decay: None,
            gates: vec![],
            penalties: vec![],
            excludes: vec![],
            diversity: DiversitySpec::default(),
            exploration: 0.0,
            sort: None,
            is_builtin: false,
        };
        let signal_names = vec!["chat".into(), "favorite".into()];
        let result = validate_profile_signals(&profile, &signal_names);
        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(err.contains("nonexistent"), "error: {err}");
    }

    #[test]
    fn load_schema_returns_tuple() {
        let (schema, profiles) = load_schema(None).unwrap();
        // Default schema has no profiles section.
        assert!(profiles.is_empty());
        assert!(schema.signals().next().is_some());
    }

    #[test]
    fn schema_with_no_profiles_section() {
        let yaml = r"
signals:
  - name: view
    entity: item
    decay:
      exponential:
        half_life_seconds: 604800
    windows: [24h, 7d]
";
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), yaml).unwrap();
        let (_, profiles) = load_schema(Some(tmp.path())).unwrap();
        assert!(profiles.is_empty());
    }

    #[test]
    fn resolve_config_path_explicit_wins() {
        // An explicit path always wins, even with a config dir set.
        let dir = tempfile::tempdir().unwrap();
        let explicit = Path::new("/some/explicit/schema.yaml");
        let resolved =
            resolve_config_path(Some(explicit), Some(dir.path()), CONFIG_DIR_SCHEMA_FILE).unwrap();
        assert_eq!(resolved.as_deref(), Some(explicit));
    }

    #[test]
    fn resolve_config_path_no_dir_returns_none() {
        // No explicit path and no config dir → fall back to the compiled-in
        // default (None).
        let resolved = resolve_config_path(None, None, CONFIG_DIR_SCHEMA_FILE).unwrap();
        assert!(resolved.is_none());
    }

    #[test]
    fn resolve_config_path_dir_with_file_resolves() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join(CONFIG_DIR_SCHEMA_FILE);
        std::fs::write(&file, "signals: []").unwrap();
        let resolved = resolve_config_path(None, Some(dir.path()), CONFIG_DIR_SCHEMA_FILE).unwrap();
        assert_eq!(resolved.as_deref(), Some(file.as_path()));
    }

    /// Error branch: a config dir is set but does NOT contain the expected file.
    /// A misconfigured container must fail loudly, not silently serve defaults.
    #[test]
    fn resolve_config_path_dir_missing_file_errors() {
        let dir = tempfile::tempdir().unwrap();
        // Dir exists but the schema file was never written into it.
        let result = resolve_config_path(None, Some(dir.path()), CONFIG_DIR_SCHEMA_FILE);
        let err = result.unwrap_err();
        assert!(matches!(err, ServerError::SchemaConfig(_)), "got {err:?}");
        let msg = err.to_string();
        assert!(
            msg.contains(CONFIG_DIR_SCHEMA_FILE) && msg.contains("does not contain"),
            "error must name the missing file: {msg}"
        );
    }

    /// A path that exists but is a directory (not a file) is also rejected — the
    /// `is_file()` guard must distinguish a readable file from a directory.
    #[test]
    fn resolve_config_path_rejects_directory_in_place_of_file() {
        let dir = tempfile::tempdir().unwrap();
        // Create a subdirectory named exactly like the expected schema file.
        std::fs::create_dir(dir.path().join(CONFIG_DIR_SCHEMA_FILE)).unwrap();
        let result = resolve_config_path(None, Some(dir.path()), CONFIG_DIR_SCHEMA_FILE);
        assert!(
            matches!(result, Err(ServerError::SchemaConfig(_))),
            "a directory is not a readable config file"
        );
    }

    #[test]
    fn profile_defaults() {
        let yaml = r"
signals:
  - name: view
    entity: item
    decay:
      exponential:
        half_life_seconds: 604800
    windows: [24h, 7d]

profiles:
  - name: minimal
    boosts:
      - signal: view
        agg: value
        window: all_time
        weight: 1.0
";
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), yaml).unwrap();
        let (_, profiles) = load_schema(Some(tmp.path())).unwrap();
        assert_eq!(profiles.len(), 1);
        let p = &profiles[0];
        assert_eq!(p.version, 1);
        assert!(!p.is_builtin);
        assert!(p.sort.is_none());
        assert!((p.exploration).abs() < f64::EPSILON);
        assert!(matches!(
            p.candidate_strategy,
            CandidateStrategy::Scan { .. }
        ));
    }
}
