//! SAML SSO configuration — FleetDM-shaped.
//!
//! The operator pastes their IdP's metadata XML (or its URL) and a
//! human-readable label. As of v0.5.1 the SAML login flow is wired
//! end-to-end (see [`crate::saml`]): SP-initiated AuthnRequest, the ACS
//! endpoint, pure-Rust signature verification, and operator-session
//! minting. This module owns only the persisted *configuration*.
//!
//! Shape borrowed from <https://github.com/fleetdm/fleet>'s app-config
//! SSO block, minus the user-RBAC fields (OpenPXE is single-tier: any
//! IdP-authenticated user the SP cryptographically verifies gets an
//! operator session; there is no per-user role table). Entity ID is
//! exposed (FleetDM-style) but defaults to the advertised public base
//! URL when blank, which is what most IdPs expect anyway.

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

use crate::{Error, Result};

/// The configurable surface. `metadata` and `metadata_url` are mutually
/// exclusive at apply time (one or the other identifies the IdP); the
/// store keeps both fields so an operator can switch between them
/// without losing the inactive one.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SsoConfig {
    /// Master switch — when false, all SSO machinery (planned for a
    /// later release) is skipped regardless of the rest of the fields.
    #[serde(default)]
    pub enabled: bool,
    /// Display name shown on the WebUI's login screen as the "Sign in
    /// with X" button label. Empty/whitespace falls back to "SSO".
    #[serde(default)]
    pub idp_name: String,
    /// Optional HTTPS URL pointing at the IdP's brand logo. Rendered
    /// next to `idp_name` on the WebUI's login screen (FleetDM-style).
    /// Length-capped at [`MAX_URL_LEN`]; empty is fine.
    #[serde(default)]
    pub idp_logo_url: String,
    /// Raw SAML metadata XML pasted by the operator. Mutually exclusive
    /// with `metadata_url`; if both are set, the URL wins at apply time
    /// (operators typically forget about a stale XML paste).
    #[serde(default)]
    pub metadata: String,
    /// HTTPS URL where the IdP serves its metadata. Loaded lazily by the
    /// future SAML flow; not validated here beyond a basic length cap.
    #[serde(default)]
    pub metadata_url: String,
    /// SP Entity ID advertised to the IdP — mirrors FleetDM's "Entity ID".
    /// Must exactly match the SP/Relying-Party entry configured on the IdP.
    /// Empty falls back to the advertised public base URL at runtime, which
    /// is what most IdPs expect. Length-capped at [`MAX_URL_LEN`].
    #[serde(default)]
    pub entity_id: String,
    /// Allow IdP-initiated login — an unsolicited `<Response>` POSTed to the
    /// ACS with no `InResponseTo`. Mirrors FleetDM's "Allow SSO login
    /// initiated by identity provider". Default off; SP-initiated (the
    /// "Sign in with X" button) is always allowed regardless.
    #[serde(default)]
    pub allow_idp_initiated: bool,
}

impl SsoConfig {
    /// Returns `true` only when the config is *usable* — enabled, and
    /// at least one of metadata/metadata_url is present. The future
    /// login flow will key off this; for v0.4.5 the WebUI uses it to
    /// surface a yellow "configured but not live yet" hint.
    #[must_use]
    pub fn is_usable(&self) -> bool {
        self.enabled && (!self.metadata.trim().is_empty() || !self.metadata_url.trim().is_empty())
    }
}

/// In-memory + on-disk SSO settings registry.
#[derive(Debug, Clone)]
pub struct SsoStore {
    path: Arc<PathBuf>,
    inner: Arc<RwLock<SsoConfig>>,
}

impl SsoStore {
    /// Load from `<work_dir>/sso.json`, or start with the default empty
    /// (`enabled = false`) config. A corrupt file falls back to default
    /// rather than blocking startup.
    #[must_use]
    pub fn load_or_default(work_dir: &std::path::Path) -> Self {
        let path = work_dir.join("sso.json");
        let cfg = match std::fs::read_to_string(&path) {
            Ok(text) => match serde_json::from_str::<SsoConfig>(&text) {
                Ok(parsed) => parsed,
                Err(e) => {
                    tracing::warn!(
                        target: "openpxe::sso",
                        "sso.json present but unreadable ({e}); starting with default config"
                    );
                    SsoConfig::default()
                }
            },
            Err(_) => SsoConfig::default(),
        };
        Self {
            path: Arc::new(path),
            inner: Arc::new(RwLock::new(cfg)),
        }
    }

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

