//! Magic-link passwordless authentication.
//!
//! Issues self-verifying expiring tokens via [`rok_encrypt::Encrypter`].
//! One-time use is enforced through [`crate::blacklist::TokenBlacklist`].
//!
//! # Flow
//!
//! 1. Call [`MagicLink::issue`] with the user's email → get a URL-safe token.
//! 2. Email the token as a link: `https://app.example.com/auth/magic?token=<token>`.
//! 3. On click, call [`MagicLink::verify`] → returns the email on success.
//! 4. Look up or create the user by email, then issue a JWT pair.

use std::time::Duration;

use chrono::Utc;
use sqlx::PgPool;

use crate::blacklist::TokenBlacklist;
use crate::error::AuthError;

const PURPOSE: &str = "magic-link";
const DEFAULT_TTL_SECS: u64 = 15 * 60;

/// Configuration for the magic-link flow.
#[derive(Debug, Clone)]
pub struct MagicLinkConfig {
    /// How long a magic-link token remains valid.  Default: 15 minutes.
    pub ttl: Duration,
    /// Where to redirect the browser after a successful login.
    pub redirect_to: String,
}

impl Default for MagicLinkConfig {
    fn default() -> Self {
        Self {
            ttl: Duration::from_secs(DEFAULT_TTL_SECS),
            redirect_to: "/dashboard".to_string(),
        }
    }
}

/// Namespace for magic-link operations.
pub struct MagicLink;

impl MagicLink {
    /// Issue a signed, expiring magic-link token for `email`.
    ///
    /// The token is a URL-safe AES-256-GCM ciphertext that embeds the email
    /// address and an expiry timestamp — no database row is created.
    pub fn issue(
        encrypter: &rok_encrypt::Encrypter,
        email: &str,
        config: &MagicLinkConfig,
    ) -> String {
        encrypter.seal_for_expiring(PURPOSE, email, config.ttl)
    }

    /// Verify `raw_token` and return the email address encoded inside it.
    ///
    /// Performs three checks:
    /// 1. AES-GCM decryption + purpose validation.
    /// 2. Built-in TTL expiry (→ `AuthError::TokenExpired`).
    /// 3. One-time enforcement via [`TokenBlacklist`] (→ `AuthError::InvalidToken`).
    pub async fn verify(
        pool: &PgPool,
        encrypter: &rok_encrypt::Encrypter,
        raw_token: &str,
    ) -> Result<String, AuthError> {
        let email = encrypter
            .open_for(PURPOSE, raw_token)
            .map_err(|e| match e {
                rok_encrypt::EncryptError::Expired => AuthError::TokenExpired,
                _ => AuthError::InvalidToken,
            })?;

        if TokenBlacklist::is_revoked(pool, raw_token).await {
            return Err(AuthError::InvalidToken);
        }

        // Blacklist the token so it cannot be reused.
        let expires_at = Utc::now() + chrono::Duration::seconds(DEFAULT_TTL_SECS as i64);
        TokenBlacklist::insert(pool, raw_token, expires_at)
            .await
            .map_err(|e| AuthError::Internal(e.to_string()))?;

        Ok(email)
    }
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use rok_encrypt::{EncryptConfig, Encrypter};

    fn enc() -> Encrypter {
        Encrypter::from_config(EncryptConfig::new("test-secret-key"))
    }

    #[test]
    fn issue_produces_non_empty_token() {
        let token = MagicLink::issue(&enc(), "alice@example.com", &MagicLinkConfig::default());
        assert!(!token.is_empty());
    }

    #[test]
    fn token_decrypts_to_email() {
        let e = enc();
        let token = MagicLink::issue(&e, "alice@example.com", &MagicLinkConfig::default());
        let email = e.open_for(PURPOSE, &token).unwrap();
        assert_eq!(email, "alice@example.com");
    }

    #[test]
    fn wrong_purpose_rejected() {
        let e = enc();
        let token = MagicLink::issue(&e, "alice@example.com", &MagicLinkConfig::default());
        let result = e.open_for("password-reset", &token);
        assert!(result.is_err());
    }

    #[test]
    fn different_email_different_token() {
        let e = enc();
        let cfg = MagicLinkConfig::default();
        let t1 = MagicLink::issue(&e, "alice@example.com", &cfg);
        let t2 = MagicLink::issue(&e, "bob@example.com", &cfg);
        assert_ne!(t1, t2);
    }

    #[test]
    fn custom_redirect_preserved() {
        let cfg = MagicLinkConfig {
            redirect_to: "/home".to_string(),
            ..Default::default()
        };
        assert_eq!(cfg.redirect_to, "/home");
    }
}
