//! CRUD over the `app_members` table — explicit per-(user, app) role
//! grants for `member` instance-role users. Owners and admins do NOT
//! appear here; their app authority is implicit (see authz.rs).
//!
//! Doubles as the production `AuthzRepo` implementation: the
//! membership lookup `can()` needs is the same single-row SELECT as
//! `find` here.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
use sqlx::PgPool;

use crate::authz::{AuthzError, AuthzRepo};

/// SQL fragment ranking the three role literals by authority so a CTE can
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
/// both effective-role queries.
const ROLE_RANK_CTE: &str =
    "role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";

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

    #[error("membership row not found: app={app_id}, user={user_id}")]
    NotFound { app_id: AppId, user_id: AdminUserId },

    #[error("invalid app_role stored in DB: {0}")]
    InvalidRole(String),
}

/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
/// so handlers can render the cross-reference without joining to apps
/// or admin_users themselves.
#[derive(Debug, Clone)]
pub struct AppMembershipRow {
    pub app_id: AppId,
    pub user_id: AdminUserId,
    pub role: AppRole,
    pub created_at: DateTime<Utc>,
}

/// `app_members` row joined with `admin_users` so the dashboard's
/// Members tab can render usernames / emails / status without an N+1
/// fetch per row. Drives `GET /apps/{id}/members`.
#[derive(Debug, Clone)]
pub struct AppMembershipDetail {
    pub user_id: AdminUserId,
    pub username: String,
    pub email: Option<String>,
    pub instance_role: InstanceRole,
    pub is_active: bool,
    pub role: AppRole,
    pub created_at: DateTime<Utc>,
}

