//! CRUD over the `app_users` table (v1.1.8 data-plane user management).
//!
//! Distinct from `admin_user_repo.rs`: that's the control-plane
//! operator table (you / me / the dashboard login). This is the
//! script-end-user surface — created by app scripts via `users::*`,
//! managed by the dashboard's per-app Users tab.
//!
//! Identity tuple is `(app_id, id)`; uniqueness is enforced on
//! `(app_id, lower(email))` so the same email can exist in different
//! apps but not twice in the same app. The repo always filters by
//! `app_id` — there is no "get any user" query. Cross-app reads must
//! pass two `app_id` values explicitly, which keeps the v1.1.3-style
//! cross-app discipline obvious at the call site.
//!
//! Password hashes go in and come out as opaque Argon2id PHC strings —
//! the repo never computes or inspects them; that's `auth.rs`'s job.

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

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

    #[error("not found: {0}")]
    NotFound(AppUserId),

    #[error("email already in use: {0}")]
    DuplicateEmail(String),
}

/// Row returned to handlers and the service layer. Never includes the
/// password hash — that ships out of [`AppUserCredentials`] on the
/// dedicated login lookup, mirroring how `admin_user_repo` splits its
/// public row from its credential row.
#[derive(Debug, Clone)]
pub struct AppUserRow {
    pub id: AppUserId,
    pub app_id: AppId,
    pub email: String,
    pub display_name: Option<String>,
    pub email_verified_at: Option<DateTime<Utc>>,
    pub last_login_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Credentials fetched for the login path only. Splitting the hash off
/// from the public row makes it obvious in service code which calls
/// touch a secret.
#[derive(Debug, Clone)]
pub struct AppUserCredentials {
    pub id: AppUserId,
    pub app_id: AppId,
    pub email: String,
    pub password_hash: String,
}

#[async_trait]
pub trait AppUserRepository: Send + Sync {
    async fn get(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
    /// Case-insensitive email lookup, scoped to a single app.
    async fn find_by_email(
        &self,
        app_id: AppId,
        email: &str,
    ) -> Result<Option<AppUserRow>, AppUserRepositoryError>;
    /// Credentials lookup for the login path. Case-insensitive on email.
    /// Returns `None` for missing — callers run a timing-flat dummy
    /// verify on miss to avoid leaking which path was taken.
    async fn get_credentials_by_email(
        &self,
        app_id: AppId,
        email: &str,
    ) -> Result<Option<AppUserCredentials>, AppUserRepositoryError>;
    async fn list(
        &self,
        app_id: AppId,
        opts: ListOpts,
    ) -> Result<ListPage<AppUserRow>, AppUserRepositoryError>;
    async fn create(
        &self,
        app_id: AppId,
        email: &str,
        password_hash: &str,
        display_name: Option<&str>,
    ) -> Result<AppUserRow, AppUserRepositoryError>;
    async fn update_display_name(
        &self,
        app_id: AppId,
        id: AppUserId,
        display_name: Option<&str>,
    ) -> Result<AppUserRow, AppUserRepositoryError>;
    async fn update_password_hash(
        &self,
        app_id: AppId,
        id: AppUserId,
        password_hash: &str,
    ) -> Result<AppUserRow, AppUserRepositoryError>;
    async fn mark_email_verified(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<AppUserRow, AppUserRepositoryError>;
    async fn touch_last_login(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<(), AppUserRepositoryError>;
    async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError>;
}

/// F-P-012: cursor carries `(created_at, id)` for keyset pagination.
/// Without the `id` tiebreaker, two users created at the same instant
/// could be skipped or duplicated at a page boundary.
#[derive(Debug, Clone, Copy)]
pub struct ListCursor {
    pub created_at: DateTime<Utc>,
    pub id: picloud_shared::AppUserId,
}

impl ListCursor {
    /// Encode as `<rfc3339>_<uuid>` — what we surface to script-land
    /// via `users::list`'s `next_cursor` field.
    #[must_use]
    pub fn encode(&self) -> String {
        format!("{}_{}", self.created_at.to_rfc3339(), self.id.into_inner())
    }

    /// Decode the wire format. Returns `None` on any parse error — the
    /// caller treats that as "start of page" rather than 400'ing on a
    /// stale cursor from a previous schema.
    #[must_use]
    pub fn decode(s: &str) -> Option<Self> {
        let (ts, id) = s.rsplit_once('_')?;
        let created_at = DateTime::parse_from_rfc3339(ts).ok()?.to_utc();
        let id = uuid::Uuid::parse_str(id).ok()?;
        Some(Self {
            created_at,
            id: picloud_shared::AppUserId::from(id),
        })
    }
}

#[derive(Debug, Clone, Default)]
pub struct ListOpts {
    pub cursor: Option<ListCursor>,
    pub limit: i64,
}

#[derive(Debug, Clone)]
pub struct ListPage<T> {
    pub items: Vec<T>,
    pub next_cursor: Option<ListCursor>,
}

pub struct PostgresAppUserRepository {
    pool: PgPool,
}

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

#[async_trait]
impl AppUserRepository for PostgresAppUserRepository {
    async fn get(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserRecord>(
            "SELECT id, app_id, email, display_name, email_verified_at, \
                    last_login_at, created_at, updated_at \
             FROM app_users WHERE app_id = $1 AND id = $2",
        )
        .bind(app_id.into_inner())
        .bind(id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn find_by_email(
        &self,
        app_id: AppId,
        email: &str,
    ) -> Result<Option<AppUserRow>, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserRecord>(
            "SELECT id, app_id, email, display_name, email_verified_at, \
                    last_login_at, created_at, updated_at \
             FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
        )
        .bind(app_id.into_inner())
        .bind(email)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn get_credentials_by_email(
        &self,
        app_id: AppId,
        email: &str,
    ) -> Result<Option<AppUserCredentials>, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserCredsRecord>(
            "SELECT id, app_id, email, password_hash \
             FROM app_users WHERE app_id = $1 AND lower(email) = lower($2)",
        )
        .bind(app_id.into_inner())
        .bind(email)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(Into::into))
    }

    async fn list(
        &self,
        app_id: AppId,
        opts: ListOpts,
    ) -> Result<ListPage<AppUserRow>, AppUserRepositoryError> {
        let limit = opts.limit.clamp(1, 500);
        // Fetch one extra to detect whether more pages exist.
        let rows = if let Some(cursor) = opts.cursor {
            sqlx::query_as::<_, AppUserRecord>(
                "SELECT id, app_id, email, display_name, email_verified_at, \
                        last_login_at, created_at, updated_at \
                 FROM app_users WHERE app_id = $1 \
                   AND (created_at, id) < ($2, $3) \
                 ORDER BY created_at DESC, id DESC LIMIT $4",
            )
            .bind(app_id.into_inner())
            .bind(cursor.created_at)
            .bind(cursor.id.into_inner())
            .bind(limit + 1)
            .fetch_all(&self.pool)
            .await?
        } else {
            sqlx::query_as::<_, AppUserRecord>(
                "SELECT id, app_id, email, display_name, email_verified_at, \
                        last_login_at, created_at, updated_at \
                 FROM app_users WHERE app_id = $1 \
                 ORDER BY created_at DESC, id DESC LIMIT $2",
            )
            .bind(app_id.into_inner())
            .bind(limit + 1)
            .fetch_all(&self.pool)
            .await?
        };

        let mut items: Vec<AppUserRow> = rows.into_iter().map(Into::into).collect();
        let next_cursor = if i64::try_from(items.len()).unwrap_or(i64::MAX) > limit {
            let cursor_row = items.pop().expect("len > limit so there is a row");
            Some(ListCursor {
                created_at: cursor_row.created_at,
                id: cursor_row.id,
            })
        } else {
            None
        };
        Ok(ListPage { items, next_cursor })
    }

    async fn create(
        &self,
        app_id: AppId,
        email: &str,
        password_hash: &str,
        display_name: Option<&str>,
    ) -> Result<AppUserRow, AppUserRepositoryError> {
        let res = sqlx::query_as::<_, AppUserRecord>(
            "INSERT INTO app_users (app_id, email, password_hash, display_name) \
             VALUES ($1, $2, $3, $4) \
             RETURNING id, app_id, email, display_name, email_verified_at, \
                       last_login_at, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(email)
        .bind(password_hash)
        .bind(display_name)
        .fetch_one(&self.pool)
        .await;

        match res {
            Ok(row) => Ok(row.into()),
            Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
                Err(AppUserRepositoryError::DuplicateEmail(email.to_string()))
            }
            Err(e) => Err(e.into()),
        }
    }

    async fn update_display_name(
        &self,
        app_id: AppId,
        id: AppUserId,
        display_name: Option<&str>,
    ) -> Result<AppUserRow, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserRecord>(
            "UPDATE app_users SET display_name = $3, updated_at = NOW() \
             WHERE app_id = $1 AND id = $2 \
             RETURNING id, app_id, email, display_name, email_verified_at, \
                       last_login_at, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(id.into_inner())
        .bind(display_name)
        .fetch_optional(&self.pool)
        .await?;
        row.map(Into::into)
            .ok_or(AppUserRepositoryError::NotFound(id))
    }

    async fn update_password_hash(
        &self,
        app_id: AppId,
        id: AppUserId,
        password_hash: &str,
    ) -> Result<AppUserRow, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserRecord>(
            "UPDATE app_users SET password_hash = $3, updated_at = NOW() \
             WHERE app_id = $1 AND id = $2 \
             RETURNING id, app_id, email, display_name, email_verified_at, \
                       last_login_at, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(id.into_inner())
        .bind(password_hash)
        .fetch_optional(&self.pool)
        .await?;
        row.map(Into::into)
            .ok_or(AppUserRepositoryError::NotFound(id))
    }

    async fn mark_email_verified(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<AppUserRow, AppUserRepositoryError> {
        let row = sqlx::query_as::<_, AppUserRecord>(
            "UPDATE app_users SET email_verified_at = NOW(), updated_at = NOW() \
             WHERE app_id = $1 AND id = $2 \
             RETURNING id, app_id, email, display_name, email_verified_at, \
                       last_login_at, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(id.into_inner())
        .fetch_optional(&self.pool)
        .await?;
        row.map(Into::into)
            .ok_or(AppUserRepositoryError::NotFound(id))
    }

    async fn touch_last_login(
        &self,
        app_id: AppId,
        id: AppUserId,
    ) -> Result<(), AppUserRepositoryError> {
        sqlx::query("UPDATE app_users SET last_login_at = NOW() WHERE app_id = $1 AND id = $2")
            .bind(app_id.into_inner())
            .bind(id.into_inner())
            .execute(&self.pool)
            .await?;
        Ok(())
    }

    async fn delete(&self, app_id: AppId, id: AppUserId) -> Result<bool, AppUserRepositoryError> {
        let res = sqlx::query("DELETE FROM app_users WHERE app_id = $1 AND id = $2")
            .bind(app_id.into_inner())
            .bind(id.into_inner())
            .execute(&self.pool)
            .await?;
        Ok(res.rows_affected() > 0)
    }
}

#[derive(sqlx::FromRow)]
struct AppUserRecord {
    id: uuid::Uuid,
    app_id: uuid::Uuid,
    email: String,
    display_name: Option<String>,
    email_verified_at: Option<DateTime<Utc>>,
    last_login_at: Option<DateTime<Utc>>,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

impl From<AppUserRecord> for AppUserRow {
    fn from(r: AppUserRecord) -> Self {
        Self {
            id: r.id.into(),
            app_id: r.app_id.into(),
            email: r.email,
            display_name: r.display_name,
            email_verified_at: r.email_verified_at,
            last_login_at: r.last_login_at,
            created_at: r.created_at,
            updated_at: r.updated_at,
        }
    }
}

#[derive(sqlx::FromRow)]
struct AppUserCredsRecord {
    id: uuid::Uuid,
    app_id: uuid::Uuid,
    email: String,
    password_hash: String,
}

impl From<AppUserCredsRecord> for AppUserCredentials {
    fn from(r: AppUserCredsRecord) -> Self {
        Self {
            id: r.id.into(),
            app_id: r.app_id.into(),
            email: r.email,
            password_hash: r.password_hash,
        }
    }
}
