//! Pure settings types and TOML merge logic — no side effects.
//!
//! Owns [`ProjectSettings`] (the API-facing settings payload),
//! [`settings_from_config`] (conversion from `ProjectConfig`), and
//! [`merge_settings_into_toml`] (the pure TOML key-updating logic used by the
//! write path in `mod.rs` + `io.rs`).

use crate::agents::AgentModel;
use crate::config::ProjectConfig;
use serde::{Deserialize, Serialize};

/// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`.
///
/// Only contains the scalar fields of `ProjectConfig` — array sections
/// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file
/// and are not editable through this API.
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectSettings {
    /// Project-wide default QA mode: "server", "agent", or "human". Default: "server".
    pub default_qa: String,
    /// Default model for coder-stage agents (e.g. `Sonnet`).
    pub default_coder_model: Option<AgentModel>,
    /// Maximum number of concurrent coder-stage agents.
    pub max_coders: Option<u32>,
    /// Maximum retries per story per pipeline stage before marking as blocked. Default: 2.
    pub max_retries: u32,
    /// Optional base branch name (e.g. "main", "master").
    pub base_branch: Option<String>,
    /// Whether to send RateLimitWarning chat notifications. Default: true.
    pub rate_limit_notifications: bool,
    /// IANA timezone name (e.g. "Europe/London").
    pub timezone: Option<String>,
    /// WebSocket URL of a remote huskies node to sync CRDT state with.
    pub rendezvous: Option<String>,
    /// How often (seconds) to check 5_done/ for items to archive. Default: 60.
    pub watcher_sweep_interval_secs: u64,
    /// How long (seconds) an item must remain in 5_done/ before archiving. Default: 14400.
    pub watcher_done_retention_secs: u64,
}

/// Convert a [`ProjectConfig`] into a [`ProjectSettings`] payload.
///
/// Pure: performs no I/O.
pub fn settings_from_config(cfg: &ProjectConfig) -> ProjectSettings {
    ProjectSettings {
        default_qa: cfg.default_qa.clone(),
        default_coder_model: cfg.default_coder_model.clone(),
        max_coders: cfg.max_coders.map(|v| v as u32),
        max_retries: cfg.max_retries,
        base_branch: cfg.base_branch.clone(),
        rate_limit_notifications: cfg.rate_limit_notifications,
        timezone: cfg.timezone.clone(),
        rendezvous: cfg.rendezvous.clone(),
        watcher_sweep_interval_secs: cfg.watcher.sweep_interval_secs,
        watcher_done_retention_secs: cfg.watcher.done_retention_secs,
    }
}

