//! CRUD over the `app_user_sessions` table (v1.1.8 data-plane sessions).
//!
//! Mirrors the shape of `admin_session_repo` (token hash as PK, sliding
//! window, prune on expiry) with three additions:
//!
//!   * `app_id` is part of every row so cross-app isolation is bright
//!     at the SQL layer. Lookups by token hash still join on app_id —
//!     a token issued for app A cannot be used to authorize a request
//!     into app B even if the hash collides (which it won't, but the
//!     defense-in-depth is cheap).
//!   * `absolute_expires_at` is the hard cap. The repo writes whatever
//!     `new_expires_at` it's handed on touch; the service computes the
//!     min of (now + ttl, absolute_expires_at) so a session that's
//!     been bumped a hundred times still dies at the absolute cap.
//!   * `revoked_at` is explicit revocation. Lookups reject revoked
//!     rows immediately so a "revoke all sessions" admin action takes
//!     effect before the next GC sweep runs.
//!
//! The token never appears in this module — only the SHA-256 hash.

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

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

/// Result of a session lookup. The service uses `expires_at` /
/// `absolute_expires_at` to decide whether the bump is allowed.
#[derive(Debug, Clone)]
pub struct AppUserSessionLookup {
    pub app_id: AppId,
    pub user_id: AppUserId,
    pub expires_at: DateTime<Utc>,
    pub absolute_expires_at: DateTime<Utc>,
}

#[async_trait]
pub trait AppUserSessionRepository: Send + Sync {
    async fn create(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        token_hash: &str,
        expires_at: DateTime<Utc>,
        absolute_expires_at: DateTime<Utc>,
    ) -> Result<(), AppUserSessionRepositoryError>;
    /// Look up a non-revoked session whose sliding window has not yet
    /// expired. Returns `None` for missing, revoked, or expired rows.
    /// The absolute cap is enforced by the caller (the value is returned
    /// here so the service can compute the next expires_at).
    async fn lookup(
        &self,
        token_hash: &str,
    ) -> Result<Option<AppUserSessionLookup>, AppUserSessionRepositoryError>;
    /// Sliding-window bump. Service supplies new_expires_at clamped at
    /// absolute_expires_at.
    async fn touch(
        &self,
        token_hash: &str,
        new_expires_at: DateTime<Utc>,
    ) -> Result<(), AppUserSessionRepositoryError>;
    /// Explicit revocation by token (logout).
    async fn revoke(&self, token_hash: &str) -> Result<(), AppUserSessionRepositoryError>;
    /// Revoke every active session for a user — password reset,
    /// admin "revoke all sessions" button. Returns rows affected so
    /// the caller can log or surface the count.
    async fn revoke_for_user(
        &self,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<u64, AppUserSessionRepositoryError>;
    /// GC sweep. Deletes rows that are either expired (sliding window
    /// or absolute cap passed) or explicitly revoked. Same hygiene as
    /// `admin_session_repo::prune_expired` plus the revocation path.
    async fn gc(&self, batch_size: i64) -> Result<u64, AppUserSessionRepositoryError>;
}

pub struct PostgresAppUserSessionRepository {
    pool: PgPool,
}

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

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

    async fn lookup(
        &self,
        token_hash: &str,
    ) -> Result<Option<AppUserSessionLookup>, AppUserSessionRepositoryError> {
        let row: Option<(uuid::Uuid, uuid::Uuid, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
            "SELECT app_id, user_id, expires_at, absolute_expires_at \
             FROM app_user_sessions \
             WHERE token_hash = $1 \
               AND revoked_at IS NULL \
               AND expires_at > NOW() \
               AND absolute_expires_at > NOW()",
        )
        .bind(token_hash)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(|(app, user, exp, abs)| AppUserSessionLookup {
            app_id: app.into(),
            user_id: user.into(),
            expires_at: exp,
            absolute_expires_at: abs,
        }))
    }

    async fn touch(
        &self,
        token_hash: &str,
        new_expires_at: DateTime<Utc>,
    ) -> Result<(), AppUserSessionRepositoryError> {
        sqlx::query(
            "UPDATE app_user_sessions \
             SET last_used_at = NOW(), expires_at = $2 \
             WHERE token_hash = $1 AND revoked_at IS NULL",
        )
        .bind(token_hash)
        .bind(new_expires_at)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn revoke(&self, token_hash: &str) -> Result<(), AppUserSessionRepositoryError> {
        sqlx::query(
            "UPDATE app_user_sessions SET revoked_at = NOW() \
             WHERE token_hash = $1 AND revoked_at IS NULL",
        )
        .bind(token_hash)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn revoke_for_user(
        &self,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<u64, AppUserSessionRepositoryError> {
        let res = sqlx::query(
            "UPDATE app_user_sessions SET revoked_at = NOW() \
             WHERE app_id = $1 AND user_id = $2 AND revoked_at IS NULL",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .execute(&self.pool)
        .await?;
        Ok(res.rows_affected())
    }

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