//! Password reset — single-use tokens for the "forgot password" flow.
//!
//! # Schema
//!
//! See `migrations/004_password_resets.sql`.
//!
//! # Flow
//!
//! 1. User submits their email: call [`PasswordReset::issue`] and send the
//!    returned `plain_token` by email.
//! 2. User submits token + new password: call [`PasswordReset::verify`] to
//!    consume the token, then hash + save the new password.

use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;

use crate::hash::sha256_hex;

const TOKEN_TTL_MINS: i64 = 60;

/// A newly-issued password reset token.
pub struct IssuedReset {
    /// Plain-text token — include in the email link, never store.
    pub plain_token: String,
    pub expires_at: DateTime<Utc>,
}

/// Row from `password_resets`.
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PasswordReset {
    pub id: i64,
    pub email: String,
    pub token_hash: String,
    pub expires_at: DateTime<Utc>,
    pub used_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
}

impl PasswordReset {
    /// Generate a reset token for `email`.
    ///
    /// Rate-limited at the DB level: replaces any unused previous token for the
    /// same email (prevents token accumulation).
    pub async fn issue(pool: &PgPool, email: &str) -> Result<IssuedReset, sqlx::Error> {
        let plain_token = generate_token();
        let hash = sha256_hex(&plain_token);
        let expires_at = Utc::now() + Duration::minutes(TOKEN_TTL_MINS);

        // Invalidate old unused tokens for this email.
        sqlx::query(
            "UPDATE password_resets SET used_at = now()
             WHERE email = $1 AND used_at IS NULL",
        )
        .bind(email)
        .execute(pool)
        .await?;

        sqlx::query(
            "INSERT INTO password_resets (email, token_hash, expires_at)
             VALUES ($1, $2, $3)",
        )
        .bind(email)
        .bind(&hash)
        .bind(expires_at)
        .execute(pool)
        .await?;

        Ok(IssuedReset {
            plain_token,
            expires_at,
        })
    }

    /// Validate `plain_token` and consume it.
    ///
    /// Returns the associated `email` on success, `None` when the token is
    /// invalid, expired, or already used.
    pub async fn verify(pool: &PgPool, plain_token: &str) -> Result<Option<String>, sqlx::Error> {
        let hash = sha256_hex(plain_token);
        let row = sqlx::query_as::<_, Self>(
            "SELECT * FROM password_resets
             WHERE token_hash = $1
               AND used_at IS NULL
               AND expires_at > now()",
        )
        .bind(&hash)
        .fetch_optional(pool)
        .await?;

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

        sqlx::query("UPDATE password_resets SET used_at = now() WHERE id = $1")
            .bind(row.id)
            .execute(pool)
            .await?;

        Ok(Some(row.email))
    }
}

fn generate_token() -> String {
    use rand::RngCore;
    let mut bytes = [0u8; 32];
    rand::thread_rng().fill_bytes(&mut bytes);
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}
