use std::time::Duration;

use axum::Json;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode};
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use uuid::Uuid;

use crate::auth::jwt;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::services::rate_limiter::client_ip;
use crate::state::AppState;

/// Names a guest may not take.
///
/// Defence in depth only. The real fix for the admin-lockout defect is that `admin_login` now
/// resolves its user by ROLE rather than by name (see `User::find_admin_for_event`), which is
/// why homoglyph and zero-width bypasses of this list are not a concern: the name is no longer
/// load-bearing for anything. What this buys is that a guest cannot impersonate the host in the
/// feed's byline, and that "Admin" stays available for the admin row.
const RESERVED_DISPLAY_NAMES: &[&str] = &["admin", "administrator", "host", "eventsnap"];

/// How long a `client_join_id` stays replayable after the account it created.
///
/// The key exists to survive a lost response, which a client retries within seconds — a guest
/// walking back into signal and reopening the app is the slow end of it. Beyond that the key is
/// only a liability: it is accepted pre-auth and answers with a session, so an unbounded lifetime
/// makes every abandoned join attempt a permanent credential sitting in `localStorage`.
const JOIN_REPLAY_WINDOW_MINUTES: i64 = 30;

/// May a presented `client_join_id` replay the account it created?
///
/// Extracted so the two guards are unit-testable without a database. Both must hold — see the
/// call site in `join` for why either alone is insufficient.
///
/// Case-insensitive on the name to match the `LOWER(display_name)` uniqueness index: a guest
/// retyping "anna" for "Anna" is the same person resuming, not a different one.
fn join_key_replayable(
    stored_name: &str,
    submitted_name: &str,
    created_at: chrono::DateTime<Utc>,
    now: chrono::DateTime<Utc>,
) -> bool {
    if stored_name.to_lowercase() != submitted_name.to_lowercase() {
        return false;
    }
    let age = now.signed_duration_since(created_at);
    // A negative age means the row is stamped in the future — clock skew between the app and
    // Postgres. Treat it as in-window rather than replayable-forever: the comparison below is
    // `<=`, so a negative duration passes, which is the same answer as "just created".
    age <= chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES)
}

fn is_reserved_display_name(name: &str) -> bool {
    let name = name.trim().to_lowercase();
    RESERVED_DISPLAY_NAMES.contains(&name.as_str())
}

/// Trim and bounds-check a display name.
///
/// Shared by `join`, `recover` and `request_pin_reset` so the length check happens BEFORE the
/// name is used to build a rate-limiter key. It was inline in `join` only, so on the other two
/// endpoints `format!("...:{ip}:{name_key}")` allocated from an unbounded, attacker-chosen
/// string and stored it in a HashMap pruned once an hour with a 24 h ceiling — turning the
/// limiter itself into the memory-exhaustion primitive it exists to prevent.
fn validate_display_name(raw: &str) -> Result<&str, AppError> {
    let name = raw.trim();
    let chars = name.chars().count();
    if chars == 0 || chars > 50 {
        return Err(AppError::BadRequest(
            "Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
        ));
    }
    // No control characters. NUL is the hard requirement — Postgres rejects 0x00 in TEXT with a
    // 500, so catching it here turns an internal error into a clean 400 — but the rest matter
    // too, and for reasons beyond tidiness:
    //
    //  * Newlines make the name a LOG INJECTION vector. Several 4xx messages interpolate it
    //    ("Der Name \"X\" ist bereits vergeben.") and those are logged; a name carrying a
    //    newline plus a plausible timestamp prefix lets two unauthenticated requests forge
    //    entries in the only forensic record an unattended event has. `error.rs` escapes on the
    //    way out as well — this is the other half, and the half that keeps the forged text out
    //    of the database and out of the feed byline in the first place.
    //  * A bare CR or a bidi override renders as a name that is not what was typed, in the feed,
    //    the host dashboard's moderation list and the keepsake.
    //
    // Deliberately NOT a whitelist: guests have accents, emoji and non-Latin scripts in their
    // names, and rejecting those would be worse than the problem.
    if name.chars().any(|c| c.is_control()) {
        return Err(AppError::BadRequest(
            "Name enthält ungültige Zeichen.".into(),
        ));
    }
    Ok(name)
}

