//! Sanctum-style personal access tokens with ability scoping.
//!
//! Tokens are opaque strings in the format `rok_live_<40 alphanum><8-char CRC32 hex>`.
//! Only the SHA-256 hash is stored in `personal_access_tokens`; the plain token
//! is shown to the caller once and never retained.

use chrono::{DateTime, Duration as CDuration, Utc};
use rand::{distributions::Alphanumeric, Rng};
use sqlx::PgPool;
use std::time::Duration;

use crate::{hash::sha256_hex, AuthError};

pub const LIVE_PREFIX: &str = "rok_live_";
pub const TEST_PREFIX: &str = "rok_test_";

// ── PlainToken ────────────────────────────────────────────────────────────────

/// The raw access token, shown to the caller once.  Hash it before storing.
pub struct PlainToken(pub String);

impl PlainToken {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for PlainToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

// ── AccessToken ───────────────────────────────────────────────────────────────

/// A row from `personal_access_tokens`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct AccessToken {
    pub id: i64,
    pub tokenable_id: i64,
    pub name: String,
    /// SHA-256 hash of the plain token (column name: `token`).
    #[sqlx(rename = "token")]
    pub token_hash: String,
    pub abilities: Vec<String>,
    pub last_used_at: Option<DateTime<Utc>>,
    pub expires_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
}

impl AccessToken {
    /// `true` if the token grants `ability` (or has wildcard `*`).
    pub fn can(&self, ability: &str) -> bool {
        self.abilities.iter().any(|a| a == "*" || a == ability)
    }

    pub fn is_expired(&self) -> bool {
        self.expires_at.map(|e| e < Utc::now()).unwrap_or(false)
    }
}

// ── AccessTokenManager ────────────────────────────────────────────────────────

/// CRUD operations for personal access tokens.
pub struct AccessTokenManager<'a> {
    pool: &'a PgPool,
}

impl<'a> AccessTokenManager<'a> {
    pub fn new(pool: &'a PgPool) -> Self {
        Self { pool }
    }

    /// Create a wildcard (`*`) token with the name `"default"`.
    pub async fn create(&self, user_id: i64) -> Result<PlainToken, AuthError> {
        self.insert(user_id, "default", &["*"], None).await
    }

    /// Create a named token scoped to `abilities`.
    pub async fn create_for(
        &self,
        user_id: i64,
        name: &str,
        abilities: &[&str],
    ) -> Result<PlainToken, AuthError> {
        self.insert(user_id, name, abilities, None).await
    }

    /// Create a named, expiring token scoped to `abilities`.
    pub async fn create_expiring(
        &self,
        user_id: i64,
        name: &str,
        abilities: &[&str],
        ttl: Duration,
    ) -> Result<PlainToken, AuthError> {
        self.insert(user_id, name, abilities, Some(ttl)).await
    }

