//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
//! these aren't about acquiring / refreshing a session — they're about reading my own
//! state once I'm already signed in.
//!
//! Current routes:
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
//!   account page loads this once on mount instead of issuing several round trips.
//! - `GET /api/v1/me/quota`  — live per-user storage quota estimate.

use axum::Json;
use axum::extract::State;
use serde::Serialize;

use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::state::AppState;

#[derive(Serialize)]
pub struct QuotaDto {
    pub enabled: bool,
    pub used_bytes: i64,
    pub limit_bytes: Option<i64>,
    pub active_uploaders: i64,
    pub free_disk_bytes: i64,
}

pub async fn get_quota(
    State(state): State<AppState>,
    auth: AuthUser,
) -> Result<Json<QuotaDto>, AppError> {
    let user = User::find_by_id(&state.pool, auth.user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;

    let estimate = compute_storage_quota(&state).await;

    // Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
    // must never reach a guest, even though the guest upload UI no longer renders it.
    // A guest still gets their own `used`/`limit` so enforcement stays transparent to
    // the code paths that consume it; only the server-wide fields are zeroed.
    let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);

    Ok(Json(QuotaDto {
        enabled: estimate.limit_bytes.is_some(),
        used_bytes: user.total_upload_bytes,
        limit_bytes: estimate.limit_bytes,
        active_uploaders: if is_staff {
            estimate.active_uploaders
        } else {
            0
        },
        free_disk_bytes: if is_staff {
            estimate.free_disk_bytes
        } else {
            0
        },
    }))
}

#[derive(Serialize)]
pub struct MeContextDto {
    pub user_id: uuid::Uuid,
    pub display_name: String,
    pub role: String,
    /// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
    pub privacy_note: String,
    pub quota_enabled: bool,
    pub storage_quota_enabled: bool,
    /// Uploads are locked (event closed) — the composer should show a locked state live
    /// instead of letting a guest compose an upload only to eat a 403.
    pub uploads_locked: bool,
    /// The gallery has been released and the export snapshotted — uploads are permanently
    /// closed for this run (release ⇒ lock, and reopening regenerates).
    pub gallery_released: bool,
    /// This guest is banned: a deliberately READ-ONLY ban (see `handlers/host.rs`) — they keep
    /// the feed and the keepsake, but every write is refused.
    ///
    /// Exposed so the UI can SAY so. Without it the client had no idea, so the upload button,
    /// the like button and "Löschen" all rendered enabled and returned 403 "Du bist gesperrt."
    /// on every tap — a guest tapping upload repeatedly with nobody to ask. The lock case
    /// (`uploads_locked`) has always been surfaced for exactly this reason; a ban was not.
    pub is_banned: bool,
}