#[derive(Deserialize)]
pub struct JoinRequest {
    pub display_name: String,
    /// Stable per-attempt key so a retry after a lost response resumes the same join instead of
    /// 409ing on a name the caller itself owns. Optional: older clients simply behave as before.
    /// See migration 027 and the retry branch in `join`.
    #[serde(default)]
    pub client_join_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct JoinResponse {
    pub jwt: String,
    pub pin: String,
    pub user_id: Uuid,
    pub is_new: bool,
}

pub async fn join(
    State(state): State<AppState>,
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<JoinRequest>,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
    let ip = client_ip(&headers, &peer.ip().to_string());
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
    let join_rate_on = config::get_bool(&state.config_cache, "join_rate_enabled", true).await;

    // Coarse per-IP flood ceiling. `/join` is pre-auth so there is no user to key on, and
    // at a venue EVERY guest arrives from one public IP — a tight per-IP bucket meant the
    // 6th person through the door was turned away by the 5 ahead of them. So the per-IP
    // limit here only bounds raw volume; the real anti-spam bucket is per-name below.
    // Cheap enough to run before validation, which keeps a flood of malformed bodies from
    // being free.
    if rate_limits_on && join_rate_on {
        // 300/min, not 60. The comment above has the right principle and the old default did not
        // follow it: a 100-guest wedding does not trickle in, it arrives at the door together when
        // the QR code goes up, and every one of those joins is the SAME public IP. At 60/min guests
        // 61-100 got a 429 on the one screen that has no auto-retry — the join page — so the
        // remedy was "ask a stranger why the app says no and tap again", at exactly the moment the
        // host is busiest. Each shed request also costs another slot when they do retry.
        //
        // This is not the anti-spam control (that is the per-name bucket below, which a flood
        // cannot evade) nor the CPU bound (that is BCRYPT_PERMITS, which caps concurrent hashing
        // at 2 regardless of how many requests arrive). It only bounds raw volume, so it can
        // afford to sit well above the real arrival peak.
        let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 300).await;
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("join_ip:{ip}"),
            ip_ceiling,
            Duration::from_secs(60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
    }

    let display_name = validate_display_name(&body.display_name)?;
    if is_reserved_display_name(display_name) {
        // 409, matching the name-taken response below, so the frontend's existing handling
        // works unchanged. See RESERVED_DISPLAY_NAMES for why this exists.
        return Err(AppError::Conflict(format!(
            "Der Name \"{display_name}\" ist reserviert. Bitte wähle einen anderen."
        )));
    }

    // Per-guest bucket, keyed like the `recover:{ip}:{name}` limiter below. This carries
    // the original 5/60s anti-spam intent, but one guest retrying can no longer consume
    // the allowance of everyone else sharing the venue's NAT.
    if rate_limits_on && join_rate_on {
        let name_key = display_name.to_lowercase();
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("join:{ip}:{name_key}"),
            5,
            Duration::from_secs(60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
    }

    let event = Event::find_or_create(
        &state.pool,
        &state.config.event_slug,
        &state.config.event_name,
    )
    .await?;

    // IDEMPOTENT RETRY — must come before the name-taken check, because on a retry the name is
    // taken by our OWN row and the 409 below is precisely the bug (H16).
    //
    // The account exists and its PIN hash is committed, but the plaintext went out in a response
    // that never arrived, so nobody alive knows it. We cannot replay it (only the bcrypt is
    // stored, deliberately), so instead we ROTATE it: mint a new PIN, overwrite the hash, issue a
    // fresh session, and answer as if this were the original reply.
    //
    // Rotating is safe here in a way it would not be elsewhere: the previous PIN was never
    // displayed to anyone, so there is no device holding it and nothing to invalidate. And it
    // beats the alternative of persisting plaintext PINs so they can be replayed — that would put
    // a recoverable credential in the database for every guest, to fix a lost packet.
    // Look the key up once, then decide whether this caller is entitled to replay it.
    let prior = match body.client_join_id {
        Some(k) => User::find_by_client_join_id(&state.pool, event.id, k).await?,
        None => None,
    };

    // A key replays ONLY for the name that minted it, and only briefly. Without both guards the
    // key is a bearer credential: it is accepted pre-auth, the submitted name was ignored, and the
    // reply carries a session with the stored row's ROLE. So anyone holding one guest's key could
    // send any name at all and receive that guest's account — a host's, if they had been promoted
    // — while the rotation below locked the rightful owner out of their own PIN.
    //
    //   * Name binding also fixes the ordinary, non-malicious version, which a party guarantees:
    //     a guest's join fails, they hand the venue tablet to the next person, and that person
    //     joins under their own name — landing in the first guest's account, posting as them.
    //   * The window bounds the credential's life. A lost response is retried in seconds, not
    //     hours; the client also clears the key on success, so the only keys that survive at all
    //     are genuinely-failed attempts. After the window the key is spent and a normal join runs.
    let replay = prior.as_ref().filter(|existing| {
        join_key_replayable(
            &existing.display_name,
            display_name,
            existing.created_at,
            Utc::now(),
        )
    });

    // A key whose row exists but is not ours to replay is spent — it must not be carried into the
    // INSERT below, or it would collide with that row on `user_client_join_id_key` and report a
    // name clash the guest cannot act on.
    let effective_join_key = if prior.is_some() {
        None
    } else {
        body.client_join_id
    };

    if let Some(existing) = replay {
        return replay_join(&state, event.id, existing).await;
    }

    // Reject if a user with this name (case-insensitive) already exists
    if User::name_taken(&state.pool, event.id, display_name).await? {
        return Err(AppError::Conflict(format!(
            "Der Name \"{}\" ist bereits vergeben.",
            display_name
        )));
    }

    // Generate a 4-digit PIN
    let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
    let pin_hash = hash_password(pin.clone(), 12).await?;

    // The pre-check above is racy: two simultaneous joins with the same name can both
    // pass it, and the DB's unique index then rejects the loser. Map that unique
    // violation to the same clean 409 the pre-check returns, not a generic 500.
    let user = match User::create(
        &state.pool,
        event.id,
        display_name,
        &pin_hash,
        effective_join_key,
    )
    .await
    {
        Ok(u) => u,
        Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
            // Either the display name or `client_join_id` collided. Both mean "somebody already
            // holds this", and for the join key that somebody is a concurrent retry of this very
            // request — so re-check the key and replay it rather than reporting a name clash the
            // guest cannot act on.
            // REPLAY it, don't 409. The loser of this race is holding proof that it is the same
            // attempt as the winner — the same `client_join_id`, minted by the same phone — and
            // the winner's row is a few milliseconds old under the same name, so the ordinary
            // name+window replay guard passes. Returning a Conflict instead sent the guest to the
            // join page's `code === 'conflict'` branch, which renders the NAME-TAKEN screen with a
            // PIN entry form — for a PIN that was never displayed to anybody. That is precisely
            // the dead end migration 027 exists to close, re-entered through the racing path, and
            // it is easy to hit: the guest's instinctive response to a hung request is to reload
            // and tap Join again.
            if let Some(join_key) = effective_join_key
                && let Some(winner) =
                    User::find_by_client_join_id(&state.pool, event.id, join_key).await?
                && join_key_replayable(
                    &winner.display_name,
                    display_name,
                    winner.created_at,
                    Utc::now(),
                )
            {
                return replay_join(&state, event.id, &winner).await;
            }
            return Err(AppError::Conflict(format!(
                "Der Name \"{}\" ist bereits vergeben.",
                display_name
            )));
        }
        Err(e) => return Err(e.into()),
    };

    let token = jwt::create_token(
        user.id,
        event.id,
        user.role.clone(),
        &state.config.jwt_secret,
        state.config.session_expiry_days,
    )
    .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;

    let token_hash = jwt::hash_token(&token);
    let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
    Session::create(&state.pool, user.id, &token_hash, expires_at).await?;

    Ok((
        StatusCode::CREATED,
        Json(JoinResponse {
            jwt: token,
            pin,
            user_id: user.id,
            is_new: true,
        }),
    ))
}

/// Answer a join retry with the row its `client_join_id` already minted.
///
/// Rotates the PIN and issues a fresh session. Rotating is safe precisely here: the previous PIN
/// was never displayed to anyone (that is what "the response was lost" means), so no device holds
/// it and there is nothing to invalidate. The alternative — persisting plaintext PINs so they can
/// be replayed — would put a recoverable credential in the database for every guest, to fix a lost
/// packet.
///
/// Callers must have established that this caller is ENTITLED to the replay (`join_key_replayable`:
/// same display name, inside the window). Both call sites do; the key is accepted pre-auth, so
/// without that check it would be a bearer credential for someone else's account.
async fn replay_join(
    state: &AppState,
    event_id: Uuid,
    existing: &User,
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
    let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
    let pin_hash = hash_password(pin.clone(), 12).await?;
    sqlx::query(
        "UPDATE \"user\"
         SET recovery_pin_hash = $2, failed_pin_attempts = 0, pin_locked_until = NULL
         WHERE id = $1",
    )
    .bind(existing.id)
    .bind(&pin_hash)
    .execute(&state.pool)
    .await?;

    let token = jwt::create_token(
        existing.id,
        event_id,
        existing.role.clone(),
        &state.config.jwt_secret,
        state.config.session_expiry_days,
    )
    .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
    let token_hash = jwt::hash_token(&token);
    let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
    Session::create(&state.pool, existing.id, &token_hash, expires_at).await?;

    tracing::info!(
        user_id = %existing.id,
        "join retry matched client_join_id; rotated the PIN and re-issued a session"
    );
    Ok((
        StatusCode::CREATED,
        Json(JoinResponse {
            jwt: token,
            pin,
            user_id: existing.id,
            is_new: true,
        }),
    ))
}

/// Default for `recover_name_rate_per_15min` — wrong PINs allowed per (IP, name) per 15 min.
/// Mirrors migration 023; kept here so the invariant below can be asserted in a test.
const RECOVER_NAME_CEILING_DEFAULT: usize = 4;

/// Hard ceiling on the CONFIGURED per-(IP, name) limit, whatever an operator sets.
///
/// The ordering `3 x ceiling <= PIN_LOCK_THRESHOLD` is the entire control that stops one source
/// from locking a victim out: display names are public on the feed, so if a single IP can spend
/// the whole lock threshold it can lock any guest it likes, repeatedly. That ordering was asserted
/// in a comment and in a test — but the test pinned the DEFAULT constant, while the handler reads
/// the config value, and `patch_config` accepted anything from 1 to 100_000. So an operator
/// raising this key restored the exact DoS the tier ordering exists to prevent, silently.
pub const RECOVER_NAME_CEILING_MAX: usize = (PIN_LOCK_THRESHOLD as usize) / 3;

/// Wrong PINs one IP may produce across ALL names before it is shut out for 15 minutes.
///
/// This is the tier that was missing, and its absence is what made 4-digit PINs practically
/// brute-forceable into a HOST account (H2). Hosts are promoted guests, so a host's entire
/// credential is a `{:04}` PIN, and `/uploaders` hands any joined guest the authoritative list of
/// names to try. The existing tiers are per-`(IP, name)` (4 per 15 min) and a per-IP REQUEST ceiling
/// (30/min) — neither of which bounds guesses *spread across names*:
///
///   * 4 guesses × 100 names = ~400 wrong PINs per 15 minutes from one IP
///   * no single account ever reaches the 12-failure lock, so nobody is locked out to notice
///   * nothing was logged beyond one `warn` per attempt, which nothing aggregates or alerts on
///
/// That is ~38,000 guesses/day against 100 accounts at 1/10,000 each — a coin-flip inside a week,
/// and far better odds than that against any particular host over an evening.
///
/// Counting FAILURES rather than requests is what makes this safe to set low: a guest recovering
/// their own device types their PIN correctly and is never charged, so a whole venue behind one NAT
/// is unaffected. 30 wrong PINs from a single IP in 15 minutes is already far beyond fat-fingering.
const RECOVER_IP_FAILURE_CEILING: usize = 30;
const RECOVER_IP_FAILURE_WINDOW: Duration = Duration::from_secs(15 * 60);

/// Window for the per-(IP, name) failure budget. Matches the per-account lockout duration, so a
/// guest who trips both waits the same fifteen minutes rather than two stacked penalties.
const RECOVER_NAME_WINDOW: Duration = Duration::from_secs(15 * 60);

/// Charge one failed PIN attempt against the per-IP budget, and report whether it is now spent.
///
/// Deliberately charged on the way OUT of a failure rather than checked on the way in, so that the
/// only thing that consumes budget is a genuinely wrong PIN.
fn charge_recover_failure(state: &AppState, ip: &str) {
    if let Err(retry_after) = state.rate_limiter.check_with_retry(
        format!("recover_fail:{ip}"),
        RECOVER_IP_FAILURE_CEILING,
        RECOVER_IP_FAILURE_WINDOW,
    ) {
        // Loud, and with the numbers an alert can key on. The old code logged one line per
        // attempt at the same level as an ordinary typo, so a horizontal sweep looked exactly
        // like a hundred guests mistyping.
        tracing::error!(
            ip = %ip,
            retry_after_secs = retry_after,
            ceiling = RECOVER_IP_FAILURE_CEILING,
            "possible PIN brute force: one IP exhausted its failed-PIN budget across names"
        );
    }
}

/// Wrong PINs, from ALL sources, before the ACCOUNT itself is locked for 15 minutes.
///
/// This was 3, which sat BELOW the per-(IP, name) ceiling of 5 — and that ordering, not the
/// number, was the defect. Display names are public on the feed, so three requests from a single
/// IP locked any guest out of their own account, repeatable every 15 minutes, indefinitely. The
/// tier meant to protect a guest was the easiest way to attack them.
///
/// Raised deliberately far above the per-IP tier so the two do different jobs. The per-(IP, name)
/// bucket is what stops a guesser, and it costs the ATTACKER. This tier is the last line against
/// a DISTRIBUTED guesser, and it is the only one an attacker can turn on a victim — so reaching
/// it must require at least three distinct sources inside the decay window.
///
/// Brute-force cost is unchanged: 12 attempts per 15 minutes is 48/hour against one account, so
/// 10 000 four-digit PINs still take ~208 hours no matter how many IPs are used. An honest guest
/// fat-fingering a 4-digit PIN never comes close, and `increment_failed_pin` now decays the
/// streak after 15 minutes so yesterday's typos don't count toward today's.
const PIN_LOCK_THRESHOLD: i16 = 12;

#[derive(Deserialize)]
pub struct RecoverRequest {
    pub display_name: String,
    pub pin: String,
}

#[derive(Serialize)]
pub struct RecoverResponse {
    pub jwt: String,
    pub user_id: Uuid,
}

/// A real cost-12 bcrypt hash of a fixed dummy value, computed once and cached. Used
/// to run a constant-time-ish verify on the "unknown display name" branch of
/// [`recover`], so that branch costs the same as a real (user-exists) verify and can't
/// be told apart by timing.
fn dummy_pin_hash() -> &'static str {
    static HASH: std::sync::OnceLock<String> = std::sync::OnceLock::new();
    HASH.get_or_init(|| {
        bcrypt::hash("eventsnap-dummy-not-a-real-pin", 12)
            .expect("bcrypt hashing of a static dummy value cannot fail")
    })
}

