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

use crate::model::{MODEL_CHOICES, ModelChoice};
use crate::settings::{
    DEFAULT_SYSTEM_PROMPT, GenerationPreferences, REASONING_MODES, ReasoningMode,
    RuntimePreferences, SpeculativePreferences, SsdPreferences,
};

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ModelPreferences {
    pub(crate) reasoning_mode: ReasoningMode,
    pub(crate) speculative: SpeculativePreferences,
    pub(crate) ssd: SsdPreferences,
}

/// 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 default_permission_mode: PermissionMode,
    pub idle_timeout_minutes: i32,
    pub a2ui_enabled: bool,
    pub dev_brain: DevBrainConfig,
    pub endpoint: EndpointConfig,
    pub system_prompt: String,
    pub(crate) generation_profiles:
        BTreeMap<ModelChoice, BTreeMap<ReasoningMode, GenerationPreferences>>,
    pub(crate) model_profiles: BTreeMap<ModelChoice, ModelPreferences>,
    pub runtime: RuntimePreferences,
    pub git: GitConfig,
    pub interface: InterfaceConfig,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            model: ModelChoice::default(),
            default_permission_mode: PermissionMode::default(),
            idle_timeout_minutes: 10,
            a2ui_enabled: true,
            dev_brain: DevBrainConfig::default(),
            endpoint: EndpointConfig::default(),
            system_prompt: DEFAULT_SYSTEM_PROMPT.into(),
            generation_profiles: MODEL_CHOICES
                .into_iter()
                .map(|model| {
                    (
                        model,
                        REASONING_MODES
                            .into_iter()
                            .map(|mode| (mode, GenerationPreferences::default()))
                            .collect(),
                    )
                })
                .collect(),
            model_profiles: MODEL_CHOICES
                .into_iter()
                .map(|model| (model, ModelPreferences::default()))
                .collect(),
            runtime: RuntimePreferences::default(),
            git: GitConfig::default(),
            interface: InterfaceConfig::default(),
        }
    }
}

pub const PERMISSION_MODES: [PermissionMode; 2] = [PermissionMode::Heuristic, PermissionMode::Ai];

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PermissionMode {
    #[default]
    Heuristic,
    Ai,
}

impl PermissionMode {
    pub(crate) fn as_id(self) -> &'static str {
        match self {
            Self::Heuristic => "heuristic",
            Self::Ai => "ai",
        }
    }

    pub(crate) fn from_id(id: &str) -> Option<Self> {
        match id {
            "heuristic" => Some(Self::Heuristic),
            "ai" => Some(Self::Ai),
            _ => None,
        }
    }
}

impl fmt::Display for PermissionMode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Heuristic => "Heuristic",
            Self::Ai => "AI based",
        })
    }
}

pub const GIT_DIFF_ALGORITHMS: [GitDiffAlgorithm; 3] = [
    GitDiffAlgorithm::Default,
    GitDiffAlgorithm::Patience,
    GitDiffAlgorithm::Minimal,
];

pub const GIT_DIFF_LAYOUTS: [GitDiffLayout; 2] = [GitDiffLayout::Unified, GitDiffLayout::Split];

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffLayout {
    #[default]
    Unified,
    Split,
}

impl fmt::Display for GitDiffLayout {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Unified => "1 column",
            Self::Split => "2 columns",
        })
    }
}

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffAlgorithm {
    #[default]
    Default,
    Patience,
    Minimal,
}

impl fmt::Display for GitDiffAlgorithm {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Default => "Default (Myers)",
            Self::Patience => "Patience",
            Self::Minimal => "Minimal",
        })
    }
}

pub const GIT_DIFF_WHITESPACE_MODES: [GitDiffWhitespace; 4] = [
    GitDiffWhitespace::ShowAll,
    GitDiffWhitespace::IgnoreAll,
    GitDiffWhitespace::IgnoreChanges,
    GitDiffWhitespace::IgnoreEndOfLine,
];

#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GitDiffWhitespace {
    #[default]
    ShowAll,
    IgnoreAll,
    IgnoreChanges,
    IgnoreEndOfLine,
}

