//! Webhook / email notification configuration.
//!
//! v0.5.0: OpenPXE can ping a chat webhook or send an email when
//! something noteworthy happens (a machine PXE-booted an image, a
//! deployment was assigned, a WoL was sent). One active provider at a
//! time, chosen by `kind` — dead-simple for an L1 tech: pick Slack,
//! paste the incoming-webhook URL, done.
//!
//! This module owns only the *configuration* (validation + persistence
//! to `<work_dir>/notify.json`). The actual sending — HTTP POST for the
//! chat providers, SMTP for email — lives in the http-api crate, which
//! already carries an HTTP client and the SMTP dependency. Keeping the
//! network I/O out of `core` matches how `BrandingStore`/`SsoStore`
//! stay pure config stores.
//!
//! Secrets note: the SMTP password is persisted in `notify.json`
//! alongside the rest of the config (0644 like the other state files).
//! It is never echoed back through the API — the snapshot used for the
//! GET response blanks it (see `Self::redacted`).

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

use crate::{Error, Result};

const MAX_URL_LEN: usize = 2048;
const MAX_FIELD_LEN: usize = 512;

/// Which notification transport is active.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyKind {
    /// Slack incoming webhook (`{ "text": ... }`).
    #[default]
    Slack,
    /// Discord webhook (`{ "content": ... }`).
    Discord,
    /// Microsoft Teams incoming webhook (legacy MessageCard JSON).
    Teams,
    /// Email via SMTP.
    Smtp,
}

impl NotifyKind {
    /// True when this kind drives a chat webhook (POST a JSON body to a
    /// single URL) rather than SMTP.
    #[must_use]
    pub fn is_webhook(self) -> bool {
        matches!(self, Self::Slack | Self::Discord | Self::Teams)
    }
}

/// Operator-configurable notification settings. Single provider active
/// at a time; the inactive fields are kept so switching providers
/// doesn't wipe the other one's values.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotifyConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub kind: NotifyKind,
    /// Incoming-webhook URL for Slack / Discord / Teams.
    #[serde(default)]
    pub webhook_url: String,

    // ── SMTP fields (used when kind == Smtp) ──
    #[serde(default)]
    pub smtp_host: String,
    #[serde(default = "default_smtp_port")]
    pub smtp_port: u16,
    #[serde(default)]
    pub smtp_username: String,
    #[serde(default)]
    pub smtp_password: String,
    /// `From:` address. Falls back to `smtp_username` when blank.
    #[serde(default)]
    pub smtp_from: String,
    /// `To:` address (single recipient — keep it simple).
    #[serde(default)]
    pub smtp_to: String,
    /// Use implicit TLS (port 465). When false we use STARTTLS on the
    /// configured port (587 typical). Either way the connection is
    /// encrypted — we never offer plaintext SMTP.
    #[serde(default)]
    pub smtp_implicit_tls: bool,
}

fn default_smtp_port() -> u16 {
    587
}

impl NotifyConfig {
    /// True when enabled and the active provider has the fields it
    /// needs to actually send.
    #[must_use]
    pub fn is_usable(&self) -> bool {
        if !self.enabled {
            return false;
        }
        if self.kind.is_webhook() {
            !self.webhook_url.trim().is_empty()
        } else {
            !self.smtp_host.trim().is_empty() && !self.smtp_to.trim().is_empty()
        }
    }

    /// A copy safe to return over the API: the SMTP password is blanked
    /// (replaced with a non-empty sentinel only when one is set, so the
    /// UI can show "configured" without leaking it).
    #[must_use]
    pub fn redacted(&self) -> NotifyConfig {
        let mut c = self.clone();
        if !c.smtp_password.is_empty() {
            c.smtp_password = SECRET_SENTINEL.to_string();
        }
        c
    }
}

/// Returned by the API in place of a stored password. When the UI PUTs
/// this value back unchanged we keep the existing password rather than
/// overwriting it with the sentinel.
pub const SECRET_SENTINEL: &str = "__keep__";

/// In-memory + on-disk notification config registry.
#[derive(Debug, Clone)]
pub struct NotifyStore {
    path: Arc<PathBuf>,
    inner: Arc<RwLock<NotifyConfig>>,
}

impl NotifyStore {
    #[must_use]
    pub fn load_or_default(work_dir: &std::path::Path) -> Self {
        let path = work_dir.join("notify.json");
        let cfg = match std::fs::read_to_string(&path) {
            Ok(text) => serde_json::from_str::<NotifyConfig>(&text).unwrap_or_else(|e| {
                tracing::warn!(
                    target: "openpxe::notify",
                    "notify.json unreadable ({e}); starting with defaults"
                );
                NotifyConfig::default()
            }),
            Err(_) => NotifyConfig::default(),
        };
        Self {
            path: Arc::new(path),
            inner: Arc::new(RwLock::new(cfg)),
        }
    }

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