/// Run a bcrypt verify on the blocking pool.
///
/// bcrypt at cost 12 is ~200ms of deliberate CPU. Called inline on an async task it pins a
/// tokio WORKER thread for that whole time, and the runtime only has one per core — so a
/// flood of `/recover` or `/admin/login` attempts stalls every other request on the box,
/// including the feed. Offloading moves that cost to the blocking pool, which is sized for
/// exactly this and whose saturation degrades logins rather than the whole app.
/// Process-wide ceiling on CONCURRENT bcrypt work.
///
/// bcrypt is deliberately expensive — ~250 ms of a core at cost 12, and this deployment's own
/// runbook generates the admin hash at a higher cost than that. Every call is correctly on
/// `spawn_blocking`, but tokio's blocking pool defaults to 512 threads, so "off the async
/// runtime" is not the same as "bounded": enough concurrent hashes will preempt both async
/// worker threads a 2 vCPU box gets, and uploads, feed and SSE stall behind them.
///
/// Three unauthenticated endpoints reach bcrypt — `/join` (hash), `/recover` (verify, including
/// a deliberate throwaway verify for unknown names) and `/admin/login` (verify) — each with only
/// a per-IP bucket in front, and at a venue every guest shares one public IP. A per-IP limit
/// therefore bounds nothing globally.
///
/// `cores - 1` leaves a core for actually serving requests. Excess callers WAIT on the permit
/// rather than burning CPU, so a flood degrades to latency instead of an outage.
static BCRYPT_PERMITS: std::sync::LazyLock<tokio::sync::Semaphore> =
    std::sync::LazyLock::new(|| {
        let cores = std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(2);
        // `cores - 1` resolves to exactly ONE permit on the 2-vCPU box this runs on, which turned
        // every join/recover/admin-login into a strict queue at ~200-250 ms each. `/recover` runs
        // an unconditional verify even for an unknown name at 30/min/IP, so roughly eight source
        // IPs saturated it indefinitely — and with an untimed `acquire()` an arriving guest's
        // `/join` HUNG rather than getting a 503 they could retry.
        //
        // Floor of 2: bcrypt at cost 12 is CPU-bound but runs on the blocking pool, so two in
        // flight on two cores still leaves the async runtime responsive, and it doubles arrival
        // throughput during the one burst that matters (everyone scanning the QR at once).
        tokio::sync::Semaphore::new(cores.saturating_sub(1).max(2))
    });

