//! CRUD over `app_user_email_verifications` (v1.1.8 commit 5).
//!
//! One-shot tokens. The consume path is an atomic UPDATE WHERE
//! consumed_at IS NULL: rowcount = 1 means the token was valid and is
//! now used; rowcount = 0 means missing / already-consumed / wrong
//! app. The repo never returns the raw token — only the user id the
//! consumed token belonged to.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, AppUserId};
use sqlx::PgPool;

#[derive(Debug, thiserror::Error)]
pub enum AppUserVerificationRepoError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),
}

#[async_trait]
pub trait AppUserVerificationRepo: Send + Sync {
    async fn create(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        token_hash: &str,
        expires_at: DateTime<Utc>,
    ) -> Result<(), AppUserVerificationRepoError>;

    /// Atomic consume. Returns `Some(user_id)` on success (rowcount = 1),
    /// `None` on missing / already-consumed / expired / wrong app.
    /// Scoped to `app_id` so a token issued for app A cannot be
    /// presented to app B.
    async fn consume(
        &self,
        app_id: AppId,
        token_hash: &str,
    ) -> Result<Option<AppUserId>, AppUserVerificationRepoError>;

    /// GC: drop consumed or expired rows, matching the dead-letter /
    /// session sweep pattern.
    async fn gc(&self, batch_size: i64) -> Result<u64, AppUserVerificationRepoError>;
}

pub struct PostgresAppUserVerificationRepo {
    pool: PgPool,
}

impl PostgresAppUserVerificationRepo {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl AppUserVerificationRepo for PostgresAppUserVerificationRepo {
    async fn create(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        token_hash: &str,
        expires_at: DateTime<Utc>,
    ) -> Result<(), AppUserVerificationRepoError> {
        sqlx::query(
            "INSERT INTO app_user_email_verifications \
                 (token_hash, app_id, user_id, expires_at) \
             VALUES ($1, $2, $3, $4)",
        )
        .bind(token_hash)
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(expires_at)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn consume(
        &self,
        app_id: AppId,
        token_hash: &str,
    ) -> Result<Option<AppUserId>, AppUserVerificationRepoError> {
        let row: Option<(uuid::Uuid,)> = sqlx::query_as(
            "UPDATE app_user_email_verifications \
             SET consumed_at = NOW() \
             WHERE token_hash = $1 \
               AND app_id = $2 \
               AND consumed_at IS NULL \
               AND expires_at > NOW() \
             RETURNING user_id",
        )
        .bind(token_hash)
        .bind(app_id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(|(uid,)| uid.into()))
    }

    async fn gc(&self, batch_size: i64) -> Result<u64, AppUserVerificationRepoError> {
        let res = sqlx::query(
            "DELETE FROM app_user_email_verifications WHERE token_hash IN ( \
                 SELECT token_hash FROM app_user_email_verifications \
                 WHERE expires_at <= NOW() OR consumed_at IS NOT NULL \
                 LIMIT $1 \
                 FOR UPDATE SKIP LOCKED \
             )",
        )
        .bind(batch_size)
        .execute(&self.pool)
        .await?;
        Ok(res.rows_affected())
    }
}