impl fmt::Display for GitDiffWhitespace {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::ShowAll => "Show all changes",
            Self::IgnoreAll => "Ignore all whitespace",
            Self::IgnoreChanges => "Ignore whitespace amount",
            Self::IgnoreEndOfLine => "Ignore end-of-line whitespace",
        })
    }
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct GitConfig {
    pub diff_layout: GitDiffLayout,
    pub diff_algorithm: GitDiffAlgorithm,
    pub context_lines: u32,
    pub interhunk_lines: u32,
    pub indent_heuristic: bool,
    pub whitespace: GitDiffWhitespace,
    pub ignore_blank_lines: bool,
}

impl Default for GitConfig {
    fn default() -> Self {
        Self {
            diff_layout: GitDiffLayout::Unified,
            diff_algorithm: GitDiffAlgorithm::Default,
            context_lines: 3,
            interhunk_lines: 0,
            indent_heuristic: false,
            whitespace: GitDiffWhitespace::ShowAll,
            ignore_blank_lines: false,
        }
    }
}

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DevBrainConfig {
    pub enabled: bool,
    pub vault_path: Option<String>,
}

impl DevBrainConfig {
    pub fn vault(&self) -> Result<std::path::PathBuf, String> {
        if !self.enabled {
            return Err("Dev Brain is disabled.".into());
        }
        let path = self
            .vault_path
            .as_deref()
            .filter(|path| !path.trim().is_empty())
            .ok_or_else(|| "Choose an Obsidian vault before enabling Dev Brain.".to_owned())?;
        crate::dev_brain::validate_vault(Path::new(path))
    }

    fn validate(&self) -> Result<(), String> {
        if self.enabled {
            self.vault().map(|_| ())
        } else {
            Ok(())
        }
    }
}

#[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,
    pub window_size: [u32; 2],
    pub window_position: Option<[i32; 2]>,
    /// 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,
            window_size: [1120, 720],
            window_position: None,
            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 mut value: Value = serde_norway::from_str(&text)
            .map_err(|error| format!("Could not read {}: {error}", path.display()))?;
        drop_legacy_model_settings(&mut value);
        let mut config: Self = serde_norway::from_value(value)
            .map_err(|error| format!("Could not read {}: {error}", path.display()))?;
        config.fill_profile_defaults();
        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());
        }
        for (model, profiles) in &self.generation_profiles {
            for (mode, generation) in profiles {
                let mut generation = generation.clone();
                generation.system_prompt = self.system_prompt.clone();
                generation.reasoning_mode = *mode;
                generation.validate().map_err(|error| {
                    format!("Generation settings for {model} / {mode}: {error}")
                })?;
            }
        }
        for (model, preferences) in &self.model_profiles {
            preferences.speculative.validate(*model)?;
            preferences.ssd.validate(*model)?;
            if preferences.reasoning_mode == ReasoningMode::Max
                && !self
                    .supported_reasoning_modes(*model)
                    .contains(&ReasoningMode::Max)
            {
                return Err(format!(
                    "Default thinking mode for {model} requires its Think Max profile to use at least 393216 context tokens."
                ));
            }
        }
        self.runtime_for(self.model).validate(self.model)?;
        self.dev_brain.validate()
    }

    pub(crate) fn reasoning_mode(&self, model: ModelChoice) -> ReasoningMode {
        self.model_profiles
            .get(&model)
            .map_or(ReasoningMode::default(), |profile| profile.reasoning_mode)
    }

    pub(crate) fn generation_for(
        &self,
        model: ModelChoice,
        reasoning_mode: ReasoningMode,
    ) -> GenerationPreferences {
        let mut generation = self
            .generation_profiles
            .get(&model)
            .and_then(|profiles| profiles.get(&reasoning_mode))
            .cloned()
            .unwrap_or_default();
        generation.system_prompt = self.system_prompt.clone();
        generation.reasoning_mode = reasoning_mode;
        generation
    }

    pub(crate) fn active_generation(&self) -> GenerationPreferences {
        self.generation_for(self.model, self.reasoning_mode(self.model))
    }

    pub(crate) fn runtime_for(&self, model: ModelChoice) -> RuntimePreferences {
        let mut runtime = self.runtime.clone();
        if let Some(profile) = self.model_profiles.get(&model) {
            runtime.speculative = profile.speculative.clone();
            runtime.ssd = profile.ssd.clone();
        }
        runtime
    }

    pub(crate) fn supported_reasoning_modes(&self, model: ModelChoice) -> &'static [ReasoningMode] {
        let max = self.generation_for(model, ReasoningMode::Max);
        max.supported_reasoning_modes()
    }

    fn fill_profile_defaults(&mut self) {
        for model in MODEL_CHOICES {
            self.model_profiles.entry(model).or_default();
            let profiles = self.generation_profiles.entry(model).or_default();
            for mode in REASONING_MODES {
                profiles.entry(mode).or_default();
            }
        }
    }
}