/// How long to wait for a bcrypt permit before shedding the request.
///
/// Generous relative to one hash (~200-250 ms) and far below any client timeout, so it only fires
/// when the queue is genuinely deep. Shedding with a `Retry-After` beats hanging: the client
/// already honours 503 + Retry-After on the load-shedding path, and a guest who is told to try
/// again in a moment is in a much better position than one staring at a spinner.
/// How long an auth request waits for a bcrypt permit before shedding.
///
/// Sized against the arrival burst, not against a comfortable latency. Two permits at cost 12 is
/// ~8 hashes/second, and tokio's semaphore is FIFO, so the request at queue position N waits
/// roughly N/8 seconds. At 5s the cliff was position ~40: the "everyone scans the QR as they walk
/// in" moment — the one case this endpoint exists for — put more than half of a 100-guest arrival
/// past the deadline and answered them with a 503. The join page has no auto-retry, so each one
/// became a guest standing in the doorway asking the host why the link is broken.
///
/// 15s drains a 100-guest burst (~12.5s) with margin, and still sits under the client's own 20s
/// abort (`TIMEOUT_MS` in `api.ts`), so a genuinely saturated server is still reported as a
/// retryable 503 rather than a hang. The shed remains for real overload; it just stops firing on
/// the ordinary case.
const BCRYPT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);

/// The 503 both bcrypt paths shed with.
fn bcrypt_busy() -> AppError {
    tracing::warn!("bcrypt queue saturated; shedding an auth request");
    AppError::ServiceUnavailable(
        "Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
        Some(5),
    )
}

async fn verify_password(candidate: String, hash: String) -> Result<bool, AppError> {
    verify_password_within(candidate, hash, BCRYPT_ACQUIRE_TIMEOUT).await
}

/// [`verify_password`] with an explicit ceiling on how long it will queue for a hash permit.
///
/// The short-timeout variant is what lets an over-budget caller still be *served* rather than
/// refused outright: the CPU bound is the semaphore, so shedding on a brief acquire timeout caps
/// the work just as hard as a rate bucket does — but it sheds whoever happens to arrive while the
/// permits are busy, instead of categorically refusing an IP that a flooder shares with the victim.
async fn verify_password_within(
    candidate: String,
    hash: String,
    acquire_timeout: Duration,
) -> Result<bool, AppError> {
    // Bounded wait — see BCRYPT_ACQUIRE_TIMEOUT. `acquire()` itself only fails if the semaphore is
    // closed, which never happens here; the timeout is the case we care about.
    let _permit = tokio::time::timeout(acquire_timeout, BCRYPT_PERMITS.acquire())
        .await
        .map_err(|_| bcrypt_busy())?;
    Ok(
        tokio::task::spawn_blocking(move || bcrypt::verify(&candidate, &hash).unwrap_or(false))
            .await
            .unwrap_or(false),
    )
}

/// Hash a secret on the blocking pool. Same reasoning as [`verify_password`] — and this one
/// runs on the busiest auth path there is, since every guest who joins gets a PIN hashed.
pub async fn hash_password(secret: String, cost: u32) -> Result<String, AppError> {
    // Same global ceiling as `verify_password` — `/join` hashes a PIN for every guest, and 100
    // guests scanning the QR at once is the arrival burst this box has to survive. Same bounded
    // wait, too: hanging on the arrival path is the worst place to hang.
    let _permit = tokio::time::timeout(BCRYPT_ACQUIRE_TIMEOUT, BCRYPT_PERMITS.acquire())
        .await
        .map_err(|_| bcrypt_busy())?;
    tokio::task::spawn_blocking(move || bcrypt::hash(&secret, cost))
        .await
        .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
        .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))
}