#[async_trait]
pub trait AppMembersRepository: Send + Sync {
    /// Single (user, app) lookup. Returns `None` for non-members and
    /// for unrelated apps. This is the hot path for `authz::can`.
    async fn find(
        &self,
        user_id: AdminUserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AppMembersRepositoryError>;

    /// Upsert a membership. Used both for first-time grants and role
    /// promotions/demotions on an existing row.
    async fn upsert(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<AppMembershipRow, AppMembersRepositoryError>;

    /// Atomic insert. Returns `Some(row)` on success, `None` if a
    /// membership already exists. Lets the HTTP handler return 409
    /// without a separate `find` round-trip (no TOCTOU between check
    /// and insert).
    async fn try_insert(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;

    /// Atomic role update. Returns `Some(row)` on success, `None` if no
    /// membership row exists. Lets PATCH return 404 without a separate
    /// `find` round-trip (no TOCTOU between check and update).
    async fn update_role(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;

    /// Remove a membership. No-op (Ok) when the row doesn't exist —
    /// the user wasn't a member, which is the desired post-condition.
    async fn remove(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
    ) -> Result<(), AppMembersRepositoryError>;

    /// Every membership the user holds. Drives the membership-filtered
    /// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
    /// `member` callers).
    async fn list_for_user(
        &self,
        user_id: AdminUserId,
    ) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;

    /// Every membership on a given app. Used by `GET
    /// /admin/apps/{id}/members` once that surface lands; included now
    /// so the trait is complete enough for tests.
    async fn list_for_app(
        &self,
        app_id: AppId,
    ) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;

    /// Like `list_for_app` but joined with `admin_users` so the
    /// dashboard can render member rows in one round-trip. Ordered by
    /// username for a stable list.
    async fn list_for_app_enriched(
        &self,
        app_id: AppId,
    ) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
}

pub struct PostgresAppMembersRepository {
    pool: PgPool,
}

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

#[async_trait]
impl AppMembersRepository for PostgresAppMembersRepository {
    async fn find(
        &self,
        user_id: AdminUserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AppMembersRepositoryError> {
        let row: Option<(String,)> =
            sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
                .bind(user_id.into_inner())
                .bind(app_id.into_inner())
                .fetch_optional(&self.pool)
                .await?;
        row.map(|(role,)| {
            AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
        })
        .transpose()
    }

    async fn upsert(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<AppMembershipRow, AppMembersRepositoryError> {
        let row = sqlx::query_as::<_, AppMembershipRecord>(
            "INSERT INTO app_members (app_id, user_id, role) \
             VALUES ($1, $2, $3) \
             ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
             RETURNING app_id, user_id, role, created_at",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(role.as_str())
        .fetch_one(&self.pool)
        .await?;
        row.try_into()
    }

    async fn remove(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
    ) -> Result<(), AppMembersRepositoryError> {
        sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
            .bind(app_id.into_inner())
            .bind(user_id.into_inner())
            .execute(&self.pool)
            .await?;
        Ok(())
    }

    async fn try_insert(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
        let row = sqlx::query_as::<_, AppMembershipRecord>(
            "INSERT INTO app_members (app_id, user_id, role) \
             VALUES ($1, $2, $3) \
             ON CONFLICT (app_id, user_id) DO NOTHING \
             RETURNING app_id, user_id, role, created_at",
        )
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .bind(role.as_str())
        .fetch_optional(&self.pool)
        .await?;
        row.map(TryInto::try_into).transpose()
    }

    async fn update_role(
        &self,
        app_id: AppId,
        user_id: AdminUserId,
        role: AppRole,
    ) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
        let row = sqlx::query_as::<_, AppMembershipRecord>(
            "UPDATE app_members SET role = $1 \
             WHERE app_id = $2 AND user_id = $3 \
             RETURNING app_id, user_id, role, created_at",
        )
        .bind(role.as_str())
        .bind(app_id.into_inner())
        .bind(user_id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        row.map(TryInto::try_into).transpose()
    }

    async fn list_for_user(
        &self,
        user_id: AdminUserId,
    ) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
        let rows = sqlx::query_as::<_, AppMembershipRecord>(
            "SELECT app_id, user_id, role, created_at \
             FROM app_members WHERE user_id = $1 \
             ORDER BY created_at",
        )
        .bind(user_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        rows.into_iter().map(TryInto::try_into).collect()
    }

    async fn list_for_app(
        &self,
        app_id: AppId,
    ) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
        let rows = sqlx::query_as::<_, AppMembershipRecord>(
            "SELECT app_id, user_id, role, created_at \
             FROM app_members WHERE app_id = $1 \
             ORDER BY created_at",
        )
        .bind(app_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        rows.into_iter().map(TryInto::try_into).collect()
    }

    async fn list_for_app_enriched(
        &self,
        app_id: AppId,
    ) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
        let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
            "SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
                    am.role, am.created_at \
             FROM app_members am \
             JOIN admin_users au ON au.id = am.user_id \
             WHERE am.app_id = $1 \
             ORDER BY au.username",
        )
        .bind(app_id.into_inner())
        .fetch_all(&self.pool)
        .await?;
        rows.into_iter().map(TryInto::try_into).collect()
    }
}

/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
/// it to `authz::can` without casting.
#[async_trait]
impl AuthzRepo for PostgresAppMembersRepository {
    async fn membership(
        &self,
        user_id: UserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AuthzError> {
        self.find(user_id, app_id)
            .await
            .map_err(|e| AuthzError::Repo(e.to_string()))
    }

    /// Highest effective role on `app_id`: one recursive CTE walks the
    /// app's group chain (app.group_id → groups.parent_id → … → root,
    /// depth-bounded), then `MAX`es the app's own `app_members` row with
    /// every ancestor `group_members` row by authority rank. A single
    /// round-trip regardless of tree depth. `None` = no grant anywhere.
    async fn effective_app_role(
        &self,
        user_id: UserId,
        app_id: AppId,
    ) -> Result<Option<AppRole>, AuthzError> {
        let row: Option<(String,)> = sqlx::query_as(&format!(
            "WITH RECURSIVE ancestors AS (
                 SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
               UNION ALL
                 SELECT g.parent_id, anc.depth + 1
                 FROM groups g JOIN ancestors anc ON g.id = anc.gid
                 WHERE g.parent_id IS NOT NULL AND anc.depth < 64
             ),
             {ROLE_RANK_CTE}
             SELECT eff.role
             FROM (
                 SELECT am.role FROM app_members am
                 WHERE am.user_id = $1 AND am.app_id = $2
               UNION ALL
                 SELECT gm.role FROM group_members gm
                 JOIN ancestors anc ON anc.gid = gm.group_id
                 WHERE gm.user_id = $1
             ) eff
             JOIN role_rank rr ON rr.role = eff.role
             ORDER BY rr.rank DESC
             LIMIT 1"
        ))
        .bind(user_id.into_inner())
        .bind(app_id.into_inner())
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| AuthzError::Repo(e.to_string()))?;
        row.map(|(role,)| {
            AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
                "invalid role {role:?} in members table"
            )))
        })
        .transpose()
    }

    /// Highest effective role on a *group* node — walks the group's own
    /// ancestor chain over `group_members`. Gates group management.
    async fn effective_group_role(
        &self,
        user_id: UserId,
        group_id: GroupId,
    ) -> Result<Option<AppRole>, AuthzError> {
        let row: Option<(String,)> = sqlx::query_as(&format!(
            "WITH RECURSIVE ancestors AS (
                 SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
               UNION ALL
                 SELECT g.id, g.parent_id, anc.depth + 1
                 FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
                 WHERE anc.depth < 64
             ),
             {ROLE_RANK_CTE}
             SELECT gm.role
             FROM group_members gm
             JOIN ancestors anc ON anc.gid = gm.group_id
             JOIN role_rank rr ON rr.role = gm.role
             WHERE gm.user_id = $1
             ORDER BY rr.rank DESC
             LIMIT 1"
        ))
        .bind(user_id.into_inner())
        .bind(group_id.into_inner())
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| AuthzError::Repo(e.to_string()))?;
        row.map(|(role,)| {
            AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
                "invalid role {role:?} in group_members table"
            )))
        })
        .transpose()
    }
}

#[derive(sqlx::FromRow)]
struct AppMembershipRecord {
    app_id: uuid::Uuid,
    user_id: uuid::Uuid,
    role: String,
    created_at: DateTime<Utc>,
}

impl TryFrom<AppMembershipRecord> for AppMembershipRow {
    type Error = AppMembersRepositoryError;
    fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
        Ok(Self {
            app_id: r.app_id.into(),
            user_id: r.user_id.into(),
            role: AppRole::from_db_str(&r.role)
                .ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
            created_at: r.created_at,
        })
    }
}

#[derive(sqlx::FromRow)]
struct AppMembershipDetailRecord {
    id: uuid::Uuid,
    username: String,
    email: Option<String>,
    instance_role: String,
    is_active: bool,
    role: String,
    created_at: DateTime<Utc>,
}

impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
    type Error = AppMembersRepositoryError;
    fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
        Ok(Self {
            user_id: r.id.into(),
            username: r.username,
            email: r.email,
            instance_role: InstanceRole::from_db_str(&r.instance_role)
                .ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
            is_active: r.is_active,
            role: AppRole::from_db_str(&r.role)
                .ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
            created_at: r.created_at,
        })
    }
}
