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;

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

#[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 {
        let ip_ceiling = config::get_usize(&state.config_cache, "join_ip_rate_per_min", 60).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 = body.display_name.trim();
    let name_chars = display_name.chars().count();
    if name_chars == 0 || name_chars > 50 {
        return Err(AppError::BadRequest(
            "Name muss zwischen 1 und 50 Zeichen lang sein.".into(),
        ));
    }
    // Postgres rejects 0x00 in TEXT columns with a 500. Catch it here so callers
    // see a clean 400 instead of an internal error.
    if display_name.contains('\0') {
        return Err(AppError::BadRequest(
            "Name enthält ungültige Zeichen.".into(),
        ));
    }

    // 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?;

    // 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).await {
        Ok(u) => u,
        Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
            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,
        }),
    ))
}

#[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.
async fn verify_password(candidate: String, hash: String) -> bool {
    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> {
    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> {
    let display_name = body.display_name.trim();

    // Per-IP+name throttle BEFORE the per-user 3-strike counter. Without this
    // an attacker who knows a display name (they're visible on the feed) can
    // burn through 3 wrong PINs and lock the victim for 15 minutes — repeated
    // every 15 minutes, indefinitely. 5 attempts per 15 minutes per (IP, name)
    // softens that into a real cost.
    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),
            ));
        }

        let name_key = display_name.to_lowercase();
        if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("recover:{ip}:{name_key}"),
            5,
            Duration::from_secs(15 * 60),
        ) {
            return Err(AppError::TooManyRequests(
                "Zu viele Versuche. Bitte warte kurz und versuche es erneut.".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;
        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 — increment failure count
        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 >= 3 {
            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"
            );
        }
    }

    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,
}

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(),
        ));
    }

    // Throttle password attempts. The admin password is bcrypt-hashed (slow to
    // verify) but with no IP-level limit a determined attacker can still mount
    // a long-running guess campaign. 5 attempts / minute / IP is plenty for
    // honest typos.
    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;
    // Stays keyed by IP on purpose: this guards a single shared credential, so a per-user
    // or per-name key would just hand an attacker a fresh bucket per guess.
    if rate_limits_on
        && admin_rate_on
        && let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
            format!("admin_login:{ip}"),
            5,
            Duration::from_secs(60),
        )
    {
        return Err(AppError::TooManyRequests(
            "Zu viele Anmeldeversuche. Bitte warte kurz und versuche es erneut.".into(),
            Some(retry_after_secs),
        ));
    }

    let valid = verify_password(
        body.password.clone(),
        state.config.admin_password_hash.clone(),
    )
    .await;

    if !valid {
        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
    let admin_name = "Admin";
    let users = User::find_by_event_and_name(&state.pool, event.id, admin_name).await?;
    let admin_user = if let Some(u) = users.into_iter().find(|u| u.role == UserRole::Admin) {
        u
    } else {
        // Admin authenticates via password, but the schema still requires a PIN
        // hash. Generate a random unguessable PIN so the recovery path remains
        // unusable as an escalation route even if the role flag ever got 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.clone(), 4).await?;
        let user = User::create(&state.pool, event.id, admin_name, &dummy_hash).await?;
        sqlx::query("UPDATE \"user\" SET role = 'admin' WHERE id = $1")
            .bind(user.id)
            .execute(&state.pool)
            .await?;
        User::find_by_id(&state.pool, user.id)
            .await?
            .ok_or_else(|| AppError::Internal(anyhow::anyhow!("admin user creation failed")))?
    };

    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,
    }))
}

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 display_name = body.display_name.trim();
    let ip = client_ip(&headers, &peer.ip().to_string());
    let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
    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),
            ));
        }
    }
    if display_name.is_empty() {
        return Ok(StatusCode::NO_CONTENT);
    }

    // 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)
}
