//! Append-only record of privileged actions. See migration 029 for why it exists.
//!
//! Design constraints, both learned from the rest of this codebase:
//!
//!  * **Never fail the action.** An audit write that can turn a successful ban into a 500 makes
//!    moderation less reliable than no audit at all. Every failure here is logged and swallowed.
//!  * **Never store a credential.** `reset_pin` is the action most worth recording and the one
//!    whose payload must never be in `detail` — a table that could hand back a guest's PIN would
//!    be a worse privacy problem than the gap it closes.
//!
//! **Action slugs actually written**, since migration 029's header lists three (`promote_user`,
//! `demote_user`, `delete_user`) that no call site has ever emitted, and the migration file cannot
//! be corrected without changing its checksum and crash-looping every database that ran it:
//!
//! `ban_user`, `unban_user`, `set_role`, `reset_pin`, `delete_upload`, `delete_comment`,
//! `lock_uploads`, `unlock_uploads`, `release_gallery`, `delete_account`, `patch_config`.
//! Eleven, one per `audit::record` call site — grep for it if this list ages.
//!
//! **There is deliberately no read endpoint.** The table is queried by hand:
//!
//! ```sql
//! SELECT created_at, actor_name, actor_role, action, target_name, detail
//!   FROM host_action_audit ORDER BY created_at DESC LIMIT 50;
//! ```

use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;

use crate::models::user::UserRole;

/// Record one privileged action.
///
/// Takes `&PgPool` rather than a transaction on purpose: the audit row is not part of the action's
/// atomicity. If the action commits and the audit write fails we want the action to stand (and a
/// loud log line); if the action rolls back, an orphan audit row saying "someone tried" is more
/// useful than silence.
#[allow(clippy::too_many_arguments)]
pub async fn record(
    pool: &PgPool,
    event_id: Uuid,
    actor_id: Uuid,
    actor_name: Option<&str>,
    actor_role: UserRole,
    action: &str,
    target_id: Option<Uuid>,
    target_name: Option<&str>,
    detail: Option<Value>,
) {
    // Resolve whatever names the caller did not supply.
    //
    // Migration 029 made `actor_id`/`target_id` deliberately non-FK so "the record survives the
    // actor's account being removed, which is exactly when it is most likely to be wanted". Every
    // caller passed None for both names, so what survived was a bare uuid resolving to nothing —
    // the guarantee the column exists for, minus the only thing that made it readable.
    //
    // Resolved HERE rather than at eleven call sites so none can be missed. The one caller that
    // destroys the row it is recording — `me::delete_account` — must still pass the name in, since
    // by the time this runs there is nothing left to look up, and that is precisely the row a host
    // will be reading the next morning ("whose photos disappeared?").
    let (actor_name, target_name) =
        resolve_names(pool, actor_id, actor_name, target_id, target_name).await;

    let result = sqlx::query(
        "INSERT INTO host_action_audit
           (event_id, actor_id, actor_name, actor_role, action, target_id, target_name, detail)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
    )
    .bind(event_id)
    .bind(actor_id)
    .bind(actor_name.as_deref())
    // `as_str()`, not `format!("{actor_role:?}")`: the Debug spelling is not a stable wire format,
    // so a `#[derive(Debug)]` change or a renamed variant would silently start writing a different
    // string into a column nothing validates. `as_str` is the one the rest of the codebase uses.
    .bind(actor_role.as_str())
    .bind(action)
    .bind(target_id)
    .bind(target_name.as_deref())
    .bind(detail)
    .execute(pool)
    .await;

    match result {
        Ok(_) => {}
        Err(e) => {
            // `error`, not `warn`: losing an audit row is the kind of thing that should show up in
            // whatever is watching the logs, even though it must not fail the request.
            tracing::error!(
                error = ?e, action, %actor_id, ?target_id,
                "failed to write host action audit row"
            );
        }
    }
}

/// Fill in any name the caller left as `None`, in ONE query.
///
/// Best-effort by the same rule as the insert: a failed lookup writes NULL rather than failing the
/// action, and it is one round-trip whether zero, one or both names are missing.
async fn resolve_names(
    pool: &PgPool,
    actor_id: Uuid,
    actor_name: Option<&str>,
    target_id: Option<Uuid>,
    target_name: Option<&str>,
) -> (Option<String>, Option<String>) {
    let need_actor = actor_name.is_none();
    let need_target = target_name.is_none() && target_id.is_some();
    if !need_actor && !need_target {
        return (
            actor_name.map(str::to_owned),
            target_name.map(str::to_owned),
        );
    }

    let mut wanted: Vec<Uuid> = Vec::with_capacity(2);
    if need_actor {
        wanted.push(actor_id);
    }
    if let Some(t) = target_id
        && need_target
    {
        wanted.push(t);
    }

    let rows: Vec<(Uuid, String)> =
        sqlx::query_as("SELECT id, display_name FROM \"user\" WHERE id = ANY($1)")
            .bind(&wanted)
            .fetch_all(pool)
            .await
            .unwrap_or_default();

    let lookup = |id: Uuid| rows.iter().find(|(i, _)| *i == id).map(|(_, n)| n.clone());

    (
        actor_name.map(str::to_owned).or_else(|| lookup(actor_id)),
        target_name
            .map(str::to_owned)
            .or_else(|| target_id.and_then(lookup)),
    )
}
