//! Device trust — "remember this device" to bypass TOTP on recognized hardware.
//!
//! # Flow
//!
//! 1. After successful TOTP login, if the user requests device trust, call
//!    [`DeviceTrust::issue`] → receive a cookie value.
//! 2. Set an HttpOnly `rok_device_trust` cookie with the returned value
//!    (30-day `Max-Age`).
//! 3. On subsequent logins, call [`DeviceTrust::verify`] with the cookie value
//!    and the authenticating `user_id`.  If `Ok(true)`, skip the TOTP step.
//! 4. Users can list and revoke trusted devices via [`DeviceTrust::list`] and
//!    [`DeviceTrust::revoke`].
//!
//! # Security model
//!
//! The cookie value is a 32-byte random string encoded as base64url.
//! The database stores its SHA-256 hash (same pattern as [`crate::password_reset`]).
//! An attacker with DB access cannot reconstruct valid cookie values.

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use sqlx::PgPool;

use crate::error::AuthError;
use crate::hash::sha256_hex;

/// Cookie name used for device trust.
pub const COOKIE_NAME: &str = "rok_device_trust";

/// Configuration for the device trust flow.
#[derive(Debug, Clone)]
pub struct DeviceTrustConfig {
    /// How long a device trust token is valid.  Default: 30 days.
    pub ttl: Duration,
    /// Maximum trusted devices per user.  Oldest is evicted when exceeded.
    pub max_per_user: usize,
}

impl Default for DeviceTrustConfig {
    fn default() -> Self {
        Self {
            ttl: Duration::days(30),
            max_per_user: 10,
        }
    }
}

/// Newtype for the `trust_device` parameter after TOTP completion.
///
/// ```rust,ignore
/// DeviceTrust::issue(&pool, user_id, "MacBook Touch ID", &config).await?;
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrustDevice(pub bool);

/// A row from the `device_trust_tokens` table.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DeviceTrustRow {
    pub id: i64,
    pub user_id: i64,
    pub token_hash: String,
    pub device_name: Option<String>,
    pub last_seen_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub created_at: DateTime<Utc>,
}

/// Namespace for device trust operations.
pub struct DeviceTrust;

impl DeviceTrust {
    /// Issue a new device trust token for `user_id`.
    ///
    /// Returns the raw cookie value to be set as an HttpOnly cookie.
    ///
    /// If `max_per_user` is reached the oldest device trust token for this
    /// user is deleted before inserting the new one.
    pub async fn issue(
        pool: &PgPool,
        user_id: i64,
        device_name: Option<&str>,
        config: &DeviceTrustConfig,
    ) -> Result<String, AuthError> {
        evict_oldest_if_needed(pool, user_id, config.max_per_user).await?;

        let raw = generate_token();
        let hash = sha256_hex(&raw);
        let expires_at = Utc::now() + config.ttl;

        sqlx::query(
            "INSERT INTO device_trust_tokens (user_id, token_hash, device_name, expires_at)
             VALUES ($1, $2, $3, $4)",
        )
        .bind(user_id)
        .bind(&hash)
        .bind(device_name)
        .bind(expires_at)
        .execute(pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))?;

        Ok(raw)
    }

    /// Verify a device trust cookie value for `user_id`.
    ///
    /// Returns `true` if the device is trusted (token exists, not expired,
    /// belongs to this user).  On success, `last_seen_at` is updated.
    pub async fn verify(pool: &PgPool, user_id: i64, raw_token: &str) -> Result<bool, AuthError> {
        let hash = sha256_hex(raw_token);

        let row = sqlx::query_as::<_, DeviceTrustRow>(
            "SELECT * FROM device_trust_tokens
             WHERE token_hash = $1 AND user_id = $2 AND expires_at > now()",
        )
        .bind(&hash)
        .bind(user_id)
        .fetch_optional(pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))?;

        let Some(row) = row else { return Ok(false) };

        sqlx::query("UPDATE device_trust_tokens SET last_seen_at = now() WHERE id = $1")
            .bind(row.id)
            .execute(pool)
            .await
            .map_err(|e| AuthError::Internal(e.to_string()))?;

        Ok(true)
    }

    /// List all active trusted devices for `user_id`.
    pub async fn list(pool: &PgPool, user_id: i64) -> Result<Vec<DeviceTrustRow>, AuthError> {
        sqlx::query_as::<_, DeviceTrustRow>(
            "SELECT * FROM device_trust_tokens
             WHERE user_id = $1 AND expires_at > now()
             ORDER BY created_at ASC",
        )
        .bind(user_id)
        .fetch_all(pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Revoke a specific device trust token by `id`.
    ///
    /// Only deletes the token if it belongs to `user_id` (IDOR prevention).
    pub async fn revoke(pool: &PgPool, user_id: i64, token_id: i64) -> Result<(), AuthError> {
        sqlx::query("DELETE FROM device_trust_tokens WHERE id = $1 AND user_id = $2")
            .bind(token_id)
            .bind(user_id)
            .execute(pool)
            .await
            .map_err(|e| AuthError::Internal(e.to_string()))?;
        Ok(())
    }

    /// Revoke all trusted devices for `user_id` (e.g. on password change).
    pub async fn revoke_all(pool: &PgPool, user_id: i64) -> Result<u64, AuthError> {
        let n = sqlx::query("DELETE FROM device_trust_tokens WHERE user_id = $1")
            .bind(user_id)
            .execute(pool)
            .await
            .map_err(|e| AuthError::Internal(e.to_string()))?
            .rows_affected();
        Ok(n)
    }
}

// ── helpers ───────────────────────────────────────────────────────────────────

fn generate_token() -> String {
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

async fn evict_oldest_if_needed(
    pool: &PgPool,
    user_id: i64,
    max_per_user: usize,
) -> Result<(), AuthError> {
    let count: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM device_trust_tokens WHERE user_id = $1 AND expires_at > now()",
    )
    .bind(user_id)
    .fetch_one(pool)
    .await
    .map_err(|e| AuthError::Internal(e.to_string()))?;

    if count >= max_per_user as i64 {
        sqlx::query(
            "DELETE FROM device_trust_tokens WHERE id = (
                SELECT id FROM device_trust_tokens
                WHERE user_id = $1 AND expires_at > now()
                ORDER BY created_at ASC
                LIMIT 1
            )",
        )
        .bind(user_id)
        .execute(pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))?;
    }

    Ok(())
}

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

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

    #[test]
    fn generate_token_is_nonempty_and_base64() {
        let t = generate_token();
        assert!(!t.is_empty());
        assert!(URL_SAFE_NO_PAD.decode(&t).is_ok());
    }

    #[test]
    fn generate_token_is_unique() {
        let a = generate_token();
        let b = generate_token();
        assert_ne!(a, b);
    }

    #[test]
    fn trust_device_newtype() {
        let t = TrustDevice(true);
        assert!(t.0);
        let f = TrustDevice(false);
        assert!(!f.0);
    }

    #[test]
    fn default_config_values() {
        let cfg = DeviceTrustConfig::default();
        assert_eq!(cfg.ttl, Duration::days(30));
        assert_eq!(cfg.max_per_user, 10);
    }

    #[test]
    fn cookie_name_constant() {
        assert_eq!(COOKIE_NAME, "rok_device_trust");
    }
}
