use std::collections::HashMap;
use std::time::Duration;

use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::services::config;
use crate::services::sse_tickets::TicketKind;
use crate::state::AppState;

// ── DTOs ─────────────────────────────────────────────────────────────────────

#[derive(Serialize)]
pub struct StatsDto {
    pub user_count: i64,
    pub upload_count: i64,
    pub comment_count: i64,
    pub disk_total_bytes: u64,
    pub disk_used_bytes: u64,
    pub disk_free_bytes: u64,
}

#[derive(Serialize, sqlx::FromRow)]
pub struct ExportJobDto {
    pub id: uuid::Uuid,
    pub r#type: String,
    pub status: String,
    pub progress_pct: i16,
    pub error_message: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}

// ── Handlers ─────────────────────────────────────────────────────────────────

pub async fn get_stats(
    State(state): State<AppState>,
    RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<StatsDto>, AppError> {
    let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
        .await?
        .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;

    let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
        .bind(event.id)
        .fetch_one(&state.pool)
        .await?;

    let (upload_count,): (i64,) =
        sqlx::query_as("SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL")
            .bind(event.id)
            .fetch_one(&state.pool)
            .await?;

    let (comment_count,): (i64,) = sqlx::query_as(
        "SELECT COUNT(*) FROM comment c
         JOIN upload u ON u.id = c.upload_id
         WHERE u.event_id = $1 AND c.deleted_at IS NULL",
    )
    .bind(event.id)
    .fetch_one(&state.pool)
    .await?;

    // Disk usage from the shared cache (unknown mount → zeros, same as before).
    let (disk_total, disk_free) = state
        .disk_cache
        .snapshot(&state.config.media_path)
        .map(|d| (d.total, d.free))
        .unwrap_or((0, 0));

    let disk_used = disk_total.saturating_sub(disk_free);

    Ok(Json(StatsDto {
        user_count,
        upload_count,
        comment_count,
        disk_total_bytes: disk_total,
        disk_used_bytes: disk_used,
        disk_free_bytes: disk_free,
    }))
}

pub async fn get_config(
    State(state): State<AppState>,
    RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> {
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM config ORDER BY key")
        .fetch_all(&state.pool)
        .await?;

    Ok(Json(rows.into_iter().collect()))
}

/// Documents the wire shape of `PATCH /admin/config` (a flat `{key: value}` object).
/// `patch_config` extracts the `HashMap` directly rather than going through this newtype, so it is
/// never constructed in Rust — it stays as the serde-derived description of the request body.
#[allow(dead_code)]
#[derive(Deserialize)]
pub struct PatchConfigRequest(pub HashMap<String, String>);

pub async fn patch_config(
    State(state): State<AppState>,
    RequireAdmin(auth): RequireAdmin,
    Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
    // Numeric keys validated as f64; boolean keys validated as truthy strings; the
    // privacy note is free text. Splitting these explicitly is verbose but makes the
    // failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
    // (key, integer_only, min, max). Ranges reject values that `parse::<f64>` would
    // accept but that silently revert to the hardcoded default at read time
    // (get_usize/get_i64 can't parse negatives/NaN/fractionals). `compression_concurrency`
    // is intentionally absent — it's read once at boot, so a live edit was a no-op.
    const NUMERIC_SPECS: &[(&str, bool, f64, f64)] = &[
        ("max_image_size_mb", true, 1.0, 1024.0),
        ("max_video_size_mb", true, 1.0, 10240.0),
        ("upload_rate_per_hour", true, 1.0, 100_000.0),
        ("feed_rate_per_min", true, 1.0, 100_000.0),
        ("export_rate_per_day", true, 1.0, 100_000.0),
        // Loose per-IP ceiling on /join. The real anti-spam bucket is per (ip, name); this
        // only bounds raw volume from one source, so it must stay well above the size of a
        // party arriving at once (see migration 017).
        ("join_ip_rate_per_min", true, 1.0, 100_000.0),
        // Same shape for /recover: the per-(ip, name) bucket is the anti-guessing control,
        // this only bounds a name-cycling flood in front of a cost-12 bcrypt (migration 019).
        ("recover_ip_rate_per_min", true, 1.0, 100_000.0),
        // Aggregate ceiling on likes + comments + comment deletions, per user per minute.
        // These were the only mutating endpoints with no limit at all (migration 020).
        ("social_rate_per_min", true, 1.0, 100_000.0),
        ("quota_tolerance", false, 0.0, 1.0),
        ("estimated_guest_count", true, 1.0, 1_000_000.0),
        // The three limiters migration 025 introduced. All are READ at runtime
        // (`upload.rs` for the edit limiter, `auth/handlers.rs` for the other two) and 025
        // INSERTs all of them into `config`, so `GET /admin/config` listed them while
        // `PATCH /admin/config` answered "Unbekannter Konfigurationsschlüssel" — the same
        // dead-key defect the comment under BOOL_KEYS says was fixed for the two login
        // toggles. These are precisely the knobs an operator reaches for while abuse is
        // happening, which is the one moment a restart to change them is unaffordable.
        ("upload_edit_rate_per_min", true, 1.0, 100_000.0),
        ("recover_name_rate_per_15min", true, 1.0, 100_000.0),
        ("pin_reset_ip_rate_per_min", true, 1.0, 100_000.0),
    ];
    const BOOL_KEYS: &[&str] = &[
        "rate_limits_enabled",
        "upload_rate_enabled",
        "feed_rate_enabled",
        "export_rate_enabled",
        "join_rate_enabled",
        // These two per-area rate toggles are HONOURED by their handlers (auth/handlers.rs reads
        // `admin_login_rate_enabled` and `recover_rate_enabled`, both defaulting true) but were
        // missing from this allowlist — so the switch existed in code and could never be flipped.
        "admin_login_rate_enabled",
        "recover_rate_enabled",
        "social_rate_enabled",
        // Read by `upload::edit_upload`, inserted by migration 025, and until now unreachable
        // from this endpoint — see the note in NUMERIC_SPECS.
        "upload_edit_rate_enabled",
        "quota_enabled",
        "storage_quota_enabled",
        "upload_count_quota_enabled",
    ];
    const TEXT_KEYS: &[&str] = &[
        "privacy_note",
        "theme_preset",
        "theme_primary",
        "theme_accent",
    ];
    const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
    // Preset ids the frontend knows how to render (mirror of PRESETS in
    // frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent
    // seeds verbatim". Kept in sync by hand — a new preset must be added in both places.
    const THEME_PRESETS: &[&str] = &[
        "champagne-gold",
        "rose",
        "sage",
        "dusk-blue",
        "classic-silver",
        "custom",
    ];

    let mut privacy_note_changed = false;
    let mut theme_changed = false;

    // Validate every key first so a bad value in the batch can't leave a partial
    // update behind — validation must fully precede any write.
    for (key, value) in &body {
        let key_str = key.as_str();
        if let Some(&(_, integer_only, min, max)) =
            NUMERIC_SPECS.iter().find(|(k, ..)| *k == key_str)
        {
            let n = value.trim().parse::<f64>().ok().filter(|n| n.is_finite());
            let n = match n {
                Some(n) => n,
                None => {
                    return Err(AppError::BadRequest(format!(
                        "Ungültiger Wert für {key}: muss eine Zahl sein."
                    )));
                }
            };
            if integer_only && n.fract() != 0.0 {
                return Err(AppError::BadRequest(format!(
                    "Ungültiger Wert für {key}: muss eine ganze Zahl sein."
                )));
            }
            if n < min || n > max {
                return Err(AppError::BadRequest(format!(
                    "Wert für {key} liegt außerhalb des zulässigen Bereichs ({min}–{max})."
                )));
            }
            // Zero is in range and catastrophic. `quota_tolerance` is the multiplier in
            // `free_disk * tolerance / active_uploaders`, so 0 makes every per-user limit 0 and
            // refuses EVERY upload — mid-event, with "Du hast dein Upload-Limit für dieses Event
            // erreicht", an error naming the wrong cause entirely. `storage_quota_enabled` is the
            // intended off-switch.
            //
            // Rejecting the value rather than raising the floor: very small tolerances are
            // legitimate (they are how a large disk is throttled down to a sensible per-guest
            // ceiling, and how the e2e quota tests steer it — around 1e-5 on a 174 GB volume), so
            // a floor of, say, 0.01 would forbid real configurations to prevent one typo.
            if key_str == "quota_tolerance" && n == 0.0 {
                return Err(AppError::BadRequest(
                    "quota_tolerance = 0 würde jeden Upload blockieren. Zum Abschalten der \
                     Speicher-Quote stattdessen „Speicher-Quote aktiv“ ausschalten."
                        .into(),
                ));
            }
        } else if BOOL_KEYS.contains(&key_str) {
            match value.trim().to_ascii_lowercase().as_str() {
                "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
                _ => {
                    return Err(AppError::BadRequest(format!(
                        "Ungültiger Wert für {key}: muss true oder false sein."
                    )));
                }
            }
        } else if TEXT_KEYS.contains(&key_str) {
            // Count characters, not bytes — the message says "Zeichen" and a
            // multi-byte grapheme shouldn't count against the limit multiple times.
            if value.chars().count() > PRIVACY_NOTE_MAX_LEN {
                return Err(AppError::BadRequest(format!(
                    "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
                )));
            }
            match key_str {
                "privacy_note" => privacy_note_changed = true,
                "theme_preset" => {
                    if !THEME_PRESETS.contains(&value.trim()) {
                        return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}.")));
                    }
                    theme_changed = true;
                }
                "theme_primary" | "theme_accent" => {
                    if !is_hex_color(value.trim()) {
                        return Err(AppError::BadRequest(format!(
                            "Ungültige Farbe für {key}: muss #rrggbb sein."
                        )));
                    }
                    theme_changed = true;
                }
                _ => {}
            }
        } else {
            return Err(AppError::BadRequest(format!(
                "Unbekannter Konfigurationsschlüssel: {key}"
            )));
        }
    }

    // Apply all writes in one transaction — the batch is all-or-nothing.
    let mut tx = state.pool.begin().await?;
    for (key, value) in &body {
        sqlx::query(
            "INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
             ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
        )
        .bind(key)
        .bind(value)
        .execute(&mut *tx)
        .await?;
    }
    tx.commit().await?;

    // The config cache must reflect this write on the very next read (tests PATCH then
    // immediately assert the new value takes effect). Invalidate synchronously here —
    // the TTL is only a backstop and must not be relied on for correctness.
    state.config_cache.invalidate();

    // Config changes were logged NOWHERE. They are the actions most likely to be blamed the
    // morning after ("why did uploads stop?") and the hardest to reconstruct, because the value
    // that caused the problem has since been changed back. Record the keys and their new values;
    // these are operational settings, not credentials, so the payload is safe to keep.
    crate::services::audit::record(
        &state.pool,
        auth.event_id,
        auth.user_id,
        None,
        auth.role.clone(),
        "patch_config",
        None,
        None,
        serde_json::to_value(&body).ok(),
    )
    .await;

    // Notify all clients that a publicly-readable config value changed so their stores
    // (e.g. the privacy note in My Account) refresh without a manual reload.
    if privacy_note_changed || theme_changed {
        let mut keys: Vec<&str> = Vec::new();
        if privacy_note_changed {
            keys.push("privacy_note");
        }
        if theme_changed {
            keys.push("theme");
        }
        let _ = state.sse_tx.send(crate::state::SseEvent::new(
            "event-updated",
            serde_json::json!({ "keys": keys }).to_string(),
        ));
    }

    Ok(StatusCode::NO_CONTENT)
}

/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not
/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS.
fn is_hex_color(s: &str) -> bool {
    let bytes = s.as_bytes();
    bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit())
}

