//! Settings service — domain logic for project settings and editor configuration.
//!
//! Extracts business logic from `http/settings.rs` following the conventions in
//! `docs/architecture/service-modules.md`:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem I/O, process spawn)
//! - `project.rs` — pure types: [`ProjectSettings`], [`settings_from_config`],
//!   [`merge_settings_into_toml`]
//! - `validate.rs` — pure validation: [`validate_project_settings`]

pub(super) mod io;
/// Pure types: `ProjectSettings`, TOML serialization, config merging.
pub mod project;
/// Pure project-settings validation rules.
pub mod validate;

pub use project::{ProjectSettings, merge_settings_into_toml, settings_from_config};
pub use validate::validate_project_settings;

use crate::config::ProjectConfig;
use crate::store::StoreOps;
use std::path::Path;

/// The store key for the configured editor command.
pub const EDITOR_COMMAND_KEY: &str = "editor_command";

// ── Error type ────────────────────────────────────────────────────────────────

/// Typed errors returned by `service::settings` functions.
///
/// HTTP handlers map these to status codes:
/// - [`Error::Validation`] → 400 Bad Request
/// - [`Error::NotConfigured`] → 400 Bad Request
/// - [`Error::Io`] → 500 Internal Server Error
/// - [`Error::Spawn`] → 500 Internal Server Error
#[derive(Debug)]
pub enum Error {
    /// A field value failed validation (e.g. unknown QA mode).
    Validation(String),
    /// No editor is configured in the store.
    NotConfigured,
    /// A filesystem read or write operation failed.
    Io(String),
    /// The editor process failed to spawn.
    Spawn(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Validation(msg) => write!(f, "Validation error: {msg}"),
            Self::NotConfigured => write!(f, "No editor configured"),
            Self::Io(msg) => write!(f, "I/O error: {msg}"),
            Self::Spawn(msg) => write!(f, "Spawn error: {msg}"),
        }
    }
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Load the current project settings from disk.
///
/// # Errors
/// - [`Error::IoError`] if the config file cannot be read or parsed.
pub fn load_project_settings(project_root: &Path) -> Result<ProjectSettings, Error> {
    let config =
        ProjectConfig::load(project_root).map_err(|e| Error::Io(format!("Load config: {e}")))?;
    Ok(settings_from_config(&config))
}

/// Write the given settings to disk, preserving array sections.
///
/// Reads the existing project.toml, merges only the scalar fields from `s`,
/// and rewrites the file. Array sections (`[[component]]`, `[[agent]]`) are
/// untouched.
///
/// # Errors
/// - [`Error::IoError`] if the config file cannot be read, parsed, or written.
pub fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), Error> {
    let config_path = project_root.join(".huskies/project.toml");

    let content = io::read_config_toml(&config_path)?;

    let mut val: toml::Value = if content.trim().is_empty() {
        toml::Value::Table(toml::map::Map::new())
    } else {
        toml::from_str(&content).map_err(|e| Error::Io(format!("Parse config: {e}")))?
    };

    merge_settings_into_toml(&mut val, s)?;

    let new_content =
        toml::to_string_pretty(&val).map_err(|e| Error::Io(format!("Serialize config: {e}")))?;
    io::write_config_toml(&config_path, &new_content)?;

    Ok(())
}

/// Return the configured editor command from the store, or `None` if not set.
///
/// Pure: reads from in-memory store only — no filesystem or network I/O.
pub fn get_editor_command(store: &dyn StoreOps) -> Option<String> {
    store
        .get(EDITOR_COMMAND_KEY)
        .and_then(|v| v.as_str().map(|s| s.to_string()))
}

