//! CRUD over `app_user_roles` (v1.1.8 commit 8).
//!
//! String-tagged roles only. No registry, no hierarchy, no
//! permission matrix — the script app decides what the strings mean.
//! Per-app, per-user; the PK is `(app_id, user_id, role)` so the
//! `add` path is idempotent (`ON CONFLICT DO NOTHING`).

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

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

#[async_trait]
pub trait AppUserRoleRepo: Send + Sync {
    /// Idempotent add — duplicate role for the same user is a no-op.
    async fn add(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<(), AppUserRoleRepoError>;

    /// Returns whether the role was actually removed (false if it
    /// wasn't there).
    async fn remove(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, AppUserRoleRepoError>;

    async fn has(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, AppUserRoleRepoError>;

    /// All roles for a single user. The AppUser DTO carries this list.
    async fn list_for_user(
        &self,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<Vec<String>, AppUserRoleRepoError>;

    /// Batched variant of `list_for_user` — returns a map from user_id
    /// to that user's role list, in one round-trip. Used by paginated
    /// admin/script list endpoints to avoid an N+1.
    ///
    /// Empty `user_ids` returns an empty map (zero queries).
    async fn list_for_users(
        &self,
        app_id: AppId,
        user_ids: &[AppUserId],
    ) -> Result<std::collections::HashMap<AppUserId, Vec<String>>, AppUserRoleRepoError>;
}

pub struct PostgresAppUserRoleRepo {
    pool: PgPool,
}

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

#[async_trait]
impl AppUserRoleRepo for PostgresAppUserRoleRepo {
    async fn add(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<(), AppUserRoleRepoError> {
        sqlx::query(
            "INSERT INTO app_user_roles (app_id, user_id, role) VALUES ($1, $2, $3) \
             ON CONFLICT (app_id, user_id, role) DO NOTHING",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(role)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn remove(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, AppUserRoleRepoError> {
        let res = sqlx::query(
            "DELETE FROM app_user_roles WHERE app_id = $1 AND user_id = $2 AND role = $3",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(role)
        .execute(&self.pool)
        .await?;
        Ok(res.rows_affected() > 0)
    }

    async fn has(
        &self,
        app_id: AppId,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, AppUserRoleRepoError> {
        let row: Option<(i64,)> = sqlx::query_as(
            "SELECT 1::BIGINT FROM app_user_roles \
             WHERE app_id = $1 AND user_id = $2 AND role = $3",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(role)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.is_some())
    }

    async fn list_for_user(
        &self,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<Vec<String>, AppUserRoleRepoError> {
        let rows: Vec<(String,)> = sqlx::query_as(
            "SELECT role FROM app_user_roles WHERE app_id = $1 AND user_id = $2 ORDER BY role",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        Ok(rows.into_iter().map(|(s,)| s).collect())
    }

    async fn list_for_users(
        &self,
        app_id: AppId,
        user_ids: &[AppUserId],
    ) -> Result<std::collections::HashMap<AppUserId, Vec<String>>, AppUserRoleRepoError> {
        if user_ids.is_empty() {
            return Ok(std::collections::HashMap::new());
        }
        let ids: Vec<uuid::Uuid> = user_ids.iter().map(|u| u.into_inner()).collect();
        let rows: Vec<(uuid::Uuid, String)> = sqlx::query_as(
            "SELECT user_id, role FROM app_user_roles \
             WHERE app_id = $1 AND user_id = ANY($2) ORDER BY user_id, role",
        )
        .bind(app_id.into_inner())
        .bind(&ids)
        .fetch_all(&self.pool)
        .await?;
        let mut out: std::collections::HashMap<AppUserId, Vec<String>> =
            user_ids.iter().map(|id| (*id, Vec::new())).collect();
        for (uid, role) in rows {
            out.entry(AppUserId::from(uid)).or_default().push(role);
        }
        Ok(out)
    }
}