pub async fn recover(
    State(state): State<AppState>,
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<RecoverRequest>,
) -> Result<Json<RecoverResponse>, AppError> {
    // Validated BEFORE it is used as a rate-limiter key — see `validate_display_name`. The
    // per-IP ceiling below is keyed only on the IP, so it is safe to run either side of this;
    // the per-NAME bucket is not.
    let display_name = validate_display_name(&body.display_name)?;

    // Per-IP+name throttle BEFORE the per-user lockout counter. Without this an attacker who
    // knows a display name (they're visible on the feed) can burn through the victim's wrong-PIN
    // budget and lock them out, repeatedly. The ceiling here MUST stay below
    // PIN_LOCK_THRESHOLD — see the constant for why that ordering is the whole control.
    let ip = client_ip(&headers, &peer.ip().to_string());
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
    let recover_rate_on = config::get_bool(&state.config_cache, "recover_rate_enabled", true).await;
    if rate_limits_on && recover_rate_on {
        // Coarse per-IP ceiling FIRST. The per-(ip, name) bucket below is the anti-guessing
        // control, but the name is attacker-chosen, so cycling names mints a fresh bucket
        // every time and leaves the per-IP cost unbounded. That matters more here than
        // anywhere else: every call runs a cost-12 bcrypt verify, including an
        // unconditional throwaway one for names that don't exist (see below), so an unknown
        // name is the CHEAPEST way to make the server do ~200ms of hashing. Checked before
        // the per-name bucket so a name generator can't walk past it.
        let ip_ceiling =
            config::get_usize(&state.config_cache, "recover_ip_rate_per_min", 30).await;
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("recover_ip:{ip}"),
            ip_ceiling,
            Duration::from_secs(60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Versuche. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
    }

    // The per-(IP, name) tier. Same treatment as the cross-name tier below, and for the same
    // reason: this bucket is keyed on an IP that behind the venue's NAT is the ENTIRE PARTY, so a
    // gate that refuses the request outright refuses it for everyone who shares that name key.
    //
    // It used to be a `check_with_retry` sitting here, before the account was looked up — which
    // means it charged EVERY request, including successful recoveries, and refused before any PIN
    // was verified. Ceiling is clamped to RECOVER_NAME_CEILING_MAX (4). So four POSTs naming
    // "Braut Sophie" with PIN 0000 locked Sophie out of her own recovery for fifteen minutes, WITH
    // THE CORRECT PIN, from any phone on the venue wifi — and four more every fifteen minutes
    // sustained it indefinitely, at a request rate far under every volume ceiling above. The
    // benign version needs no attacker: the host mistypes their own 4-digit PIN four times.
    //
    // Now it counts FAILURES only, and a spent budget changes what a failure answers rather than
    // refusing outright. A correct PIN always authenticates. Guessing is bounded exactly as before
    // — wrong PINs are what spend the budget — with the per-account 3-strike lockout underneath it.
    let name_key = display_name.to_lowercase();
    let name_bucket = format!("recover_name_fail:{ip}:{name_key}");
    let name_ceiling = if rate_limits_on && recover_rate_on {
        Some(
            config::get_usize(
                &state.config_cache,
                "recover_name_rate_per_15min",
                RECOVER_NAME_CEILING_DEFAULT,
            )
            .await
            // CLAMPED, not merely defaulted — see RECOVER_NAME_CEILING_MAX. The config value is
            // operator-settable and the invariant it has to respect is not expressible in
            // `patch_config`'s numeric range, so it is enforced at the point of use.
            .min(RECOVER_NAME_CEILING_MAX),
        )
    } else {
        None
    };
    let name_budget_spent: Option<u64> = name_ceiling.and_then(|max| {
        state
            .rate_limiter
            .peek(&name_bucket, max, RECOVER_NAME_WINDOW)
            .err()
    });
    // Charged on the way OUT of a failure, exactly like `charge_recover_failure`.
    let charge_name_failure = || {
        if let Some(max) = name_ceiling {
            let _ =
                state
                    .rate_limiter
                    .check_with_retry(name_bucket.clone(), max, RECOVER_NAME_WINDOW);
        }
    };
    let name_refusal = |retry_after_secs: u64| {
        AppError::TooManyRequests(
            "Zu viele fehlgeschlagene Versuche für diesen Namen. Bitte warte 15 Minuten.".into(),
            Some(retry_after_secs),
        )
    };

    // The cross-name tier (H2). Read-only here — budget is spent only by an actual wrong PIN
    // below — so a venue full of guests recovering their own devices never trips it, while a
    // horizontal sweep across the public name list runs out after RECOVER_IP_FAILURE_CEILING.
    //
    // Placed before any bcrypt work so an exhausted IP also stops consuming the hash permits.
    // A SPENT BUDGET NO LONGER REFUSES THE REQUEST OUTRIGHT — it only changes what a FAILURE
    // answers. This gate used to `return` here, before the account was even looked up, and that
    // handed any guest a venue-wide denial of service.
    //
    // The bucket is keyed on IP, and behind the venue's NAT that is one address for the entire
    // party. Thirty POSTs with invented names — each one landing in the `users.is_empty()` branch
    // below, which charged unconditionally — spent the shared budget for fifteen minutes, and ~2
    // requests/minute sustained it indefinitely. Everyone at the party was then refused PIN
    // recovery WITH THE CORRECT PIN. The host is the one who cannot absorb that: hosts are
    // promoted guests whose only credential is a 4-digit PIN, so `/recover` is their only way back
    // in after losing a session, and the constants here have no config key to turn off.
    //
    // So an exhausted budget is carried as a flag: a correct PIN still authenticates, while every
    // wrong one answers 429 instead of 401. The guessing itself stays bounded where it always
    // really was — the per-(IP,name) ceiling, and the per-account 3-strike lockout that no
    // attacker on any IP can evade.
    let ip_budget_spent: Option<u64> = if rate_limits_on && recover_rate_on {
        state
            .rate_limiter
            .peek(
                &format!("recover_fail:{ip}"),
                RECOVER_IP_FAILURE_CEILING,
                RECOVER_IP_FAILURE_WINDOW,
            )
            .err()
    } else {
        None
    };
    let sweep_refusal = |retry_after_secs: u64| {
        AppError::TooManyRequests(
            "Zu viele fehlgeschlagene Versuche von diesem Netzwerk. Bitte warte 15 Minuten.".into(),
            Some(retry_after_secs),
        )
    };

    let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
        .await?
        .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;

    let users = User::find_by_event_and_name(&state.pool, event.id, display_name).await?;

    if users.is_empty() {
        // No user with this name. Run a throwaway bcrypt verify so this branch takes
        // the same time as the user-exists path, and return the SAME error as a wrong
        // PIN — so "no such name" and "wrong PIN" are indistinguishable by response or
        // timing. Display names are already public on the feed, but this still closes
        // the /recover enumeration + timing oracle.
        let _ = verify_password(body.pin.clone(), dummy_pin_hash().to_string()).await;
        // Charged here too, or the cheapest sweep (guessing names that don't exist) would be free
        // — and the whole point of the tier is that guessing costs the guesser something.
        charge_recover_failure(&state, &ip);
        charge_name_failure();
        if let Some(retry_after_secs) = name_budget_spent {
            return Err(name_refusal(retry_after_secs));
        }
        if let Some(retry_after_secs) = ip_budget_spent {
            return Err(sweep_refusal(retry_after_secs));
        }
        return Err(AppError::Unauthorized("PIN ist falsch.".into()));
    }

    for user in &users {
        // Check PIN lockout. If the lockout has expired, also reset the failed-attempt
        // counter so the user gets a fresh 3-strike window — otherwise the counter
        // stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
        // after waiting out the cooldown. Without this reset, a once-locked account
        // is effectively permanently fragile.
        if let Some(locked_until) = user.pin_locked_until {
            if Utc::now() < locked_until {
                // The exact deadline is known, so surface it as Retry-After instead of
                // making the client guess at the "15 Minuten" in the copy.
                let retry_after_secs = (locked_until - Utc::now()).num_seconds().max(1) as u64;
                return Err(AppError::TooManyRequests(
                    "Zu viele Versuche. Bitte warte 15 Minuten.".into(),
                    Some(retry_after_secs),
                ));
            }
            // Lockout window expired — wipe the counter and the timestamp.
            User::reset_pin_attempts(&state.pool, user.id).await?;
        }

        let pin_matches = verify_password(body.pin.clone(), user.recovery_pin_hash.clone()).await?;

        if pin_matches {
            // Reset failed attempts on success
            User::reset_pin_attempts(&state.pool, user.id).await?;

            let token = jwt::create_token(
                user.id,
                event.id,
                user.role.clone(),
                &state.config.jwt_secret,
                state.config.session_expiry_days,
            )
            .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;

            let token_hash = jwt::hash_token(&token);
            let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
            Session::create(&state.pool, user.id, &token_hash, expires_at).await?;

            return Ok(Json(RecoverResponse {
                jwt: token,
                user_id: user.id,
            }));
        }

        // Wrong PIN — charge both the per-account counter and this IP's cross-name budget. The
        // account counter alone never fires against a sweep that only spends 4 guesses per name.
        charge_recover_failure(&state, &ip);
        charge_name_failure();
        let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
        tracing::warn!(
            user_id = %user.id,
            event_id = %event.id,
            ip = %ip,
            attempts,
            "recover: wrong PIN"
        );
        if attempts >= PIN_LOCK_THRESHOLD {
            let lockout = Utc::now() + chrono::Duration::minutes(15);
            User::lock_pin(&state.pool, user.id, lockout).await?;
            tracing::warn!(
                user_id = %user.id,
                event_id = %event.id,
                ip = %ip,
                "recover: account locked for 15 minutes"
            );
        }
    }

    if let Some(retry_after_secs) = name_budget_spent {
        return Err(name_refusal(retry_after_secs));
    }
    if let Some(retry_after_secs) = ip_budget_spent {
        return Err(sweep_refusal(retry_after_secs));
    }
    Err(AppError::Unauthorized("PIN ist falsch.".into()))
}

#[derive(Deserialize)]
pub struct AdminLoginRequest {
    pub password: String,
}

#[derive(Serialize)]
pub struct AdminLoginResponse {
    pub jwt: String,
    /// The admin's user id + display name, so the client can populate a real identity
    /// (own-post affordances, a name on the Account page) instead of a blank session.
    pub user_id: Uuid,
    pub display_name: String,
}

/// Requests per minute per IP after which admin login is served in DEGRADED mode.
///
/// Not a security control — the failure bucket below is. It bounds how deep a queue can form on
/// `BCRYPT_PERMITS`, which is what actually caps the CPU cost.
///
/// This used to REFUSE above the ceiling, and that made it a denial of service against the one
/// person who cannot route around it. `/admin/login` is a public linkable page, every guest at the
/// venue shares the operator's IP behind NAT, and the check ran before `verify_password` — so one
/// phone posting twice a minute kept the bucket full and the operator, on that same IP, was
/// refused WITH THE CORRECT PASSWORD. The escape hatch was circular: `admin_login_rate_enabled`
/// is only flippable through `PATCH /admin/config`, which needs the session being refused. That
/// locked out moderation, gallery release and every config key — including the ones that would
/// undo it.
///
/// So exceeding it no longer refuses; it shortens the hash-permit wait to
/// [`ADMIN_LOGIN_DEGRADED_ACQUIRE`]. The CPU bound is unchanged, because the CPU bound was always
/// the semaphore and never this bucket: a flood now sheds itself on a busy semaphore, while the
/// operator's single well-timed request still gets a permit and a truthful answer.
const ADMIN_LOGIN_CPU_CEILING: usize = 30;

/// How long an over-budget admin login will queue for a hash permit before shedding with 503.
///
/// Short enough that a flood cannot build a queue that starves `/join`, long enough that a
/// request arriving while two permits are mid-hash (~250 ms each) still waits its turn.
const ADMIN_LOGIN_DEGRADED_ACQUIRE: Duration = Duration::from_secs(1);

pub async fn admin_login(
    State(state): State<AppState>,
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<AdminLoginRequest>,
) -> Result<Json<AdminLoginResponse>, AppError> {
    if state.config.admin_password_hash.is_empty() {
        return Err(AppError::Forbidden(
            "Admin-Login ist nicht konfiguriert.".into(),
        ));
    }

    // Throttling here is in two parts, and the ORDER is the whole point.
    //
    // A single tight IP-keyed bucket checked before the password was verified made this
    // endpoint a denial-of-service against its own operator. Every guest at the venue shares
    // one public IP behind NAT, `/admin/login` is a public linkable page, and the check ran
    // BEFORE `verify_password` — so five requests a minute from any phone in the room kept the
    // bucket permanently full and the admin, on that same IP, could never spend a slot.
    // Successful logins consumed budget too, so a typo plus a retry on two devices did it by
    // accident. And the escape hatch was circular: `admin_login_rate_enabled` can only be
    // flipped through `PATCH /admin/config`, which needs the session being blocked.
    let ip = client_ip(&headers, &peer.ip().to_string());
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
    let admin_rate_on =
        config::get_bool(&state.config_cache, "admin_login_rate_enabled", true).await;

    // Part 1: a ceiling whose only job is to bound how deep a queue can form on the hash permits.
    // Cost-12 verification is ~250 ms of a core, so an endpoint with no bound at all lets a flood
    // build a backlog that starves `/join`. Exceeding it DEGRADES rather than refuses — see
    // ADMIN_LOGIN_CPU_CEILING for why refusing here was a DoS against the operator.
    let degraded = rate_limits_on
        && admin_rate_on
        && state
            .rate_limiter
            .check_with_retry(
                format!("admin_login_cpu:{ip}"),
                ADMIN_LOGIN_CPU_CEILING,
                Duration::from_secs(60),
            )
            .is_err();
    if degraded {
        tracing::warn!(
            ip = %ip,
            "admin login over its per-IP volume ceiling; serving with a shortened hash-permit wait"
        );
    }

    let valid = verify_password_within(
        body.password.clone(),
        state.config.admin_password_hash.clone(),
        if degraded {
            ADMIN_LOGIN_DEGRADED_ACQUIRE
        } else {
            BCRYPT_ACQUIRE_TIMEOUT
        },
    )
    .await?;

    if !valid {
        // Part 2: the tight bucket, charged ONLY on a wrong password. A correct password is
        // never rate-limited, so no amount of guessing by anyone else can lock the operator
        // out — which also dissolves the circular escape hatch above. Brute force is still
        // bounded: every wrong guess costs a slot, and slots are per-IP.
        if rate_limits_on
            && admin_rate_on
            && let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
                format!("admin_login_fail:{ip}"),
                5,
                Duration::from_secs(60),
            )
        {
            tracing::warn!(ip = %ip, "admin_login: wrong password, failure bucket exhausted");
            return Err(AppError::TooManyRequests(
                "Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
        tracing::warn!(ip = %ip, "admin_login: wrong password");
        return Err(AppError::Unauthorized("Falsches Passwort.".into()));
    }

    let event = Event::find_or_create(
        &state.pool,
        &state.config.event_slug,
        &state.config.event_name,
    )
    .await?;

    // Find or create the admin user for this event — BY ROLE, never by name.
    //
    // The name lookup this replaces is what made admin login brickable. Migration 007 makes
    // display_name unique per event case-insensitively and `join` had no reserved-name guard,
    // so a guest joining as "admin" before the operator's first login made the lookup miss on
    // role, the fallback `create("Admin")` violate that index, and `?` return a permanent 500 —
    // taking out moderation, config and gallery release with no in-app recovery.
    let admin_user = match User::find_admin_for_event(&state.pool, event.id).await? {
        Some(u) => u,
        None => create_admin_user(&state, event.id).await?,
    };

    tracing::info!(user_id = %admin_user.id, event_id = %event.id, ip = %ip, "admin_login: success");

    let token = jwt::create_token(
        admin_user.id,
        event.id,
        UserRole::Admin,
        &state.config.jwt_secret,
        1, // Admin sessions expire after 1 day
    )
    .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;

    let token_hash = jwt::hash_token(&token);
    let expires_at = Utc::now() + chrono::Duration::days(1);
    Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;

    Ok(Json(AdminLoginResponse {
        jwt: token,
        user_id: admin_user.id,
        display_name: admin_user.display_name,
    }))
}

/// Create this event's admin row on first successful admin login.
///
/// Prefers the name "Admin". If a legacy database has a guest squatting on it — the state
/// migration 023 renames away, but a row could also predate that or be created between
/// migrations — falls back to a suffixed name rather than failing the login.
///
/// PROMOTING THE SQUATTING ROW WOULD BE A SERIOUS MISTAKE, and is the obvious-looking fix, so
/// it is spelled out: that row carries a `recovery_pin_hash` the guest knows. Setting
/// `role = 'admin'` on it would hand them the admin dashboard through `/recover`, permanently,
/// via a path that needs no password. A separate row under an uglier name is worse UX and much
/// better security — and since the lookup is now by role, the fallback name never has to be
/// guessed again on a later login.
async fn create_admin_user(state: &AppState, event_id: Uuid) -> Result<User, AppError> {
    // Admin authenticates via password, but the schema still requires a PIN hash. Generate a
    // random unguessable one so the recovery path stays unusable as an escalation route even if
    // the role flag were ever cleared.
    let dummy_pin: String = (0..32)
        .map(|_| rand::rng().random_range(b'a'..=b'z') as char)
        .collect();
    let dummy_hash = hash_password(dummy_pin, 4).await?;

    match User::create_with_role(&state.pool, event_id, "Admin", &dummy_hash, UserRole::Admin).await
    {
        Ok(u) => Ok(u),
        Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
            let fallback = format!("Admin-{}", &Uuid::new_v4().to_string()[..8]);
            tracing::warn!(
                %event_id, %fallback,
                "the name \"Admin\" is held by a non-admin user; creating the admin under a \
                 fallback name. Rename that guest to free it — do NOT promote their row, they \
                 know its recovery PIN."
            );
            Ok(User::create_with_role(
                &state.pool,
                event_id,
                &fallback,
                &dummy_hash,
                UserRole::Admin,
            )
            .await?)
        }
        Err(e) => Err(e.into()),
    }
}

pub async fn logout(State(state): State<AppState>, auth: AuthUser) -> Result<StatusCode, AppError> {
    Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
    Ok(StatusCode::NO_CONTENT)
}

/// "Sign out everywhere" — revoke every session for the caller, not just the current one.
/// A single leaked/kept-alive device (a shared phone, a laptop recovered via PIN) can then
/// be cut from any of the user's devices.
pub async fn logout_all(
    State(state): State<AppState>,
    auth: AuthUser,
) -> Result<StatusCode, AppError> {
    Session::delete_all_for_user(&state.pool, auth.user_id).await?;
    Ok(StatusCode::NO_CONTENT)
}

#[derive(Deserialize)]
pub struct PinResetRequestBody {
    pub display_name: String,
}

/// A guest who forgot their PIN asks a host to reset it in-app. Unauthenticated (they
/// can't log in without the PIN) and rate-limited. Always returns 204 regardless of
/// whether the name exists, so it can't enumerate display names beyond what the public
/// feed already exposes.
pub async fn request_pin_reset(
    State(state): State<AppState>,
    ConnectInfo(peer): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    Json(body): Json<PinResetRequestBody>,
) -> Result<StatusCode, AppError> {
    let ip = client_ip(&headers, &peer.ip().to_string());
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;

    // Coarse per-IP ceiling FIRST, keyed only on the IP so its key is bounded by construction.
    // /join got one of these in migration 017 and /recover in 019; this third unauthenticated
    // endpoint was simply missed — migration 019's own comment describes exactly this attack.
    // Without it, the per-name bucket below is no ceiling at all: the name is attacker-chosen,
    // so cycling names mints a fresh bucket every request.
    if rate_limits_on {
        let ip_ceiling =
            config::get_usize(&state.config_cache, "pin_reset_ip_rate_per_min", 30).await;
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("pin_reset_ip:{ip}"),
            ip_ceiling,
            Duration::from_secs(60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
    }

    // Validated BEFORE the per-name key is built, so an unbounded name can never be retained in
    // the limiter map. NOTE the 204: this endpoint's contract is that it answers identically
    // whether or not the name exists, so it cannot enumerate guests. A 400 here would be a new
    // signal — it would distinguish a malformed name from a well-formed unknown one. Silence is
    // the correct response, and matches what an empty name already did.
    let Ok(display_name) = validate_display_name(&body.display_name) else {
        return Ok(StatusCode::NO_CONTENT);
    };

    if rate_limits_on {
        let name_key = display_name.to_lowercase();
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("pin_reset_req:{ip}:{name_key}"),
            3,
            Duration::from_secs(15 * 60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
                Some(retry_after_secs),
            ));
        }
    }

    // Single statement so the existing-name and unknown-name paths do IDENTICAL work
    // (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
    // no timing oracle despite the always-204 contract. Admins recover via password, so
    // they're excluded from the join and never get a reset request queued.
    let _ = sqlx::query(
        "INSERT INTO pin_reset_request (event_id, user_id)
         SELECT e.id, u.id
         FROM event e
         JOIN \"user\" u
           ON u.event_id = e.id
          AND lower(u.display_name) = lower($2)
          AND u.role <> 'admin'
         WHERE e.slug = $1
         ON CONFLICT (user_id) DO NOTHING",
    )
    .bind(&state.config.event_slug)
    .bind(display_name)
    .execute(&state.pool)
    .await;

    // Broadcast unconditionally (carries no per-name info) so an online host's request
    // badge updates without revealing whether the name existed.
    let _ = state
        .sse_tx
        .send(crate::state::SseEvent::new("pin-reset-requested", "{}"));

    Ok(StatusCode::NO_CONTENT)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Control characters are rejected at the door. Newlines in particular: several 4xx messages
    /// interpolate the display name and those are logged, so a name carrying a newline plus a
    /// plausible prefix would let two unauthenticated requests forge lines in the event's only
    /// forensic record. `error.rs` escapes on output too; this keeps it out of the database and
    /// the feed byline in the first place.
    #[test]
    fn a_display_name_may_not_carry_control_characters() {
        for bad in ["Anna\nERROR forged", "Anna\rX", "Anna\u{0}X", "A\u{7}B"] {
            assert!(
                validate_display_name(bad).is_err(),
                "{bad:?} must be rejected"
            );
        }
        // Real guests have accents, emoji and non-Latin names — never reject those.
        for good in [
            "Anna",
            "Zo\u{eb}",
            "Jos\u{e9}",
            "\u{5c71}\u{7530}",
            "Anna \u{1f389}",
        ] {
            assert!(
                validate_display_name(good).is_ok(),
                "{good:?} must be allowed"
            );
        }
    }

    /// THE defect, stated as arithmetic: the account-lock threshold sat BELOW the per-(IP, name)
    /// attempt ceiling, so a single IP could exhaust it and lock any guest whose display name is
    /// visible on the feed — every 15 minutes, indefinitely. The tier meant to protect a guest
    /// was the cheapest way to attack them.
    ///
    /// The fix is the ORDERING, not either number on its own, so that is what this pins.
    #[test]
    fn one_ip_cannot_reach_the_account_lock() {
        assert!(
            PIN_LOCK_THRESHOLD as usize >= RECOVER_NAME_CEILING_MAX * 3,
            "locking a victim must require at least three distinct sources; \
             threshold {PIN_LOCK_THRESHOLD} vs the ENFORCED per-IP ceiling \
             {RECOVER_NAME_CEILING_MAX} (the default is {RECOVER_NAME_CEILING_DEFAULT})"
        );
    }

    /// Raising the threshold must not quietly weaken brute-force resistance. 4-digit PINs, and
    /// the lockout window is 15 minutes, so an attacker gets PIN_LOCK_THRESHOLD tries per window.
    #[test]
    fn the_raised_threshold_still_makes_guessing_a_four_digit_pin_impractical() {
        let attempts_per_hour = PIN_LOCK_THRESHOLD as u64 * 4; // four 15-minute windows
        let hours_for_full_keyspace = 10_000 / attempts_per_hour;
        assert!(
            hours_for_full_keyspace >= 168,
            "exhausting 10k PINs would take {hours_for_full_keyspace}h — under a week is too fast"
        );
    }

    #[test]
    fn reserved_names_are_matched_case_insensitively_and_trimmed() {
        for name in ["admin", "Admin", "ADMIN", "  Host  ", "EventSnap"] {
            assert!(is_reserved_display_name(name), "{name} must be reserved");
        }
    }

    /// A substring match here would reject perfectly ordinary names, which is a worse outcome
    /// than the impersonation the list guards against.
    #[test]
    fn names_that_merely_contain_a_reserved_word_are_allowed() {
        for name in ["Administrata", "Hostess", "Adminah", "Ghost", "hosting"] {
            assert!(!is_reserved_display_name(name), "{name} must be allowed");
        }
    }

    #[test]
    fn display_names_are_bounded_before_they_can_become_a_rate_limit_key() {
        assert!(validate_display_name("  Lena  ").is_ok());
        assert_eq!(validate_display_name("  Lena  ").unwrap(), "Lena");
        // The case that made the limiter itself the exhaustion primitive.
        assert!(validate_display_name(&"a".repeat(51)).is_err());
        assert!(validate_display_name(&"a".repeat(2_000_000)).is_err());
        assert!(validate_display_name("   ").is_err());
        assert!(validate_display_name("bad\0name").is_err());
    }

    /// The join key is presented pre-auth and the reply carries a SESSION for the stored row —
    /// so a key that replays for a name it did not create is an account-takeover primitive, not
    /// a convenience bug. These pin both guards.
    #[test]
    fn a_join_key_replays_only_for_the_name_that_created_it() {
        let created = Utc::now();
        // The legitimate case: same guest, same name, retrying a lost response.
        assert!(join_key_replayable("Anna", "Anna", created, created));
        // Case-insensitive, matching the LOWER(display_name) uniqueness index.
        assert!(join_key_replayable("Anna", "anna", created, created));
        assert!(join_key_replayable("Zoë", "zoë", created, created));

        // The takeover: a held key presented with any other name must NOT resolve to the
        // stored account, whatever role that account happens to carry.
        assert!(!join_key_replayable(
            "Braut Sophie",
            "Zufaelliger Fremder",
            created,
            created
        ));
        // The shared-device case a party guarantees: the next person types their own name.
        assert!(!join_key_replayable("Anna", "Bernd", created, created));
        // Not a prefix or substring match either.
        assert!(!join_key_replayable("Anna", "Anna B.", created, created));
    }

    #[test]
    fn a_join_key_stops_replaying_once_its_window_closes() {
        let created = Utc::now();
        let inside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES - 1);
        let edge = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES);
        let outside = created + chrono::Duration::minutes(JOIN_REPLAY_WINDOW_MINUTES + 1);

        assert!(join_key_replayable("Anna", "Anna", created, inside));
        // Inclusive at the boundary — an exactly-on-time retry is still the guest's own.
        assert!(join_key_replayable("Anna", "Anna", created, edge));
        // Past it the key is spent, so an abandoned attempt in localStorage stops being a
        // permanent unauthenticated credential.
        assert!(!join_key_replayable("Anna", "Anna", created, outside));
        assert!(!join_key_replayable(
            "Anna",
            "Anna",
            created,
            created + chrono::Duration::days(3)
        ));
    }

    #[test]
    fn a_clock_skewed_row_is_treated_as_fresh_not_immortal() {
        // Postgres stamps `created_at`; if its clock is ahead of ours the age is negative. That
        // must read as "just created" (replayable), never as an unbounded window.
        let now = Utc::now();
        let future = now + chrono::Duration::minutes(5);
        assert!(join_key_replayable("Anna", "Anna", future, now));
        // And the name guard still applies regardless of skew.
        assert!(!join_key_replayable("Anna", "Bernd", future, now));
    }
}