    /// Replace the whole config in one shot. Light validation: metadata
    /// XML and URL are length-capped so an operator can't OOM us by
    /// pasting a 10 GiB blob; the IdP UI tab clamps the input visually,
    /// but the server enforces a hard ceiling regardless.
    pub fn replace(&self, mut cfg: SsoConfig) -> Result<SsoConfig> {
        cfg.idp_name = cfg.idp_name.trim().to_string();
        cfg.idp_logo_url = cfg.idp_logo_url.trim().to_string();
        cfg.metadata = cfg.metadata.trim().to_string();
        cfg.metadata_url = cfg.metadata_url.trim().to_string();
        cfg.entity_id = cfg.entity_id.trim().to_string();
        if cfg.entity_id.len() > MAX_URL_LEN {
            return Err(Error::Invalid(format!(
                "entity_id exceeds {MAX_URL_LEN}-char cap"
            )));
        }
        if cfg.metadata.len() > MAX_METADATA_BYTES {
            return Err(Error::Invalid(format!(
                "metadata XML exceeds {MAX_METADATA_BYTES}-byte cap"
            )));
        }
        if cfg.metadata_url.len() > MAX_URL_LEN {
            return Err(Error::Invalid(format!(
                "metadata_url exceeds {MAX_URL_LEN}-char cap"
            )));
        }
        if cfg.idp_logo_url.len() > MAX_URL_LEN {
            return Err(Error::Invalid(format!(
                "idp_logo_url exceeds {MAX_URL_LEN}-char cap"
            )));
        }
        if !cfg.metadata_url.is_empty()
            && !cfg.metadata_url.starts_with("http://")
            && !cfg.metadata_url.starts_with("https://")
        {
            return Err(Error::Invalid(
                "metadata_url must start with http:// or https://".into(),
            ));
        }
        if !cfg.idp_logo_url.is_empty()
            && !cfg.idp_logo_url.starts_with("http://")
            && !cfg.idp_logo_url.starts_with("https://")
        {
            return Err(Error::Invalid(
                "idp_logo_url must start with http:// or https://".into(),
            ));
        }
        // If they're trying to *enable* the integration but haven't
        // supplied either source, reject — saves a "configured but
        // unusable" surprise later.
        if cfg.enabled && cfg.metadata.is_empty() && cfg.metadata_url.is_empty() {
            return Err(Error::Invalid(
                "enable SSO requires either metadata XML or a metadata URL".into(),
            ));
        }
        {
            let mut g = self.inner.write();
            *g = cfg.clone();
        }
        self.persist();
        tracing::info!(
            target: "openpxe::sso",
            enabled = cfg.enabled,
            idp = %cfg.idp_name,
            has_xml = !cfg.metadata.is_empty(),
            has_url = !cfg.metadata_url.is_empty(),
            "sso configuration updated"
        );
        Ok(cfg)
    }

    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::sso", "serialize sso.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::sso", "write sso.json tmp: {e}");
            return;
        }
        if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
            tracing::warn!(target: "openpxe::sso", "rename sso.json: {e}");
        }
    }
}