pub async fn get_context(
    State(state): State<AppState>,
    auth: AuthUser,
) -> Result<Json<MeContextDto>, AppError> {
    let user = User::find_by_id(&state.pool, auth.user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;

    let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
    let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
    let storage_quota_enabled =
        config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;

    let event =
        crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
    let uploads_locked = event
        .as_ref()
        .map(|e| e.uploads_locked_at.is_some())
        .unwrap_or(false);
    let gallery_released = event
        .as_ref()
        .map(|e| e.export_released_at.is_some())
        .unwrap_or(false);

    Ok(Json(MeContextDto {
        user_id: user.id,
        display_name: user.display_name,
        role: user.role.as_str().to_string(),
        privacy_note,
        quota_enabled,
        storage_quota_enabled,
        uploads_locked,
        gallery_released,
        is_banned: user.is_banned,
    }))
}

/// `(original_path, preview_path, thumbnail_path, display_path)` for one upload.
type UploadFilePaths = (String, Option<String>, Option<String>, Option<String>);

/// Delete the caller's own account and everything attached to it.
///
/// The erasure path (H18). There was no user-deletion route at ANY role, so honouring a "please
/// remove my photos and my name" request meant hand-written SQL against production — during or
/// after a wedding, by whoever happened to have psql access. Deletion also never removed text:
/// captions, comment bodies and hashtag links survived indefinitely by design, so even the
/// existing per-photo delete left the guest's words in the database and in the keepsake.
///
/// Self-service on purpose. The alternative (host-initiated only) puts a guest's erasure request
/// through a third party who is at a party, and the join page's data notice now promises this.
///
/// ORDER MATTERS. `upload.user_id` and `comment.user_id` are plain FKs with NO `ON DELETE CASCADE`
/// (migration 002), so deleting the user first fails on a constraint violation. Children first,
/// then the row itself — at which point `session`, `like` and `pin_reset_request` do cascade.
pub async fn delete_account(
    State(state): State<AppState>,
    auth: AuthUser,
) -> Result<axum::http::StatusCode, AppError> {
    // The last host/admin may not erase themselves: it would leave the event with no operator and
    // no way to appoint one. Mirrors the floor `set_role` and `ban_user` already enforce.
    let user = User::find_by_id(&state.pool, auth.user_id)
        .await?
        .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
    if matches!(user.role, UserRole::Host | UserRole::Admin) {
        let others = sqlx::query_scalar::<_, i64>(
            "SELECT COUNT(*) FROM \"user\"
              WHERE event_id = $1 AND id != $2
                AND role IN ('host', 'admin') AND is_banned = FALSE",
        )
        .bind(auth.event_id)
        .bind(auth.user_id)
        .fetch_one(&state.pool)
        .await?;
        if others == 0 {
            return Err(AppError::BadRequest(
                "Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
                 dein Konto löschst."
                    .into(),
            ));
        }
    }

    // Collect the file paths BEFORE the rows go, or they are unrecoverable. Every derivative, not
    // just the original: a preview left behind is still the guest's photo.
    let files: Vec<UploadFilePaths> = sqlx::query_as(
        "SELECT original_path, preview_path, thumbnail_path, display_path
           FROM upload WHERE user_id = $1",
    )
    .bind(auth.user_id)
    .fetch_all(&state.pool)
    .await?;

    let mut tx = state.pool.begin().await?;
    // Comments the guest wrote on OTHER people's photos. Hard delete, not `deleted_at`: this is
    // erasure, and a soft delete leaves the body in the table and in the keepsake's data.json.
    sqlx::query("DELETE FROM comment WHERE user_id = $1")
        .bind(auth.user_id)
        .execute(&mut *tx)
        .await?;
    // Their uploads. Cascades comments and likes ON those uploads, plus upload_hashtag links.
    sqlx::query("DELETE FROM upload WHERE user_id = $1")
        .bind(auth.user_id)
        .execute(&mut *tx)
        .await?;
    // Invalidate the keepsake inside the same transaction — an already-released archive still
    // contains this guest's photos and captions, and erasure that leaves them in the downloadable
    // ZIP has not happened. Returns None when the event isn't released, in which case there is
    // nothing to rebuild.
    let regen = crate::services::export::invalidate_and_arm(
        &mut tx,
        &state.config.event_slug,
        crate::services::export::Affects::Both,
    )
    .await?;
    // The last-host guard again, now AUTHORITATIVELY — inside the transaction, holding a lock.
    //
    // The check above runs on the pool before this transaction opens, so two hosts deleting
    // themselves at the same moment each saw the other and both proceeded, leaving the event with
    // NO operator: nobody to moderate, nobody to release the gallery, and no way to appoint anyone
    // because appointing requires a host. That is not recoverable from inside the app.
    //
    // Serialised with a transaction-scoped ADVISORY lock, not a row lock.
    //
    // `FOR UPDATE` on the other operators' rows looks like the obvious answer and is the wrong one:
    // each deleter would lock the OTHER's row and then try to delete its own, so the two block on
    // each other and Postgres resolves it by killing one with a deadlock error — the invariant
    // holds, but the loser gets a 500 instead of the sentence below. Locking the `event` row
    // instead would serialise cleanly, but it inverts the lock order every moderation path uses
    // (upload/user rows first, event last), which is an ABBA waiting to happen.
    //
    // An advisory lock has neither problem: it is a separate lock space, so it cannot interact with
    // the row-lock graph at all, and it is released automatically when this transaction ends.
    // 4242 is an arbitrary namespace to keep this key from colliding with any future advisory use.
    if matches!(user.role, UserRole::Host | UserRole::Admin) {
        sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
            .bind(auth.event_id)
            .execute(&mut *tx)
            .await?;
        let others: Vec<uuid::Uuid> = sqlx::query_scalar(
            "SELECT id FROM \"user\"
              WHERE event_id = $1 AND id != $2
                AND role IN ('host', 'admin') AND is_banned = FALSE",
        )
        .bind(auth.event_id)
        .bind(auth.user_id)
        .fetch_all(&mut *tx)
        .await?;
        if others.is_empty() {
            return Err(AppError::BadRequest(
                "Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
                 dein Konto löschst."
                    .into(),
            ));
        }
    }

    // And the account. `session`, `like` and `pin_reset_request` cascade from here.
    sqlx::query("DELETE FROM \"user\" WHERE id = $1")
        .bind(auth.user_id)
        .execute(&mut *tx)
        .await?;
    tx.commit().await?;

    // IMMEDIATELY after the commit, before any other `.await`. Every other `invalidate_and_arm`
    // call site does this; this one used to spawn the workers *after* the file-removal loop below,
    // and axum drops a handler future the moment the client disconnects. Drop it inside that loop
    // and the keepsake is left with the epoch bumped, both `export_job` rows armed `pending` at
    // that epoch, and NO WORKER: `/export/zip` and `/export/html` 404, the UI sits on
    // "Wird vorbereitet…" forever, and `recover_exports` only runs at boot. Deleting your account
    // from a phone that walks out of wifi range is enough to do it.
    if let Some(r) = regen {
        crate::handlers::host::start_regen(&state, r);
    }

    // Best effort, after the commit. Anything missed here is an orphan with no row pointing at it,
    // which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation
    // rather than leaving the file referenced.
    for (original, preview, thumbnail, display) in &files {
        for rel in [
            Some(original),
            preview.as_ref(),
            thumbnail.as_ref(),
            display.as_ref(),
        ]
        .into_iter()
        .flatten()
        {
            let abs = state.config.media_path.join(rel);
            if let Err(e) = tokio::fs::remove_file(&abs).await
                && e.kind() != std::io::ErrorKind::NotFound
            {
                tracing::warn!(error = ?e, path = %abs.display(), "account deletion: could not remove media file");
            }
        }
    }

    // Evict their content from every open feed and the projector. `user-hidden` is exactly the
    // right signal — it already means "this user's cards must go" — and reusing it means every
    // client already handles this with no new event type.
    let _ = state.sse_tx.send(crate::state::SseEvent::new(
        "user-hidden",
        serde_json::json!({ "user_id": auth.user_id }).to_string(),
    ));

    // Audited like the host actions it resembles, with the actor and target being the same person.
    //
    // The names are passed EXPLICITLY here, unlike every other call site. `audit::record` resolves
    // a missing name by looking the id up in `"user"` — and this handler has just hard-deleted that
    // row, so the lookup would find nothing and write the NULL that makes the record unreadable.
    // This is the row most likely to be read later ("whose photos disappeared?"), and migration 029
    // made these columns non-FK precisely so it would survive the deletion.
    crate::services::audit::record(
        &state.pool,
        auth.event_id,
        auth.user_id,
        Some(&user.display_name),
        user.role.clone(),
        "delete_account",
        Some(auth.user_id),
        Some(&user.display_name),
        Some(serde_json::json!({ "uploads_removed": files.len() })),
    )
    .await;

    tracing::info!(user_id = %auth.user_id, uploads = files.len(), "account deleted by its owner");
    Ok(axum::http::StatusCode::NO_CONTENT)
}