/// Open a file in the configured editor at the optional line number.
///
/// # Errors
/// - [`Error::NotConfigured`] if no editor has been set in the store.
/// - [`Error::SpawnError`] if the editor process fails to start.
pub fn open_file_in_editor(
    store: &dyn StoreOps,
    path: &str,
    line: Option<u32>,
) -> Result<(), Error> {
    let editor_command = get_editor_command(store).ok_or(Error::NotConfigured)?;

    let file_ref = match line {
        Some(l) => format!("{path}:{l}"),
        None => path.to_string(),
    };

    io::spawn_editor(&editor_command, &file_ref)
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    // ── Error Display ─────────────────────────────────────────────────────────

    #[test]
    fn error_display_validation() {
        let e = Error::Validation("bad value".to_string());
        assert!(e.to_string().contains("Validation error"));
        assert!(e.to_string().contains("bad value"));
    }

    #[test]
    fn error_display_not_configured() {
        let e = Error::NotConfigured;
        assert!(e.to_string().contains("No editor configured"));
    }

    #[test]
    fn error_display_io() {
        let e = Error::Io("disk full".to_string());
        assert!(e.to_string().contains("I/O error"));
        assert!(e.to_string().contains("disk full"));
    }

    #[test]
    fn error_display_spawn() {
        let e = Error::Spawn("not found".to_string());
        assert!(e.to_string().contains("Spawn error"));
        assert!(e.to_string().contains("not found"));
    }

    // ── load_project_settings ─────────────────────────────────────────────────

    #[test]
    fn load_project_settings_returns_defaults_when_no_toml() {
        let dir = TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        let s = load_project_settings(dir.path()).unwrap();
        assert_eq!(s.default_qa, "server");
        assert_eq!(s.max_retries, 2);
        assert!(s.rate_limit_notifications);
    }

    // ── write_project_settings ────────────────────────────────────────────────

    #[test]
    fn write_project_settings_creates_file() {
        let dir = TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        let s = load_project_settings(dir.path()).unwrap();
        write_project_settings(dir.path(), &s).unwrap();
        assert!(dir.path().join(".huskies/project.toml").exists());
    }

    #[test]
    fn write_project_settings_roundtrips() {
        let dir = TempDir::new().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        let mut s = load_project_settings(dir.path()).unwrap();
        s.default_qa = "agent".to_string();
        s.max_retries = 7;
        write_project_settings(dir.path(), &s).unwrap();

        let loaded = load_project_settings(dir.path()).unwrap();
        assert_eq!(loaded.default_qa, "agent");
        assert_eq!(loaded.max_retries, 7);
    }

    // ── get_editor_command ────────────────────────────────────────────────────

    #[test]
    fn get_editor_command_returns_none_when_unset() {
        use crate::store::JsonFileStore;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        assert!(get_editor_command(&store).is_none());
    }

    #[test]
    fn get_editor_command_returns_value_when_set() {
        use crate::store::JsonFileStore;
        use serde_json::json;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        store.set(EDITOR_COMMAND_KEY, json!("zed"));
        assert_eq!(get_editor_command(&store), Some("zed".to_string()));
    }

    // ── open_file_in_editor ───────────────────────────────────────────────────

    #[test]
    fn open_file_in_editor_returns_not_configured_when_no_editor() {
        use crate::store::JsonFileStore;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        let result = open_file_in_editor(&store, "src/main.rs", Some(42));
        assert!(matches!(result, Err(Error::NotConfigured)));
    }

    #[test]
    fn open_file_in_editor_returns_spawn_error_for_nonexistent_editor() {
        use crate::store::JsonFileStore;
        use serde_json::json;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        store.set(EDITOR_COMMAND_KEY, json!("this_editor_xyz_does_not_exist"));
        let result = open_file_in_editor(&store, "src/main.rs", Some(1));
        assert!(matches!(result, Err(Error::Spawn(_))));
    }

    #[test]
    fn open_file_in_editor_succeeds_with_echo() {
        use crate::store::JsonFileStore;
        use serde_json::json;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        store.set(EDITOR_COMMAND_KEY, json!("echo"));
        let result = open_file_in_editor(&store, "src/main.rs", Some(10));
        assert!(result.is_ok());
    }

    #[test]
    fn open_file_in_editor_formats_path_without_line() {
        use crate::store::JsonFileStore;
        use serde_json::json;
        let dir = TempDir::new().unwrap();
        let store = JsonFileStore::new(dir.path().join("store.json")).unwrap();
        store.set(EDITOR_COMMAND_KEY, json!("echo"));
        let result = open_file_in_editor(&store, "src/lib.rs", None);
        assert!(result.is_ok());
    }
}