fn drop_legacy_model_settings(value: &mut Value) {
    let Value::Mapping(root) = value else {
        return;
    };
    let generation_key = Value::String("generation".into());
    let prompt_key = Value::String("system_prompt".into());
    if let Some(Value::Mapping(mut generation)) = root.remove(&generation_key)
        && !root.contains_key(&prompt_key)
        && let Some(prompt) = generation.remove(&prompt_key)
    {
        root.insert(prompt_key, prompt);
    }
    if let Some(Value::Mapping(runtime)) = root.get_mut(Value::String("runtime".into())) {
        runtime.remove(Value::String("speculative".into()));
        runtime.remove(Value::String("ssd".into()));
    }
}

/// 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 kept = match defaults.get(key) {
                    Some(default) => without_defaults(entry.clone(), default)?,
                    None => entry.clone(),
                };
                Some((key.clone(), kept))
            })
            .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 mut config = Config {
            model: ModelChoice::Glm52,
            default_permission_mode: PermissionMode::Ai,
            a2ui_enabled: false,
            git: GitConfig {
                diff_layout: GitDiffLayout::Split,
                diff_algorithm: GitDiffAlgorithm::Patience,
                context_lines: 5,
                whitespace: GitDiffWhitespace::IgnoreEndOfLine,
                ..GitConfig::default()
            },
            interface: InterfaceConfig {
                window_size: [1280, 800],
                window_position: Some([120, -40]),
                ..InterfaceConfig::default()
            },
            ..Config::default()
        };
        config
            .generation_profiles
            .get_mut(&ModelChoice::Glm52)
            .unwrap()
            .get_mut(&ReasoningMode::Direct)
            .unwrap()
            .context_tokens = 65_536;
        let model = config.model_profiles.get_mut(&ModelChoice::Glm52).unwrap();
        model.reasoning_mode = ReasoningMode::Direct;
        model.ssd = SsdPreferences {
            enabled: true,
            cache: Some(StreamingCacheBudget::Gib(64)),
            ..SsdPreferences::default()
        };
        config.save(&path).unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert_eq!(
            text,
            "model: glm-5.2\ndefault_permission_mode: ai\na2ui_enabled: false\n\
             generation_profiles:\n  glm-5.2:\n    none:\n      context_tokens: 65536\n\
             model_profiles:\n  glm-5.2:\n    reasoning_mode: none\n    ssd:\n      enabled: true\n      cache: 64GB\n\
             git:\n  diff_layout: split\n  diff_algorithm: patience\n  context_lines: 5\n  whitespace: ignore-end-of-line\n\
             interface:\n  window_size:\n  - 1280\n  - 800\n  window_position:\n  - 120\n  - -40\n"
        );
        assert_eq!(Config::load(&path).unwrap(), config);
        fs::remove_dir_all(&directory).unwrap();
    }

    #[test]
    fn legacy_flat_model_settings_are_dropped_but_the_prompt_is_kept() {
        let directory =
            std::env::temp_dir().join(format!("ds4-config-legacy-{}", std::process::id()));
        let path = directory.join("config.yaml");
        fs::create_dir_all(&directory).unwrap();
        fs::write(
            &path,
            "generation:\n  system_prompt: keep me\n  temperature: 0.2\nruntime:\n  execution:\n    quality: true\n  ssd:\n    enabled: true\n",
        )
        .unwrap();

        let config = Config::load(&path).unwrap();
        assert_eq!(config.system_prompt, "keep me");
        assert_eq!(config.active_generation().temperature, None);
        assert!(!config.runtime_for(config.model).ssd.enabled);
        assert!(config.runtime.execution.quality);
        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());
    }
}