/// Saturation caps. The numbers are generous for any real IdP metadata
/// document — Okta's largest is ~50 KB, Azure AD's ~30 KB.
const MAX_METADATA_BYTES: usize = 1024 * 1024;
const MAX_URL_LEN: usize = 2048;

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

    #[test]
    fn default_is_disabled_and_empty() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let cfg = s.snapshot();
        assert!(!cfg.enabled);
        assert!(cfg.metadata.is_empty());
        assert!(cfg.metadata_url.is_empty());
        assert!(!cfg.is_usable());
    }

    #[test]
    fn replace_metadata_url_round_trip_via_disk() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        s.replace(SsoConfig {
            enabled: true,
            idp_name: "Okta".into(),
            metadata: String::new(),
            metadata_url: "https://idp.example.com/metadata".into(),
            idp_logo_url: String::new(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        })
        .unwrap();
        drop(s);
        let s2 = SsoStore::load_or_default(dir.path());
        let cfg = s2.snapshot();
        assert!(cfg.enabled);
        assert!(cfg.is_usable());
        assert_eq!(cfg.idp_name, "Okta");
        assert_eq!(cfg.metadata_url, "https://idp.example.com/metadata");
    }

    #[test]
    fn replace_xml_paste_is_accepted() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let xml = r#"<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">test</EntityDescriptor>"#;
        s.replace(SsoConfig {
            enabled: true,
            idp_name: "Test IdP".into(),
            metadata: xml.into(),
            metadata_url: String::new(),
            idp_logo_url: String::new(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        })
        .unwrap();
        assert!(s.snapshot().is_usable());
    }

    #[test]
    fn enable_without_source_is_rejected() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let r = s.replace(SsoConfig {
            enabled: true,
            idp_name: "Okta".into(),
            metadata: String::new(),
            metadata_url: String::new(),
            idp_logo_url: String::new(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
        // …and a disabled blank config is fine.
        s.replace(SsoConfig::default()).unwrap();
    }

    #[test]
    fn metadata_url_must_be_http_scheme() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let r = s.replace(SsoConfig {
            enabled: false,
            idp_name: String::new(),
            metadata: String::new(),
            metadata_url: "ftp://idp.example.com/metadata".into(),
            idp_logo_url: String::new(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
    }

    #[test]
    fn idp_logo_url_must_be_http_scheme() {
        // v0.4.6: SSO settings learned an idp_logo_url so the login
        // screen can render the FleetDM-style "Sign in with <IdP-logo>"
        // affordance. Same scheme rule as metadata_url.
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let r = s.replace(SsoConfig {
            enabled: false,
            idp_name: "Okta".into(),
            metadata: String::new(),
            metadata_url: String::new(),
            idp_logo_url: "data:image/png;base64,...".into(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
        // Real HTTPS URL is fine.
        s.replace(SsoConfig {
            enabled: false,
            idp_name: "Okta".into(),
            metadata: String::new(),
            metadata_url: String::new(),
            idp_logo_url: "https://idp.example.com/logo.png".into(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        })
        .unwrap();
        assert_eq!(
            s.snapshot().idp_logo_url,
            "https://idp.example.com/logo.png"
        );
    }

    #[test]
    fn entity_id_and_idp_initiated_round_trip() {
        // v0.5.1: SP Entity ID + IdP-initiated toggle persist across reload.
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        s.replace(SsoConfig {
            enabled: true,
            idp_name: "Keycloak".into(),
            metadata: String::new(),
            metadata_url: "https://idp.example.com/metadata".into(),
            idp_logo_url: String::new(),
            entity_id: "https://pxe.example.com".into(),
            allow_idp_initiated: true,
        })
        .unwrap();
        drop(s);
        let cfg = SsoStore::load_or_default(dir.path()).snapshot();
        assert_eq!(cfg.entity_id, "https://pxe.example.com");
        assert!(cfg.allow_idp_initiated);
    }

    #[test]
    fn entity_id_cap_enforced() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let r = s.replace(SsoConfig {
            enabled: false,
            idp_name: String::new(),
            metadata: String::new(),
            metadata_url: String::new(),
            idp_logo_url: String::new(),
            entity_id: "x".repeat(MAX_URL_LEN + 1),
            allow_idp_initiated: false,
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
    }

    #[test]
    fn metadata_size_cap_enforced() {
        let dir = tempdir().unwrap();
        let s = SsoStore::load_or_default(dir.path());
        let oversize = "a".repeat(MAX_METADATA_BYTES + 1);
        let r = s.replace(SsoConfig {
            enabled: false,
            idp_name: String::new(),
            metadata: oversize,
            metadata_url: String::new(),
            idp_logo_url: String::new(),
            entity_id: String::new(),
            allow_idp_initiated: false,
        });
        assert!(matches!(r, Err(Error::Invalid(_))));
    }
}