pub async fn get_export_jobs(
    State(state): State<AppState>,
    RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<Vec<ExportJobDto>>, AppError> {
    let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
        .await?
        .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;

    let jobs = sqlx::query_as::<_, ExportJobDto>(
        "SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at
         FROM export_job
         WHERE event_id = $1
         ORDER BY created_at DESC",
    )
    .bind(event.id)
    .fetch_all(&state.pool)
    .await?;

    Ok(Json(jobs))
}

// ── Export download endpoints (authenticated guests) ─────────────────────────

#[derive(Deserialize)]
pub struct DownloadQuery {
    pub ticket: String,
}

/// Mint a short-lived ticket for a browser-driven export download. The download
/// is a top-level navigation so the multi-GB ZIP streams straight to disk instead
/// of being buffered in memory by `fetch()` + `blob()` — but a navigation can't
/// carry an `Authorization` header, so the client exchanges its Bearer token for
/// an opaque ticket here, then hits `/export/zip?ticket=...`. Reuses the same
/// single-use, 30s-TTL store as the SSE stream.
#[derive(serde::Deserialize)]
pub struct ExportTicketQuery {
    /// Which archive the ticket is for — `zip` or `html`.
    ///
    /// REQUIRED. It used to be optional "so an older client keeps working", but the ticket is now
    /// bound to the archive it was minted for (see `TicketKind::Download`), and a ticket with no
    /// archive would either have to be valid for both — the abuse this closes — or be issued for a
    /// guess that 401s at the other endpoint. Every shipped client sends it.
    #[serde(default)]
    pub kind: Option<String>,
}

pub async fn export_ticket(
    State(state): State<AppState>,
    axum::extract::Query(q): axum::extract::Query<ExportTicketQuery>,
    auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
    // NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
    // by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
    // released — Spec design choice"). The export is read-only, so it stays available
    // to them, consistent with the read-only-ban model.

    // The rate limit is enforced HERE rather than on the download itself, and that placement is
    // the whole point: the download is an iframe navigation, so its response is invisible to the
    // page. Limiting it there meant a guest over the limit tapped "Herunterladen", the ticket
    // POST returned 200, the iframe silently received a 429, and absolutely nothing happened —
    // forever, with no explanation, on the one screen that is the emotional payoff of the app.
    // Minting is a normal `fetch`, so a 429 here reaches the user as a German message.
    //
    // Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be
    // obtained from this authenticated endpoint, so one mint is at most one download.
    // Confirm the archive actually EXISTS before spending anything on it.
    //
    // `export_status` — which is what enables the Download button — reports `done` from
    // `export_job`, while the download resolves through `export_current.file_path` plus a
    // `Path::exists()`. Those are different sources of truth and can legitimately disagree: a
    // row can say done while the file is gone, or an epoch bump can retire it between the page
    // rendering and the guest tapping. When they disagreed the guest got the worst possible
    // shape of failure — a green "Download gestartet" toast, a consumed single-use ticket, one
    // of only three daily slots spent, and nothing in their Downloads folder, repeatable until
    // the day's allowance was gone.
    //
    // Checking here, before `enforce_export_rate`, turns that into an honest error on a plain
    // `fetch` that the existing `toastError` path already renders. This is NOT the HEAD probe
    // ruled out elsewhere: it reads the same indexed row the download will read and touches no
    // ticket, so it cannot consume anything.
    let export_kind = match q.kind.as_deref() {
        Some("zip") => crate::services::sse_tickets::ExportKind::Zip,
        Some("html") => crate::services::sse_tickets::ExportKind::Html,
        Some(other) => {
            return Err(AppError::BadRequest(format!(
                "Unbekannter Export-Typ: {other}"
            )));
        }
        None => {
            return Err(AppError::BadRequest(
                "Es fehlt die Angabe, welches Archiv geladen werden soll. Bitte lade die Seite \
                 neu und versuche es erneut."
                    .into(),
            ));
        }
    };
    {
        let export_type = match export_kind {
            crate::services::sse_tickets::ExportKind::Zip => "zip",
            crate::services::sse_tickets::ExportKind::Html => "html",
        };
        let msg = if export_type == "zip" {
            "Der ZIP-Export ist noch nicht verfügbar."
        } else {
            "Der HTML-Export ist noch nicht verfügbar."
        };
        resolve_export_file(&state, export_type, msg).await?;
    }

    // `issue` returns None when the ticket store is at capacity. Unwrapping it into the JSON body
    // serialized `{"ticket": null}` with a 200 — so `api.post` resolved happily, the page toasted
    // success, and the iframe navigated to `?ticket=null`. 503 + Retry-After, matching how
    // `sse::issue_ticket` answers the identical condition.
    //
    // Minted BEFORE the rate limit is charged. Charging first meant a store-capacity 503 — a
    // server-side condition the guest did nothing to cause and cannot see — still cost one of
    // their three DAILY downloads. There is no refund path, so the only fix is not to charge until
    // the thing being charged for actually exists.
    let ticket = state
        .sse_tickets
        .issue(auth.token_hash, TicketKind::Download(export_kind))
        .ok_or_else(|| {
            AppError::ServiceUnavailable(
                "Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
                Some(30),
            )
        })?;

    enforce_export_rate(&state, auth.user_id).await?;

    Ok(Json(serde_json::json!({ "ticket": ticket })))
}

/// Validate a download ticket (single-use) and confirm its session still exists.
/// Resolve a single-use download ticket to the user who minted it. The caller needs the
/// id to key the export rate limit per-user (see `enforce_export_rate`).
async fn authenticate_download_ticket(
    state: &AppState,
    ticket: &str,
    want: crate::services::sse_tickets::ExportKind,
) -> Result<Uuid, AppError> {
    // Non-consuming: a keepsake download must survive being resumed with `Range`, and a
    // single-use ticket meant the resume 401'd and cost the guest another of their three daily
    // downloads. `redeem_download` bounds it by DOWNLOAD_TTL instead, and the session check
    // below still runs on every request.
    let token_hash = state
        .sse_tickets
        .redeem_download(ticket, want)
        .ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
    let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
        .await
        .map_err(|e| AppError::Internal(e.into()))?
        .ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
    Ok(session.user_id)
}

pub async fn download_zip(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
    // Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
    // to the page. Charging it again here would cost every download two slots.
    authenticate_download_ticket(
        &state,
        &q.ticket,
        crate::services::sse_tickets::ExportKind::Zip,
    )
    .await?;

    let path =
        resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
    serve_file(
        path,
        "Gallery.zip",
        "application/zip",
        headers
            .get(axum::http::header::RANGE)
            .and_then(|v| v.to_str().ok()),
        headers
            .get(axum::http::header::IF_RANGE)
            .and_then(|v| v.to_str().ok()),
    )
    .await
}

/// Resolve the on-disk path of the CURRENT export generation — readiness check and path lookup in
/// ONE read, through the `export_current` view (migration 014).
///
/// This used to be two statements: the caller checked `event.export_zip_ready`, then this fetched
/// `export_job.file_path`. A reopen landing between them served a keepsake that should have 404'd —
/// the same stale-keepsake class, leaking through the read path, and it existed only because
/// readiness was a stored copy rather than a derivation. `export_current` gives both facts from one
/// consistent snapshot: it yields a row only when the event is released AND the job is `done` at the
/// event's current epoch, so "is it ready" and "which file" can no longer disagree.
async fn resolve_export_file(
    state: &AppState,
    export_type: &str,
    not_ready_msg: &str,
) -> Result<std::path::PathBuf, AppError> {
    let file_path: Option<(Option<String>,)> = sqlx::query_as(
        "SELECT c.file_path FROM export_current c
         JOIN event e ON e.id = c.event_id
         WHERE e.slug = $1 AND c.type = $2::export_type AND c.status = 'done'",
    )
    .bind(&state.config.event_slug)
    .bind(export_type)
    .fetch_optional(&state.pool)
    .await
    .map_err(|e| AppError::Internal(e.into()))?;

    let Some((Some(rel),)) = file_path else {
        return Err(AppError::NotFound(not_ready_msg.into()));
    };

    // `file_path` is stored as `exports/<name>`; the base dir is already `export_path`, so
    // join only the file name (defends against any absolute/`..` content too).
    let name = std::path::Path::new(&rel)
        .file_name()
        .ok_or_else(|| AppError::NotFound("Exportdatei nicht gefunden.".into()))?;
    let path = state.config.export_path.join(name);
    if !path.exists() {
        return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
    }
    Ok(path)
}

pub async fn download_html(
    State(state): State<AppState>,
    headers: axum::http::HeaderMap,
    Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
    // See `download_zip`: the limit is charged at ticket mint, where the client can see it.
    authenticate_download_ticket(
        &state,
        &q.ticket,
        crate::services::sse_tickets::ExportKind::Html,
    )
    .await?;

    let path =
        resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
    serve_file(
        path,
        "Memories.zip",
        "application/zip",
        headers
            .get(axum::http::header::RANGE)
            .and_then(|v| v.to_str().ok()),
        headers
            .get(axum::http::header::IF_RANGE)
            .and_then(|v| v.to_str().ok()),
    )
    .await
}

/// Stream a keepsake archive, honouring `Range`.
///
/// Range support is not a nicety here. The keepsake is the emotional payoff of the product and can
/// be ~1.4 GB; without `Accept-Ranges` a download that dies at 90% over hotel wifi restarts at byte
/// zero. Worse, the 3/day limit is charged when the download TICKET is minted and ZIP+HTML already
/// costs 2 — so one dropped connection locked a guest out of their own wedding photos for ~24h.
///
/// Reuses `upload::parse_range`, which already implements exactly the forms a client sends and is
/// unit-tested there. The media routes have always done this correctly; this route was the outlier.
async fn serve_file(
    path: std::path::PathBuf,
    filename: &str,
    content_type: &str,
    range_header: Option<&str>,
    if_range_header: Option<&str>,
) -> Result<axum::response::Response, AppError> {
    use crate::handlers::upload::{RangeSpec, parse_range};
    use axum::body::Body;
    use axum::http::{Response, StatusCode, header};
    use tokio::io::{AsyncReadExt, AsyncSeekExt};
    use tokio_util::io::ReaderStream;

    let mut file = tokio::fs::File::open(&path)
        .await
        .map_err(|e| AppError::Internal(e.into()))?;
    let len = file
        .metadata()
        .await
        .map_err(|e| AppError::Internal(e.into()))?
        .len();

    let disposition = format!("attachment; filename=\"{filename}\"");

    // A validator that CHANGES when the archive does, so a resume cannot splice two generations.
    //
    // The on-disk name is `{prefix}.{event_id}.{epoch}.zip`, so it already identifies the exact
    // generation; length distinguishes a rebuild at the same epoch. Together they are a strong
    // validator.
    //
    // Why this matters: `resolve_export_file` re-reads `export_current` on EVERY request, and a
    // download ticket outlives several redemptions. So a guest whose 500 MB download drops at
    // 500 MB, while the host takes a photo down (epoch bumps, rebuild lands, the old generation is
    // pruned), used to resume with `Range: bytes=500000000-` against a DIFFERENT FILE of a
    // different length — and the server would happily seek 500 MB into it and stream. The client
    // concatenated the two halves into a structurally corrupt ZIP, with nothing logged anywhere.
    let etag = format!(
        "\"{}-{len}\"",
        path.file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(filename)
    );

    let base = |status: StatusCode| {
        Response::builder()
            .status(status)
            .header(header::CONTENT_TYPE, content_type)
            .header(header::CONTENT_DISPOSITION, disposition.clone())
            // Advertised on EVERY response, including the 200. A client only knows it may resume
            // if the first (unranged) response says so.
            .header(header::ACCEPT_RANGES, "bytes")
            .header(header::ETAG, etag.clone())
    };

    // Serve a partial ONLY when the client proves it is resuming the same bytes.
    //
    // `If-Range` matching our ETag is that proof. A client that sends `Range` with no `If-Range`
    // at all (curl -C -, wget -c, most download managers) cannot be given a partial safely — it
    // has no way to notice the archive changed underneath it — so it gets a 200 and starts over.
    // Restarting a download is a cost; a silently corrupt keepsake is not recoverable. Browsers
    // send `If-Range`, so the ordinary resume path is unaffected, and this is the first release
    // where their resume works at all: without a validator they simply refused to try.
    let resume_is_safe = if_range_header.is_some_and(|v| v.trim() == etag);
    let effective_range = if resume_is_safe { range_header } else { None };

    match parse_range(effective_range, len) {
        RangeSpec::Full => base(StatusCode::OK)
            .header(header::CONTENT_LENGTH, len)
            .body(Body::from_stream(ReaderStream::new(file)))
            .map_err(|e| AppError::Internal(e.into())),

        RangeSpec::Partial { start, end } => {
            file.seek(std::io::SeekFrom::Start(start))
                .await
                .map_err(|e| AppError::Internal(e.into()))?;
            let span = end - start + 1;
            base(StatusCode::PARTIAL_CONTENT)
                .header(header::CONTENT_LENGTH, span)
                .header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{len}"))
                .body(Body::from_stream(ReaderStream::new(file.take(span))))
                .map_err(|e| AppError::Internal(e.into()))
        }

        RangeSpec::Unsatisfiable => base(StatusCode::RANGE_NOT_SATISFIABLE)
            .header(header::CONTENT_RANGE, format!("bytes */{len}"))
            .body(Body::empty())
            .map_err(|e| AppError::Internal(e.into())),
    }
}

/// Also expose export status to all authenticated users (guests need it for the export page)
pub async fn export_status(
    State(state): State<AppState>,
    _auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
    let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
        .await?
        .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;

    let released = event.export_released_at.is_some();

    // ONE statement: the epoch comparison happens inside the query, against a single snapshot.
    // Binding the epoch read by a previous statement would let a release/regeneration commit in
    // between and yield `{released: true, zip: locked, html: locked}` — a state that never existed.
    //
    // Only jobs at the CURRENT epoch are reported. A row left behind by a retired generation (a
    // worker superseded mid-run) is meaningless — surfacing its frozen `running`/77% would show a
    // progress bar that never moves for a keepsake nobody is building. It reads as "locked" (no
    // current job), which is exactly what it is.
    // `error_message` is carried here, not just on the admin dashboard's job list. The host is the
    // one who releases the keepsake and the one who owns the "Erneut versuchen" button, but this
    // endpoint used to hand them a bare `failed` — so a fully actionable reason (notably the disk
    // preflight's "needs X GB, Y GB free") was written to the row and then shown to nobody who
    // could act on it. An admin-only diagnostic is not a diagnostic for the person on the spot.
    let jobs: Vec<(String, String, i16, Option<String>)> = sqlx::query_as(
        "SELECT j.type::text, j.status::text, j.progress_pct, j.error_message
           FROM export_job j
           JOIN event e ON e.id = j.event_id
          WHERE e.id = $1 AND j.epoch = e.export_epoch",
    )
    .bind(event.id)
    .fetch_all(&state.pool)
    .await?;

    let job_status = |type_name: &str| {
        jobs.iter()
            .find(|(t, _, _, _)| t == type_name)
            .map(|(_, status, pct, err)| {
                serde_json::json!({
                    "status": status,
                    "progress_pct": pct,
                    // Only on a failure. A stale message left on a row that has since been re-armed
                    // would otherwise show an error next to a running progress bar.
                    "error_message": if status == "failed" { err.clone() } else { None },
                })
            })
            .unwrap_or_else(|| {
                serde_json::json!({
                    "status": "locked", "progress_pct": 0, "error_message": null,
                })
            })
    };

    Ok(Json(serde_json::json!({
        "released": released,
        "zip": job_status("zip"),
        "html": job_status("html"),
    })))
}

/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
/// each request.
async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
    let export_rate_on = config::get_bool(&state.config_cache, "export_rate_enabled", true).await;
    if !(rate_limits_on && export_rate_on) {
        return Ok(());
    }
    let limit = config::get_usize(&state.config_cache, "export_rate_per_day", 3).await;
    // Keyed per-user. This was the worst of the IP-keyed limiters: 3 downloads per DAY
    // shared across every guest behind the venue's public IP, so the fourth person to
    // fetch their keepsake was locked out until the next day.
    if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
        format!("export:{user_id}"),
        limit,
        Duration::from_secs(86400),
    ) {
        // Names the real window. The generic "warte kurz" wording this used to share with the
        // per-minute limiters is actively wrong here — the bucket is a DAY, so a guest told to
        // wait a moment would keep tapping a button that cannot work again until tomorrow.
        return Err(AppError::TooManyRequests(
            "Du hast das Tageslimit für Downloads erreicht. Versuch es später noch einmal — \
             deine Galerie bleibt gespeichert."
                .into(),
            Some(retry_after_secs),
        ));
    }
    Ok(())
}
