//! Auth: trusted-key allow-list and bearer-token validation for `/crdt-sync` connections.

use std::collections::HashMap;
use std::sync::OnceLock;

/// Trusted public keys loaded once at startup.
static TRUSTED_KEYS: OnceLock<Vec<String>> = OnceLock::new();

/// Initialise the trusted-key allow-list for connect-time mutual auth.
///
/// Must be called once at startup before any WebSocket connections are
/// accepted. Subsequent calls are no-ops (OnceLock).
pub fn init_trusted_keys(keys: Vec<String>) {
    let _ = TRUSTED_KEYS.set(keys);
}

/// Return a reference to the trusted-key allow-list.
pub(super) fn trusted_keys() -> &'static [String] {
    TRUSTED_KEYS.get().map(|v| v.as_slice()).unwrap_or(&[])
}

// ── Bearer-token auth ───────────────────────────────────────────────

/// Time-to-live for CRDT bearer tokens in seconds (30 days).
pub(super) const TOKEN_TTL_SECS: f64 = 30.0 * 24.0 * 3600.0;

/// Whether a bearer token is required for `/crdt-sync` connections.
/// `None` (uninitialised) → open access (backward compatible).
pub(super) static REQUIRE_TOKEN: OnceLock<bool> = OnceLock::new();

/// Valid bearer tokens — maps token string to its expiry unix timestamp.
static CRDT_TOKENS: OnceLock<std::sync::RwLock<HashMap<String, f64>>> = OnceLock::new();

/// Initialise bearer-token auth for CRDT-sync connections.
///
/// Must be called once at startup before any WebSocket connections are accepted.
/// When `require` is `true`, clients must supply a valid `?token=` query
/// parameter on the upgrade request or receive HTTP 401.  When `require` is
/// `false` (default) a token is optional — connections without one are
/// accepted, but a supplied token is still validated.
pub fn init_token_auth(require: bool, tokens: Vec<String>) {
    let _ = REQUIRE_TOKEN.set(require);
    let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new()));
    if let Ok(mut map) = store.write() {
        let now = chrono::Utc::now().timestamp() as f64;
        for token in tokens {
            map.insert(token, now + TOKEN_TTL_SECS);
        }
    }
}

/// Add a bearer token to the CRDT-sync token store.
///
/// The token expires after [`TOKEN_TTL_SECS`] seconds. Returns the expiry
/// unix timestamp so callers can surface it in admin tooling.
pub fn add_join_token(token: String) -> f64 {
    let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new()));
    let now = chrono::Utc::now().timestamp() as f64;
    let expires_at = now + TOKEN_TTL_SECS;
    if let Ok(mut map) = store.write() {
        map.insert(token, expires_at);
    }
    expires_at
}

/// Validate a bearer token against the CRDT-sync token store.
///
/// Returns `true` if the token exists in the store and has not expired.
pub(super) fn validate_join_token(token: &str) -> bool {
    let Some(store) = CRDT_TOKENS.get() else {
        return false;
    };
    let now = chrono::Utc::now().timestamp() as f64;
    store
        .read()
        .ok()
        .and_then(|map| map.get(token).copied())
        .is_some_and(|expires_at| expires_at > now)
}

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

    #[test]
    fn config_trusted_keys_parsed_from_toml() {
        let toml_str = r#"
trusted_keys = [
    "aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233",
    "11223344556677881122334455667788112233445566778811223344556677ab",
]

[[agent]]
name = "test"
"#;
        let config: crate::config::ProjectConfig =
            crate::config::ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.trusted_keys.len(), 2);
        assert_eq!(
            config.trusted_keys[0],
            "aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233"
        );
    }

    #[test]
    fn config_trusted_keys_defaults_to_empty() {
        let config = crate::config::ProjectConfig::default();
        assert!(
            config.trusted_keys.is_empty(),
            "trusted_keys must default to empty (reject all)"
        );
    }

    #[test]
    fn valid_token_passes_validation() {
        let token = format!("test-valid-{}", uuid::Uuid::new_v4());
        super::add_join_token(token.clone());
        assert!(
            super::validate_join_token(&token),
            "A freshly added token must pass validation"
        );
    }

    #[test]
    fn bogus_token_fails_validation() {
        let bogus = "this-token-was-never-added-to-the-store";
        assert!(
            !super::validate_join_token(bogus),
            "An unknown token must fail validation"
        );
    }

    #[test]
    fn expired_token_fails_validation() {
        // Insert a token directly with an already-past expiry timestamp.
        let token = format!("test-expired-{}", uuid::Uuid::new_v4());
        let store = super::CRDT_TOKENS
            .get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new()));
        // expires_at = 1 (way in the past — 1970-01-01T00:00:01Z)
        store.write().unwrap().insert(token.clone(), 1.0_f64);
        assert!(
            !super::validate_join_token(&token),
            "An expired token must fail validation"
        );
    }

    #[test]
    fn no_token_with_require_true_is_rejected() {
        // Simulate: require_token=true, token=None → reject.
        let require_token = true;
        let token: Option<&str> = None;
        let should_reject = match token {
            Some(t) => !super::validate_join_token(t),
            None if require_token => true,
            None => false,
        };
        assert!(
            should_reject,
            "Missing token must be rejected when token is required"
        );
    }

    #[test]
    fn no_token_with_require_false_is_accepted() {
        let require_token = false;
        let token: Option<&str> = None;
        let should_reject = match token {
            Some(t) => !super::validate_join_token(t),
            None if require_token => true,
            None => false,
        };
        assert!(
            !should_reject,
            "Missing token must be accepted in open mode"
        );
    }

    #[test]
    fn add_join_token_returns_future_expiry() {
        let token = format!("test-expiry-{}", uuid::Uuid::new_v4());
        let now = chrono::Utc::now().timestamp() as f64;
        let expires_at = super::add_join_token(token);
        assert!(
            expires_at > now,
            "Expiry timestamp must be in the future (got {expires_at}, now={now})"
        );
    }

    #[test]
    fn token_ttl_is_thirty_days() {
        assert_eq!(
            super::TOKEN_TTL_SECS,
            30.0 * 24.0 * 3600.0,
            "TOKEN_TTL_SECS must be 30 days"
        );
    }

    #[test]
    fn config_crdt_require_token_defaults_to_false() {
        let config = crate::config::ProjectConfig::default();
        assert!(
            !config.crdt_require_token,
            "crdt_require_token must default to false (open access)"
        );
    }

    #[test]
    fn config_crdt_tokens_defaults_to_empty() {
        let config = crate::config::ProjectConfig::default();
        assert!(
            config.crdt_tokens.is_empty(),
            "crdt_tokens must default to empty"
        );
    }

    #[test]
    fn config_crdt_token_fields_parsed_from_toml() {
        let toml_str = r#"
crdt_require_token = true
crdt_tokens = ["token-abc", "token-xyz"]

[[agent]]
name = "test"
"#;
        let config: crate::config::ProjectConfig = toml::from_str(toml_str).unwrap();
        assert!(config.crdt_require_token);
        assert_eq!(config.crdt_tokens, vec!["token-abc", "token-xyz"]);
    }
}
