//! User persistence.

use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;

use crate::domain::User;
use crate::error::{AppError, AppResult};

/// `(total, admins)` user counts for the admin overview dashboard.
pub async fn counts(pool: &PgPool) -> AppResult<(i64, i64)> {
    let row: (i64, i64) = sqlx::query_as(
        "SELECT COUNT(*)::bigint, COUNT(*) FILTER (WHERE is_admin)::bigint FROM users",
    )
    .fetch_one(pool)
    .await?;
    Ok(row)
}

/// Most recently created user (username, created_at), if any exist.
pub async fn newest(pool: &PgPool) -> AppResult<Option<(String, DateTime<Utc>)>> {
    let row: Option<(String, DateTime<Utc>)> =
        sqlx::query_as("SELECT username, created_at FROM users ORDER BY created_at DESC LIMIT 1")
            .fetch_optional(pool)
            .await?;
    Ok(row)
}

pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppResult<User> {
    let result = sqlx::query_as::<_, User>(
        r#"
        INSERT INTO users (username, password_hash)
        VALUES ($1, $2)
        RETURNING id, username, password_hash, created_at, is_admin
        "#,
    )
    .bind(username)
    .bind(password_hash)
    .fetch_one(pool)
    .await;

    match result {
        Ok(user) => Ok(user),
        Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
            Err(AppError::Conflict("username is already taken".into()))
        }
        Err(e) => Err(AppError::Database(e)),
    }
}

/// Case-insensitive lookup so login with "Alice" matches a user
/// registered as "alice" (the unique index on `lower(username)` keeps
/// the comparison cheap). Equivalent in spirit to ILIKE but uses the
/// functional index directly.
pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult<Option<User>> {
    let row = sqlx::query_as::<_, User>(
        r#"
        SELECT id, username, password_hash, created_at, is_admin
        FROM users
        WHERE lower(username) = lower($1)
        "#,
    )
    .bind(username)
    .fetch_optional(pool)
    .await?;
    Ok(row)
}

pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
    let row = sqlx::query_as::<_, User>(
        r#"SELECT id, username, password_hash, created_at, is_admin FROM users WHERE id = $1"#,
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;
    Ok(row)
}

/// Postgres advisory-lock key guarding admin-count-changing operations
/// (demote, delete-admin). Without this lock two concurrent demotes of
/// different admins could each pass their "more than one admin remains"
/// check, then commit, leaving zero admins. The lock serialises any tx
/// that might change the admin count so the recount under the lock is
/// authoritative.
///
/// Value is the bytes of "admininv" interpreted as a big-endian i64.
/// Postgres' advisory-lock keyspace is global; collision risk with
/// `CRON_LOCK_KEY` and friends is ~2^-64.
pub const ADMIN_INVARIANT_LOCK_KEY: i64 = 0x61_64_6d_69_6e_69_6e_76;

#[derive(Debug, Default)]
pub struct ListUsersQuery {
    pub search: Option<String>,
    pub limit: i64,
    pub offset: i64,
}

/// Paginated user list with total count. `search` is a case-insensitive
/// substring match on `username`. Order is alphabetical by username so
/// pagination is stable across concurrent writes (mangas changing
/// is_admin doesn't reshuffle the page).
pub async fn list_with_total(
    pool: &PgPool,
    q: &ListUsersQuery,
) -> AppResult<(Vec<User>, i64)> {
    let pat = q
        .search
        .as_ref()
        .map(|s| format!("%{}%", s.trim()))
        .filter(|p| p.len() > 2);

    let items = sqlx::query_as::<_, User>(
        r#"
        SELECT id, username, password_hash, created_at, is_admin
          FROM users
         WHERE ($1::text IS NULL OR username ILIKE $1)
         ORDER BY username
         LIMIT $2 OFFSET $3
        "#,
    )
    .bind(&pat)
    .bind(q.limit)
    .bind(q.offset)
    .fetch_all(pool)
    .await?;

    let total: i64 = sqlx::query_scalar(
        "SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)",
    )
    .bind(&pat)
    .fetch_one(pool)
    .await?;

    Ok((items, total))
}

