//! CRUD over `app_user_invitations` (v1.1.8 commit 7).
//!
//! Distinct shape from the verification / reset repos: pre-stages
//! email + display_name + roles for a user that doesn't exist yet.
//! The admin UI references rows by surrogate `id`; the consumer
//! references by `token_hash`. Both views are scoped by `app_id`.

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

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

#[derive(Debug, Clone)]
pub struct InvitationRow {
    pub id: InvitationId,
    pub app_id: AppId,
    pub email: String,
    pub display_name: Option<String>,
    pub roles: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub accepted_at: Option<DateTime<Utc>>,
}

/// Snapshot returned by `consume` — just the fields needed to create
/// the new user. Excludes timestamps and ids since the caller already
/// has the token hash and the consume happened atomically.
#[derive(Debug, Clone)]
pub struct ConsumedInvitation {
    pub email: String,
    pub display_name: Option<String>,
    pub roles: Vec<String>,
}

#[async_trait]
pub trait AppUserInvitationRepo: Send + Sync {
    async fn create(
        &self,
        app_id: AppId,
        email: &str,
        display_name: Option<&str>,
        roles: &[String],
        token_hash: &str,
        expires_at: DateTime<Utc>,
    ) -> Result<InvitationRow, AppUserInvitationRepoError>;

    /// Pending invitations (accepted_at IS NULL) for the admin UI.
    async fn list_pending(
        &self,
        app_id: AppId,
    ) -> Result<Vec<InvitationRow>, AppUserInvitationRepoError>;

    /// Admin revoke (deletes the pending row). Returns whether a row
    /// was actually removed.
    async fn revoke(
        &self,
        app_id: AppId,
        invite_id: InvitationId,
    ) -> Result<bool, AppUserInvitationRepoError>;

    /// Atomic one-shot consume by token. Returns the staged fields
    /// for the caller to create the user from; None on
    /// missing / already-accepted / expired / wrong app.
    async fn consume_by_token(
        &self,
        app_id: AppId,
        token_hash: &str,
    ) -> Result<Option<ConsumedInvitation>, AppUserInvitationRepoError>;

    /// GC: drop accepted or expired rows.
    async fn gc(&self, batch_size: i64) -> Result<u64, AppUserInvitationRepoError>;
}

pub struct PostgresAppUserInvitationRepo {
    pool: PgPool,
}

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

#[async_trait]
impl AppUserInvitationRepo for PostgresAppUserInvitationRepo {
    async fn create(
        &self,
        app_id: AppId,
        email: &str,
        display_name: Option<&str>,
        roles: &[String],
        token_hash: &str,
        expires_at: DateTime<Utc>,
    ) -> Result<InvitationRow, AppUserInvitationRepoError> {
        let row: InvitationRecord = sqlx::query_as(
            "INSERT INTO app_user_invitations \
                 (token_hash, app_id, email, display_name, roles, expires_at) \
             VALUES ($1, $2, $3, $4, $5, $6) \
             RETURNING id, app_id, email, display_name, roles, \
                       created_at, expires_at, accepted_at",
        )
        .bind(token_hash)
        .bind(app_id.into_inner())
        .bind(email)
        .bind(display_name)
        .bind(roles)
        .bind(expires_at)
        .fetch_one(&self.pool)
        .await?;
        Ok(row.into())
    }

    async fn list_pending(
        &self,
        app_id: AppId,
    ) -> Result<Vec<InvitationRow>, AppUserInvitationRepoError> {
        let rows: Vec<InvitationRecord> = sqlx::query_as(
            "SELECT id, app_id, email, display_name, roles, \
                    created_at, expires_at, accepted_at \
             FROM app_user_invitations \
             WHERE app_id = $1 AND accepted_at IS NULL \
             ORDER BY created_at DESC",
        )
        .bind(app_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(Into::into).collect())
    }

    async fn revoke(
        &self,
        app_id: AppId,
        invite_id: InvitationId,
    ) -> Result<bool, AppUserInvitationRepoError> {
        let res = sqlx::query(
            "DELETE FROM app_user_invitations \
             WHERE app_id = $1 AND id = $2 AND accepted_at IS NULL",
        )
        .bind(app_id.into_inner())
        .bind(invite_id.into_inner())
        .execute(&self.pool)
        .await?;
        Ok(res.rows_affected() > 0)
    }

    async fn consume_by_token(
        &self,
        app_id: AppId,
        token_hash: &str,
    ) -> Result<Option<ConsumedInvitation>, AppUserInvitationRepoError> {
        let row: Option<(String, Option<String>, Vec<String>)> = sqlx::query_as(
            "UPDATE app_user_invitations \
             SET accepted_at = NOW() \
             WHERE token_hash = $1 \
               AND app_id = $2 \
               AND accepted_at IS NULL \
               AND expires_at > NOW() \
             RETURNING email, display_name, roles",
        )
        .bind(token_hash)
        .bind(app_id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(|(email, display_name, roles)| ConsumedInvitation {
            email,
            display_name,
            roles,
        }))
    }

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

#[derive(sqlx::FromRow)]
struct InvitationRecord {
    id: uuid::Uuid,
    app_id: uuid::Uuid,
    email: String,
    display_name: Option<String>,
    roles: Vec<String>,
    created_at: DateTime<Utc>,
    expires_at: DateTime<Utc>,
    accepted_at: Option<DateTime<Utc>>,
}

impl From<InvitationRecord> for InvitationRow {
    fn from(r: InvitationRecord) -> Self {
        Self {
            id: r.id.into(),
            app_id: r.app_id.into(),
            email: r.email,
            display_name: r.display_name,
            roles: r.roles,
            created_at: r.created_at,
            expires_at: r.expires_at,
            accepted_at: r.accepted_at,
        }
    }
}
