use serde::{Deserialize, Serialize};
use serde_norway::{Mapping, Value};
use std::fs;
use std::path::Path;

use crate::model::ModelChoice;
use crate::settings::{GenerationPreferences, RuntimePreferences};

/// Settings the application persists beside its database, as a YAML file users
/// and agents can edit by hand. Only values that differ from the defaults are
/// written; anything missing falls back to the default.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    pub model: ModelChoice,
    pub idle_timeout_minutes: i32,
    pub endpoint: EndpointConfig,
    pub generation: GenerationPreferences,
    pub runtime: RuntimePreferences,
    pub interface: InterfaceConfig,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            model: ModelChoice::default(),
            idle_timeout_minutes: 10,
            endpoint: EndpointConfig::default(),
            generation: GenerationPreferences::default(),
            runtime: RuntimePreferences::default(),
            interface: InterfaceConfig::default(),
        }
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EndpointConfig {
    pub port: i32,
    pub enabled: bool,
    pub cors: bool,
}

impl Default for EndpointConfig {
    fn default() -> Self {
        Self {
            port: 4000,
            enabled: true,
            cors: false,
        }
    }
}

/// Window state the application restores on the next launch.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct InterfaceConfig {
    pub sidebar_collapsed: bool,
    pub sidebar_width: i32,
    /// Project the app reopens on. Cleared when that project goes away.
    pub last_project_id: Option<i32>,
}

impl Default for InterfaceConfig {
    fn default() -> Self {
        Self {
            sidebar_collapsed: false,
            sidebar_width: 276,
            last_project_id: None,
        }
    }
}

impl Config {
    /// A missing file is the default configuration; a malformed one is an error,
    /// so a typo never silently resets everything.
    pub fn load(path: &Path) -> Result<Self, String> {
        let text = match fs::read_to_string(path) {
            Ok(text) => text,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self::default());
            }
            Err(error) => return Err(format!("Could not read {}: {error}", path.display())),
        };
        let config: Self = serde_norway::from_str(&text)
            .map_err(|error| format!("Could not read {}: {error}", path.display()))?;
        config.validate()?;
        Ok(config)
    }

    pub fn save(&self, path: &Path) -> Result<(), String> {
        self.validate()?;
        let value = serde_norway::to_value(self).map_err(|error| error.to_string())?;
        let defaults =
            serde_norway::to_value(Self::default()).map_err(|error| error.to_string())?;
        let value = without_defaults(value, &defaults)
            .unwrap_or_else(|| Value::Mapping(Mapping::default()));
        let text = serde_norway::to_string(&value).map_err(|error| error.to_string())?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
        }
        fs::write(path, text)
            .map_err(|error| format!("Could not write {}: {error}", path.display()))
    }

    pub fn validate(&self) -> Result<(), String> {
        if !(1..=1440).contains(&self.idle_timeout_minutes) {
            return Err("Idle timeout must be between 1 and 1440 minutes.".into());
        }
        if !(1..=65_535).contains(&self.endpoint.port) {
            return Err("Endpoint port must be between 1 and 65535.".into());
        }
        self.generation.validate()?;
        self.runtime.validate(self.model)
    }
}

/// Drops every value that still matches the default, so the file lists only what
/// the user actually set. Mappings that end up empty disappear with their key.
fn without_defaults(value: Value, defaults: &Value) -> Option<Value> {
    if let (Value::Mapping(entries), Value::Mapping(defaults)) = (&value, defaults) {
        let kept = entries
            .iter()
            .filter_map(|(key, entry)| {
                let default = defaults.get(key)?;
                Some((key.clone(), without_defaults(entry.clone(), default)?))
            })
            .collect::<Mapping>();
        return (!kept.is_empty()).then_some(Value::Mapping(kept));
    }
    (&value != defaults).then_some(value)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::settings::{ReasoningMode, SsdPreferences, StreamingCacheBudget};

    #[test]
    fn defaults_write_an_empty_file_and_reload_unchanged() {
        let directory = std::env::temp_dir().join(format!("ds4-config-{}", std::process::id()));
        let path = directory.join("config.yaml");
        Config::default().save(&path).unwrap();

        assert_eq!(fs::read_to_string(&path).unwrap().trim(), "{}");
        assert_eq!(Config::load(&path).unwrap(), Config::default());
        fs::remove_dir_all(&directory).unwrap();
    }

    #[test]
    fn only_changed_values_are_written_and_read_back() {
        let directory = std::env::temp_dir().join(format!("ds4-config-set-{}", std::process::id()));
        let path = directory.join("config.yaml");
        let config = Config {
            model: ModelChoice::Glm52,
            generation: GenerationPreferences {
                context_tokens: 65_536,
                reasoning_mode: ReasoningMode::Direct,
                ..GenerationPreferences::default()
            },
            runtime: RuntimePreferences {
                ssd: SsdPreferences {
                    enabled: true,
                    cache: Some(StreamingCacheBudget::Gib(64)),
                    ..SsdPreferences::default()
                },
                ..RuntimePreferences::default()
            },
            ..Config::default()
        };
        config.save(&path).unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert_eq!(
            text,
            "model: glm-5.2\n\
             generation:\n  context_tokens: 65536\n  reasoning_mode: none\n\
             runtime:\n  ssd:\n    enabled: true\n    cache: 64GB\n"
        );
        assert_eq!(Config::load(&path).unwrap(), config);
        fs::remove_dir_all(&directory).unwrap();
    }

    #[test]
    fn invalid_files_are_reported_instead_of_reset() {
        let directory = std::env::temp_dir().join(format!("ds4-config-bad-{}", std::process::id()));
        let path = directory.join("config.yaml");
        fs::create_dir_all(&directory).unwrap();

        fs::write(&path, "idle_timeout_minutes: 0\n").unwrap();
        assert!(Config::load(&path).is_err());
        fs::write(&path, "nonsense: 1\n").unwrap();
        assert!(Config::load(&path).is_err());
        fs::remove_dir_all(&directory).unwrap();

        // A missing file is simply the defaults.
        assert_eq!(Config::load(&path).unwrap(), Config::default());
    }
}