    /// Replace the whole config. `incoming.smtp_password == SECRET_SENTINEL`
    /// is treated as "keep the existing password" so the UI never has to
    /// round-trip the real secret.
    pub fn replace(&self, mut incoming: NotifyConfig) -> Result<NotifyConfig> {
        incoming.webhook_url = incoming.webhook_url.trim().to_string();
        incoming.smtp_host = incoming.smtp_host.trim().to_string();
        incoming.smtp_username = incoming.smtp_username.trim().to_string();
        incoming.smtp_from = incoming.smtp_from.trim().to_string();
        incoming.smtp_to = incoming.smtp_to.trim().to_string();

        // Preserve the stored password when the UI sends the sentinel.
        if incoming.smtp_password == SECRET_SENTINEL {
            incoming
                .smtp_password
                .clone_from(&self.inner.read().smtp_password);
        }

        // Length caps.
        if incoming.webhook_url.len() > MAX_URL_LEN {
            return Err(Error::Invalid(format!(
                "webhook URL exceeds {MAX_URL_LEN}-char cap"
            )));
        }
        for (name, v) in [
            ("smtp_host", &incoming.smtp_host),
            ("smtp_username", &incoming.smtp_username),
            ("smtp_from", &incoming.smtp_from),
            ("smtp_to", &incoming.smtp_to),
        ] {
            if v.len() > MAX_FIELD_LEN {
                return Err(Error::Invalid(format!(
                    "{name} exceeds {MAX_FIELD_LEN}-char cap"
                )));
            }
        }

        // Validate the active provider only when enabling.
        if incoming.enabled {
            if incoming.kind.is_webhook() {
                if incoming.webhook_url.is_empty() {
                    return Err(Error::Invalid(
                        "a webhook URL is required to enable chat notifications".into(),
                    ));
                }
                if !incoming.webhook_url.starts_with("https://")
                    && !incoming.webhook_url.starts_with("http://")
                {
                    return Err(Error::Invalid(
                        "webhook URL must start with http:// or https://".into(),
                    ));
                }
            } else {
                if incoming.smtp_host.is_empty() {
                    return Err(Error::Invalid(
                        "SMTP host is required to enable email notifications".into(),
                    ));
                }
                if incoming.smtp_to.is_empty() {
                    return Err(Error::Invalid(
                        "a recipient (To) is required to enable email notifications".into(),
                    ));
                }
                if incoming.smtp_port == 0 {
                    return Err(Error::Invalid("SMTP port must be non-zero".into()));
                }
            }
        }

        {
            let mut g = self.inner.write();
            *g = incoming.clone();
        }
        self.persist();
        tracing::info!(
            target: "openpxe::notify",
            enabled = incoming.enabled, kind = ?incoming.kind,
            "notification configuration updated"
        );
        Ok(incoming)
    }

    fn persist(&self) {
        let snap = self.inner.read().clone();
        let body = match serde_json::to_vec_pretty(&snap) {
            Ok(b) => b,
            Err(e) => {
                tracing::warn!(target: "openpxe::notify", "serialize notify.json: {e}");
                return;
            }
        };
        if let Some(parent) = self.path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let tmp = self.path.with_extension("json.tmp");
        if let Err(e) = std::fs::write(&tmp, body) {
            tracing::warn!(target: "openpxe::notify", "write notify.json tmp: {e}");
            return;
        }
        if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
            tracing::warn!(target: "openpxe::notify", "rename notify.json: {e}");
        }
    }
}

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

    #[test]
    fn default_disabled_not_usable() {
        let dir = tempdir().unwrap();
        let s = NotifyStore::load_or_default(dir.path());
        assert!(!s.snapshot().enabled);
        assert!(!s.snapshot().is_usable());
    }

    #[test]
    fn slack_requires_url_when_enabled() {
        let dir = tempdir().unwrap();
        let s = NotifyStore::load_or_default(dir.path());
        let r = s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Slack,
            ..Default::default()
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
        s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Slack,
            webhook_url: "https://hooks.slack.com/services/XXX".into(),
            ..Default::default()
        })
        .unwrap();
        assert!(s.snapshot().is_usable());
    }

    #[test]
    fn smtp_requires_host_and_recipient() {
        let dir = tempdir().unwrap();
        let s = NotifyStore::load_or_default(dir.path());
        let r = s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Smtp,
            smtp_host: "smtp.example.com".into(),
            ..Default::default()
        });
        assert!(matches!(r, Err(Error::Invalid(_))), "missing recipient should reject");
        s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Smtp,
            smtp_host: "smtp.example.com".into(),
            smtp_port: 587,
            smtp_to: "ops@example.com".into(),
            smtp_from: "openpxe@example.com".into(),
            ..Default::default()
        })
        .unwrap();
        assert!(s.snapshot().is_usable());
    }

    #[test]
    fn password_sentinel_preserves_stored_secret() {
        let dir = tempdir().unwrap();
        let s = NotifyStore::load_or_default(dir.path());
        s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Smtp,
            smtp_host: "smtp.example.com".into(),
            smtp_port: 587,
            smtp_to: "ops@example.com".into(),
            smtp_password: "s3cret".into(),
            ..Default::default()
        })
        .unwrap();
        // Redacted snapshot hides the password behind the sentinel.
        assert_eq!(s.snapshot().redacted().smtp_password, SECRET_SENTINEL);
        // PUTting the sentinel back keeps the real password.
        s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Smtp,
            smtp_host: "smtp.example.com".into(),
            smtp_port: 587,
            smtp_to: "ops@example.com".into(),
            smtp_password: SECRET_SENTINEL.into(),
            ..Default::default()
        })
        .unwrap();
        assert_eq!(s.snapshot().smtp_password, "s3cret");
    }

    #[test]
    fn webhook_url_scheme_enforced() {
        let dir = tempdir().unwrap();
        let s = NotifyStore::load_or_default(dir.path());
        let r = s.replace(NotifyConfig {
            enabled: true,
            kind: NotifyKind::Discord,
            webhook_url: "ftp://example.com/hook".into(),
            ..Default::default()
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
    }
}