/// Raw `is_admin` update with no safety checks, no audit log, and no
/// advisory lock. Exists only as a test setup helper for the admin-
/// feature integration suite — production code MUST go through
/// [`admin_safe_set_is_admin`], which enforces self-protection, the
/// last-admin invariant, and the audit log atomically.
pub async fn set_is_admin_unchecked(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> {
    sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2")
        .bind(value)
        .bind(id)
        .execute(pool)
        .await?;
    Ok(())
}

/// Ensure the user `username` exists and is an admin. Called at startup
/// from `app::build` when `ADMIN_USERNAME` / `ADMIN_PASSWORD` are set.
///
/// Semantics — see cross-cutting decision #2 in the feature plan:
/// - If no row exists: create with the env-supplied password hashed via
///   argon2id and `is_admin = true`.
/// - If a row already exists: flip `is_admin` to true if needed; **never**
///   touch the existing `password_hash`. Lets the operator rotate the
///   admin password through the UI without env-var conflict.
///
/// Wrapped in a transaction so a concurrent `register` for the same
/// username can't slip an INSERT between the SELECT and UPDATE/INSERT.
///
/// Set `is_admin` on a user with full safety checks: rejects self-demote,
/// rejects demoting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY`
/// to close the parallel-demote race), and writes an `admin_audit` row
/// in the same tx so the log mirrors what actually committed.
///
/// Returns the freshly-written user row (so the handler can return it
/// without a second SELECT).
pub async fn admin_safe_set_is_admin(
    pool: &PgPool,
    actor_id: Uuid,
    target_id: Uuid,
    value: bool,
) -> AppResult<User> {
    // Cheap pre-check before opening a tx — also covers the "demote me"
    // case which would otherwise pass the recount when other admins exist.
    if actor_id == target_id && !value {
        return Err(AppError::Conflict(
            "cannot demote yourself; ask another admin".into(),
        ));
    }

    let mut tx = pool.begin().await?;
    sqlx::query("SELECT pg_advisory_xact_lock($1)")
        .bind(ADMIN_INVARIANT_LOCK_KEY)
        .execute(&mut *tx)
        .await?;

    let target: Option<User> = sqlx::query_as(
        "SELECT id, username, password_hash, created_at, is_admin \
           FROM users WHERE id = $1 FOR UPDATE",
    )
    .bind(target_id)
    .fetch_optional(&mut *tx)
    .await?;
    let Some(target) = target else {
        return Err(AppError::NotFound);
    };

    // No-op: caller asked to set `is_admin` to its current value. Return
    // the row as-is without writing an audit entry — otherwise repeated
    // PATCH calls (browser retry, double-click) pile misleading
    // "promote_user" rows in `admin_audit` for actions that changed
    // nothing.
    if target.is_admin == value {
        tx.commit().await?;
        return Ok(target);
    }

    // Recount inside the lock — this is the authoritative read.
    if target.is_admin && !value {
        let admin_count: i64 =
            sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true")
                .fetch_one(&mut *tx)
                .await?;
        if admin_count <= 1 {
            return Err(AppError::Conflict(
                "cannot demote the last admin; promote another user first".into(),
            ));
        }
    }

    let updated: User = sqlx::query_as(
        "UPDATE users SET is_admin = $1 WHERE id = $2 \
         RETURNING id, username, password_hash, created_at, is_admin",
    )
    .bind(value)
    .bind(target_id)
    .fetch_one(&mut *tx)
    .await?;

    let action = if value { "promote_user" } else { "demote_user" };
    crate::repo::admin_audit::insert(
        &mut *tx,
        actor_id,
        action,
        "user",
        Some(target_id),
        serde_json::json!({ "username": target.username }),
    )
    .await?;

    tx.commit().await?;
    Ok(updated)
}

/// Delete a user with full safety checks: rejects self-delete, rejects
/// deleting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY`),
/// and writes an `admin_audit` row in the same tx. Captures the deleted
/// username + admin status in the audit payload so the action is
/// readable after the user row itself is gone.
pub async fn admin_safe_delete(
    pool: &PgPool,
    actor_id: Uuid,
    target_id: Uuid,
) -> AppResult<()> {
    if actor_id == target_id {
        return Err(AppError::Conflict(
            "cannot delete yourself; ask another admin".into(),
        ));
    }

    let mut tx = pool.begin().await?;
    sqlx::query("SELECT pg_advisory_xact_lock($1)")
        .bind(ADMIN_INVARIANT_LOCK_KEY)
        .execute(&mut *tx)
        .await?;

    let target: Option<User> = sqlx::query_as(
        "SELECT id, username, password_hash, created_at, is_admin \
           FROM users WHERE id = $1 FOR UPDATE",
    )
    .bind(target_id)
    .fetch_optional(&mut *tx)
    .await?;
    let Some(target) = target else {
        return Err(AppError::NotFound);
    };

    if target.is_admin {
        let admin_count: i64 =
            sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true")
                .fetch_one(&mut *tx)
                .await?;
        if admin_count <= 1 {
            return Err(AppError::Conflict(
                "cannot delete the last admin; promote another user first".into(),
            ));
        }
    }

    sqlx::query("DELETE FROM users WHERE id = $1")
        .bind(target_id)
        .execute(&mut *tx)
        .await?;

    crate::repo::admin_audit::insert(
        &mut *tx,
        actor_id,
        "delete_user",
        "user",
        Some(target_id),
        serde_json::json!({
            "username": target.username,
            "was_admin": target.is_admin,
        }),
    )
    .await?;

    tx.commit().await?;
    Ok(())
}

/// Admin-initiated user creation. Wraps the INSERT + audit row in a
/// single transaction so a rolled-back create never leaves an orphan
/// audit entry. Caller (HTTP handler) is responsible for validating
/// `username`/`password` and hashing — this fn assumes both are
/// already vetted by the same `validate_*` rules used by self-
/// registration.
pub async fn admin_create_user(
    pool: &PgPool,
    actor_id: Uuid,
    username: &str,
    password_hash: &str,
    is_admin: bool,
) -> AppResult<User> {
    let mut tx = pool.begin().await?;
    let user: User = match sqlx::query_as::<_, User>(
        "INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) \
         RETURNING id, username, password_hash, created_at, is_admin",
    )
    .bind(username)
    .bind(password_hash)
    .bind(is_admin)
    .fetch_one(&mut *tx)
    .await
    {
        Ok(u) => u,
        Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
            return Err(AppError::Conflict("username is already taken".into()));
        }
        Err(e) => return Err(AppError::Database(e)),
    };

    crate::repo::admin_audit::insert(
        &mut *tx,
        actor_id,
        "create_user",
        "user",
        Some(user.id),
        serde_json::json!({
            "username": user.username,
            "is_admin": user.is_admin,
        }),
    )
    .await?;

    tx.commit().await?;
    Ok(user)
}

pub async fn bootstrap_admin(
    pool: &PgPool,
    username: &str,
    password: &str,
) -> AppResult<()> {
    let mut tx = pool.begin().await?;
    let existing: Option<(Uuid,)> = sqlx::query_as(
        "SELECT id FROM users WHERE lower(username) = lower($1) FOR UPDATE",
    )
    .bind(username)
    .fetch_optional(&mut *tx)
    .await?;
    match existing {
        Some((id,)) => {
            sqlx::query("UPDATE users SET is_admin = true WHERE id = $1 AND is_admin = false")
                .bind(id)
                .execute(&mut *tx)
                .await?;
        }
        None => {
            let hash = crate::auth::password::hash_password_async(password.to_string()).await?;
            sqlx::query("INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, true)")
                .bind(username)
                .bind(&hash)
                .execute(&mut *tx)
                .await?;
        }
    }
    tx.commit().await?;
    Ok(())
}
