//! Runtime-mutable settings, distinct from the static `Config`.
//!
//! Rationale: `Config` holds bind addresses, paths, and other things that
//! can only reasonably change at process start. `Settings` holds everything
//! the web UI can flip at runtime: timeouts, default boot action, Windows
//! feature toggles, etc. Persisted to `<work_dir>/settings.json` so they
//! survive pod restarts without requiring a ConfigMap edit.
//!
//! **Crucial property:** every UI-facing "feature flag" in here maps to a
//! specific iPXE script-generation behavior elsewhere in the codebase. The
//! user never writes iPXE; they toggle a setting and we translate.

use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
    /// Seconds to wait at the top-level boot menu before falling through
    /// to `timeout_action`. Default 600s per the Phase 2 spec.
    pub boot_menu_timeout_secs: u32,

    /// What happens if the boot-menu timer hits zero with no selection.
    pub timeout_action: TimeoutAction,

    /// Master enable for Windows ISO support. When off, Windows ISOs are
    /// listed in the UI as "Windows — disabled" and not exposed in the
    /// PXE menu. Off by default: Windows support requires bundling Samba
    /// and wimlib in the runtime image (see deploy/docker/Dockerfile).
    pub windows_enabled: bool,

    /// SMB share hostname/IP the patched WinPE's startnet.cmd will
    /// `net use` against. Empty string = auto-fill with the public IP at
    /// script-generation time.
    pub smb_host_override: String,

    /// Global kernel-args append (added to every Linux entry's cmdline).
    /// Useful for things like `console=ttyS0,115200` on serial-only boxes.
    /// Do NOT accept raw iPXE script fragments here; this is literal kernel
    /// args only.
    pub extra_kernel_args: String,

    /// If true, the "Default → Boot from Local HDD" menu item is the
    /// pre-selected entry (and is what the timeout falls to if
    /// `timeout_action = LocalHdd`).
    pub default_local_hdd: bool,

    /// When a client hits the Queued Deployment item, how long (seconds) to
    /// hold it in queue before giving up and falling back to the menu.
    /// 0 = forever.
    pub queue_wait_max_secs: u32,

    /// Optional DNS server advertised on the Network tab. Purely
    /// informational today — OpenPXE does not run a DNS server, but
    /// operators expect to be able to record what the upstream DNS is.
    /// Empty string = unset (UI shows placeholder).
    pub dns_server: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TimeoutAction {
    /// Sit at the menu forever (no fallthrough).
    Stay,
    /// Chain the "Boot from Local HDD" entry.
    LocalHdd,
    /// Put the client into the deployment queue, waiting for operator
    /// assignment.
    #[default]
    QueuedDeployment,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            boot_menu_timeout_secs: 600,
            timeout_action: TimeoutAction::QueuedDeployment,
            windows_enabled: false,
            smb_host_override: String::new(),
            extra_kernel_args: String::new(),
            default_local_hdd: true,
            queue_wait_max_secs: 0,
            dns_server: String::new(),
        }
    }
}

#[derive(Debug)]
pub struct SettingsStore {
    path: PathBuf,
    inner: RwLock<Settings>,
}

impl SettingsStore {
    /// Load from `work_dir/settings.json`, or create with defaults if the
    /// file is missing/corrupt. Never fails — a bad settings file on disk
    /// is not a reason to refuse to start.
    pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
        let path = work_dir.join("settings.json");
        let initial = match std::fs::read_to_string(&path) {
            Ok(text) => match serde_json::from_str::<Settings>(&text) {
                Ok(s) => s,
                Err(e) => {
                    tracing::warn!(
                        target: "openpxe::settings",
                        "settings.json present but unreadable ({e}); falling back to defaults"
                    );
                    Settings::default()
                }
            },
            Err(_) => Settings::default(),
        };
        Arc::new(Self {
            path,
            inner: RwLock::new(initial),
        })
    }

    #[must_use]
    pub fn snapshot(&self) -> Settings {
        self.inner.read().clone()
    }

    /// Atomically replace settings and persist. The caller supplies the full
    /// `Settings` struct — partial updates happen at the HTTP layer via
    /// merge-then-store. Persistence errors are logged but not returned;
    /// settings live in memory authoritatively and only SHOULD be on disk.
    pub fn replace(&self, new: Settings) {
        {
            let mut g = self.inner.write();
            *g = new;
        }
        let snap = self.snapshot();
        if let Err(e) = self.persist(&snap) {
            tracing::warn!(target: "openpxe::settings", "failed to persist settings: {e}");
        }
    }

    fn persist(&self, s: &Settings) -> std::io::Result<()> {
        let tmp = self.path.with_extension("json.tmp");
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let body = serde_json::to_vec_pretty(s)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
        std::fs::write(&tmp, body)?;
        std::fs::rename(tmp, &self.path)
    }
}

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

    #[test]
    fn defaults_roundtrip() {
        let dir = tempdir().unwrap();
        let store = SettingsStore::load_or_default(dir.path());
        let s = store.snapshot();
        assert_eq!(s.boot_menu_timeout_secs, 600);
        assert_eq!(s.timeout_action, TimeoutAction::QueuedDeployment);
        assert!(!s.windows_enabled);
    }

    #[test]
    fn replace_persists() {
        let dir = tempdir().unwrap();
        let store = SettingsStore::load_or_default(dir.path());
        let mut new = store.snapshot();
        new.boot_menu_timeout_secs = 30;
        new.windows_enabled = true;
        store.replace(new);
        // Reload from disk.
        drop(store);
        let reloaded = SettingsStore::load_or_default(dir.path());
        let s = reloaded.snapshot();
        assert_eq!(s.boot_menu_timeout_secs, 30);
        assert!(s.windows_enabled);
    }

    #[test]
    fn settings_serialize_queue_naming() {
        let text = serde_json::to_string(&Settings::default()).unwrap();
        assert!(text.contains("queue_wait_max_secs"));
    }

    #[test]
    fn corrupt_file_falls_back_to_default() {
        let dir = tempdir().unwrap();
        std::fs::write(dir.path().join("settings.json"), b"{ not json }").unwrap();
        let store = SettingsStore::load_or_default(dir.path());
        assert_eq!(store.snapshot().boot_menu_timeout_secs, 600);
    }
}