/// Merge the scalar settings from `s` into an existing TOML value in-place.
///
/// Array sections (`[[component]]`, `[[agent]]`) and unknown keys are preserved.
/// Pure: performs no I/O.
///
/// # Errors
/// - [`super::Error::IoError`] if `val` is not a TOML table.
pub fn merge_settings_into_toml(
    val: &mut toml::Value,
    s: &ProjectSettings,
) -> Result<(), super::Error> {
    let table = val
        .as_table_mut()
        .ok_or_else(|| super::Error::Io("Config is not a TOML table".to_string()))?;

    // Scalar root fields — always written
    table.insert(
        "default_qa".to_string(),
        toml::Value::String(s.default_qa.clone()),
    );
    table.insert(
        "max_retries".to_string(),
        toml::Value::Integer(s.max_retries as i64),
    );
    table.insert(
        "rate_limit_notifications".to_string(),
        toml::Value::Boolean(s.rate_limit_notifications),
    );

    // Optional scalar fields — insert when Some, remove when None
    match &s.default_coder_model {
        Some(v) => {
            table.insert(
                "default_coder_model".to_string(),
                toml::Value::String(v.as_str().to_string()),
            );
        }
        None => {
            table.remove("default_coder_model");
        }
    }
    match s.max_coders {
        Some(v) => {
            table.insert("max_coders".to_string(), toml::Value::Integer(v as i64));
        }
        None => {
            table.remove("max_coders");
        }
    }
    match &s.base_branch {
        Some(v) => {
            table.insert("base_branch".to_string(), toml::Value::String(v.clone()));
        }
        None => {
            table.remove("base_branch");
        }
    }
    match &s.timezone {
        Some(v) => {
            table.insert("timezone".to_string(), toml::Value::String(v.clone()));
        }
        None => {
            table.remove("timezone");
        }
    }
    match &s.rendezvous {
        Some(v) => {
            table.insert("rendezvous".to_string(), toml::Value::String(v.clone()));
        }
        None => {
            table.remove("rendezvous");
        }
    }

    // [watcher] sub-table
    let watcher_entry = table
        .entry("watcher".to_string())
        .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
    if let toml::Value::Table(wt) = watcher_entry {
        wt.insert(
            "sweep_interval_secs".to_string(),
            toml::Value::Integer(s.watcher_sweep_interval_secs as i64),
        );
        wt.insert(
            "done_retention_secs".to_string(),
            toml::Value::Integer(s.watcher_done_retention_secs as i64),
        );
    }

    Ok(())
}

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

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

    fn default_settings() -> ProjectSettings {
        settings_from_config(&ProjectConfig::default())
    }

    fn empty_toml() -> toml::Value {
        toml::Value::Table(toml::map::Map::new())
    }

    // ── settings_from_config ──────────────────────────────────────────────────

    #[test]
    fn settings_from_config_reflects_defaults() {
        let s = default_settings();
        assert_eq!(s.default_qa, "server");
        assert_eq!(s.max_retries, 2);
        assert!(s.rate_limit_notifications);
        assert!(s.default_coder_model.is_none());
        assert!(s.max_coders.is_none());
        assert!(s.base_branch.is_none());
        assert!(s.timezone.is_none());
        assert!(s.rendezvous.is_none());
    }

    #[test]
    fn settings_from_config_copies_all_scalar_fields() {
        let cfg = ProjectConfig {
            default_qa: "human".to_string(),
            default_coder_model: Some(AgentModel::Opus),
            max_coders: Some(4),
            max_retries: 5,
            base_branch: Some("main".to_string()),
            rate_limit_notifications: false,
            timezone: Some("UTC".to_string()),
            rendezvous: Some("ws://host:3001/crdt-sync".to_string()),
            watcher: crate::config::WatcherConfig {
                sweep_interval_secs: 30,
                done_retention_secs: 7200,
                reconcile_interval_secs: 30,
                liveness_tick_secs: 5,
            },
            ..Default::default()
        };

        let s = settings_from_config(&cfg);
        assert_eq!(s.default_qa, "human");
        assert_eq!(s.default_coder_model, Some(AgentModel::Opus));
        assert_eq!(s.max_coders, Some(4));
        assert_eq!(s.max_retries, 5);
        assert_eq!(s.base_branch, Some("main".to_string()));
        assert!(!s.rate_limit_notifications);
        assert_eq!(s.timezone, Some("UTC".to_string()));
        assert_eq!(s.rendezvous, Some("ws://host:3001/crdt-sync".to_string()));
        assert_eq!(s.watcher_sweep_interval_secs, 30);
        assert_eq!(s.watcher_done_retention_secs, 7200);
    }

    #[test]
    fn settings_from_config_max_coders_usize_to_u32() {
        let cfg = ProjectConfig {
            max_coders: Some(3usize),
            ..Default::default()
        };
        let s = settings_from_config(&cfg);
        assert_eq!(s.max_coders, Some(3u32));
    }

    // ── merge_settings_into_toml ──────────────────────────────────────────────

    #[test]
    fn merge_writes_scalar_root_fields() {
        let mut val = empty_toml();
        let s = ProjectSettings {
            default_qa: "agent".to_string(),
            default_coder_model: None,
            max_coders: None,
            max_retries: 3,
            base_branch: None,
            rate_limit_notifications: false,
            timezone: None,
            rendezvous: None,
            watcher_sweep_interval_secs: 60,
            watcher_done_retention_secs: 14400,
        };
        merge_settings_into_toml(&mut val, &s).unwrap();

        let t = val.as_table().unwrap();
        assert_eq!(t["default_qa"].as_str(), Some("agent"));
        assert_eq!(t["max_retries"].as_integer(), Some(3));
        assert_eq!(t["rate_limit_notifications"].as_bool(), Some(false));
    }

    #[test]
    fn merge_inserts_optional_fields_when_some() {
        let mut val = empty_toml();
        let s = ProjectSettings {
            default_qa: "server".to_string(),
            default_coder_model: Some(AgentModel::Sonnet),
            max_coders: Some(2),
            max_retries: 2,
            base_branch: Some("main".to_string()),
            rate_limit_notifications: true,
            timezone: Some("America/New_York".to_string()),
            rendezvous: Some("ws://host/crdt-sync".to_string()),
            watcher_sweep_interval_secs: 60,
            watcher_done_retention_secs: 14400,
        };
        merge_settings_into_toml(&mut val, &s).unwrap();

        let t = val.as_table().unwrap();
        assert_eq!(t["default_coder_model"].as_str(), Some("sonnet"));
        assert_eq!(t["max_coders"].as_integer(), Some(2));
        assert_eq!(t["base_branch"].as_str(), Some("main"));
        assert_eq!(t["timezone"].as_str(), Some("America/New_York"));
        assert_eq!(t["rendezvous"].as_str(), Some("ws://host/crdt-sync"));
    }

    #[test]
    fn merge_removes_optional_fields_when_none() {
        let mut val = empty_toml();
        // First set them
        let s_with = ProjectSettings {
            default_qa: "server".to_string(),
            default_coder_model: Some(AgentModel::Sonnet),
            max_coders: Some(3),
            max_retries: 2,
            base_branch: Some("master".to_string()),
            rate_limit_notifications: true,
            timezone: Some("UTC".to_string()),
            rendezvous: None,
            watcher_sweep_interval_secs: 60,
            watcher_done_retention_secs: 14400,
        };
        merge_settings_into_toml(&mut val, &s_with).unwrap();

        // Then clear them
        let s_clear = ProjectSettings {
            default_qa: "server".to_string(),
            default_coder_model: None,
            max_coders: None,
            max_retries: 2,
            base_branch: None,
            rate_limit_notifications: true,
            timezone: None,
            rendezvous: None,
            watcher_sweep_interval_secs: 60,
            watcher_done_retention_secs: 14400,
        };
        merge_settings_into_toml(&mut val, &s_clear).unwrap();

        let t = val.as_table().unwrap();
        assert!(!t.contains_key("default_coder_model"));
        assert!(!t.contains_key("max_coders"));
        assert!(!t.contains_key("base_branch"));
        assert!(!t.contains_key("timezone"));
    }

    #[test]
    fn merge_writes_watcher_sub_table() {
        let mut val = empty_toml();
        let s = ProjectSettings {
            default_qa: "server".to_string(),
            default_coder_model: None,
            max_coders: None,
            max_retries: 2,
            base_branch: None,
            rate_limit_notifications: true,
            timezone: None,
            rendezvous: None,
            watcher_sweep_interval_secs: 45,
            watcher_done_retention_secs: 3600,
        };
        merge_settings_into_toml(&mut val, &s).unwrap();

        let t = val.as_table().unwrap();
        let wt = t["watcher"].as_table().unwrap();
        assert_eq!(wt["sweep_interval_secs"].as_integer(), Some(45));
        assert_eq!(wt["done_retention_secs"].as_integer(), Some(3600));
    }

    #[test]
    fn merge_preserves_unknown_toml_keys() {
        let existing_toml = r#"
[[agent]]
name = "coder-1"
model = "sonnet"
stage = "coder"

[[component]]
name = "server"
path = "."
"#;
        let mut val: toml::Value = toml::from_str(existing_toml).unwrap();
        let s = default_settings();
        merge_settings_into_toml(&mut val, &s).unwrap();

        // Re-serialize and verify agent/component sections are preserved
        let output = toml::to_string_pretty(&val).unwrap();
        assert!(
            output.contains("coder-1"),
            "agent section should be preserved"
        );
        assert!(
            output.contains("component"),
            "component section should be preserved"
        );
    }

    #[test]
    fn merge_returns_error_for_non_table_toml() {
        let mut val = toml::Value::String("not a table".to_string());
        let s = default_settings();
        let result = merge_settings_into_toml(&mut val, &s);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), super::super::Error::Io(_)));
    }

    #[test]
    fn merge_formatting_produces_valid_toml() {
        let mut val = empty_toml();
        let s = ProjectSettings {
            default_qa: "human".to_string(),
            default_coder_model: Some(AgentModel::Opus),
            max_coders: Some(2),
            max_retries: 4,
            base_branch: Some("develop".to_string()),
            rate_limit_notifications: false,
            timezone: Some("Europe/London".to_string()),
            rendezvous: Some("ws://remote:3001/crdt-sync".to_string()),
            watcher_sweep_interval_secs: 120,
            watcher_done_retention_secs: 28800,
        };
        merge_settings_into_toml(&mut val, &s).unwrap();

        let output = toml::to_string_pretty(&val).unwrap();
        // Verify round-trip: the output must be valid TOML
        let reparsed: toml::Value = toml::from_str(&output).unwrap();
        let t = reparsed.as_table().unwrap();
        assert_eq!(t["default_qa"].as_str(), Some("human"));
        assert_eq!(t["default_coder_model"].as_str(), Some("opus"));
        assert_eq!(t["max_coders"].as_integer(), Some(2));
    }
}