    /// Return all tokens for `user_id`, newest first.
    pub async fn for_user(&self, user_id: i64) -> Result<Vec<AccessToken>, AuthError> {
        sqlx::query_as::<_, AccessToken>(
            "SELECT id, tokenable_id, name, token, abilities, \
                    last_used_at, expires_at, created_at \
             FROM personal_access_tokens \
             WHERE tokenable_id = $1 \
             ORDER BY created_at DESC",
        )
        .bind(user_id)
        .fetch_all(self.pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Delete the token with the given database `id`.
    pub async fn revoke(&self, token_id: i64) -> Result<(), AuthError> {
        sqlx::query("DELETE FROM personal_access_tokens WHERE id = $1")
            .bind(token_id)
            .execute(self.pool)
            .await
            .map(|_| ())
            .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Delete the token that matches the given plain token string.
    pub async fn revoke_current(&self, token: &str) -> Result<(), AuthError> {
        let hash = hash_token(token);
        sqlx::query("DELETE FROM personal_access_tokens WHERE token = $1")
            .bind(&hash)
            .execute(self.pool)
            .await
            .map(|_| ())
            .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Delete all tokens for `user_id`.
    pub async fn revoke_all(&self, user_id: i64) -> Result<(), AuthError> {
        sqlx::query("DELETE FROM personal_access_tokens WHERE tokenable_id = $1")
            .bind(user_id)
            .execute(self.pool)
            .await
            .map(|_| ())
            .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Delete all expired tokens.  Returns the number of rows removed.
    pub async fn prune_expired(&self) -> Result<u64, AuthError> {
        sqlx::query(
            "DELETE FROM personal_access_tokens \
             WHERE expires_at IS NOT NULL AND expires_at < NOW()",
        )
        .execute(self.pool)
        .await
        .map(|r| r.rows_affected())
        .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Look up a token by its plain value.  Returns `None` if not found.
    pub async fn find_by_token(&self, token: &str) -> Result<Option<AccessToken>, AuthError> {
        let hash = hash_token(token);
        sqlx::query_as::<_, AccessToken>(
            "SELECT id, tokenable_id, name, token, abilities, \
                    last_used_at, expires_at, created_at \
             FROM personal_access_tokens WHERE token = $1",
        )
        .bind(&hash)
        .fetch_optional(self.pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))
    }

    /// Stamp `last_used_at = NOW()` on the given token row.
    pub async fn touch(&self, token_id: i64) -> Result<(), AuthError> {
        sqlx::query("UPDATE personal_access_tokens SET last_used_at = NOW() WHERE id = $1")
            .bind(token_id)
            .execute(self.pool)
            .await
            .map(|_| ())
            .map_err(|e| AuthError::Internal(e.to_string()))
    }

    // ── private ───────────────────────────────────────────────────────────────

    async fn insert(
        &self,
        user_id: i64,
        name: &str,
        abilities: &[&str],
        expires_in: Option<Duration>,
    ) -> Result<PlainToken, AuthError> {
        let plain = generate_token(LIVE_PREFIX);
        let hash = hash_token(&plain);
        let abilities_owned: Vec<String> = abilities.iter().map(|s| s.to_string()).collect();
        let expires_at: Option<DateTime<Utc>> =
            expires_in.and_then(|d| CDuration::from_std(d).ok().map(|cd| Utc::now() + cd));

        sqlx::query(
            "INSERT INTO personal_access_tokens \
             (tokenable_id, name, token, abilities, expires_at) \
             VALUES ($1, $2, $3, $4, $5)",
        )
        .bind(user_id)
        .bind(name)
        .bind(&hash)
        .bind(&abilities_owned)
        .bind(expires_at)
        .execute(self.pool)
        .await
        .map_err(|e| AuthError::Internal(e.to_string()))?;

        Ok(PlainToken(plain))
    }
}

// ── token helpers ─────────────────────────────────────────────────────────────

/// Generate `<prefix><40 random alphanum><8-char CRC32 hex>`.
pub fn generate_token(prefix: &str) -> String {
    let random: String = rand::thread_rng()
        .sample_iter(&Alphanumeric)
        .take(40)
        .map(char::from)
        .collect();
    let checksum = crc32fast::hash(random.as_bytes());
    format!("{prefix}{random}{checksum:08x}")
}

/// SHA-256 hash of a plain token — stored in the DB, never the plain value.
pub fn hash_token(raw: &str) -> String {
    sha256_hex(raw)
}

/// `true` if `s` starts with a known rok access-token prefix.
pub fn is_access_token(s: &str) -> bool {
    s.starts_with(LIVE_PREFIX) || s.starts_with(TEST_PREFIX)
}

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

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

    #[test]
    fn generate_live_token() {
        let token = generate_token(LIVE_PREFIX);
        assert!(token.starts_with(LIVE_PREFIX));
        // prefix(9) + 40 random + 8 crc32 hex = 57 chars total
        assert_eq!(token.len(), LIVE_PREFIX.len() + 40 + 8);
    }

    #[test]
    fn is_access_token_detection() {
        assert!(is_access_token("rok_live_abc123"));
        assert!(is_access_token("rok_test_abc123"));
        assert!(!is_access_token("eyJhbGciOiJIUzI1NiJ9.some.jwt"));
        assert!(!is_access_token("rk_live_oldformat"));
    }

    #[test]
    fn hash_is_deterministic() {
        let token = "rok_live_testtoken";
        assert_eq!(hash_token(token), hash_token(token));
        assert_ne!(hash_token(token), hash_token("rok_live_other"));
    }

    #[test]
    fn access_token_can_checks() {
        let at = AccessToken {
            id: 1,
            tokenable_id: 42,
            name: "test".into(),
            token_hash: "hash".into(),
            abilities: vec!["posts:read".into(), "posts:write".into()],
            last_used_at: None,
            expires_at: None,
            created_at: Utc::now(),
        };
        assert!(at.can("posts:read"));
        assert!(at.can("posts:write"));
        assert!(!at.can("posts:delete"));
    }

    #[test]
    fn wildcard_ability_grants_all() {
        let at = AccessToken {
            id: 1,
            tokenable_id: 42,
            name: "admin".into(),
            token_hash: "hash".into(),
            abilities: vec!["*".into()],
            last_used_at: None,
            expires_at: None,
            created_at: Utc::now(),
        };
        assert!(at.can("anything"));
        assert!(at.can("posts:delete"));
    }
}
