use std::sync::Arc;

use axum::{
    extract::{Path, Query, State},
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
    Json,
};
use serde::Deserialize;
use tokio::sync::Mutex;

use room_protocol::{make_dm, make_message, Message};

use crate::{
    query::{has_narrowing_filter, QueryFilter},
    registry::UserRegistry,
};

use super::super::{
    auth::validate_token,
    service::{DispatchResult, RoomService},
    state::RoomState,
};
use super::{DaemonWsState, WsAppState};

// ── Shared helpers ──────────────────────────────────────────────────────

pub(super) fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
    headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
}

/// Convert a `route_and_dispatch` result into an axum HTTP response.
fn dispatch_to_response(result: anyhow::Result<DispatchResult>) -> axum::response::Response {
    match result {
        Ok(DispatchResult::Handled) => {
            (StatusCode::OK, Json(serde_json::json!({"type":"ok"}))).into_response()
        }
        Ok(DispatchResult::Reply(json)) => {
            let v: serde_json::Value =
                serde_json::from_str(&json).unwrap_or(serde_json::json!({"reply": json}));
            (StatusCode::OK, Json(v)).into_response()
        }
        Ok(DispatchResult::Shutdown) => {
            (StatusCode::OK, Json(serde_json::json!({"type":"shutdown"}))).into_response()
        }
        Ok(DispatchResult::Sent(seq_msg)) => {
            let json = serde_json::to_value(&seq_msg).unwrap_or_default();
            (StatusCode::OK, Json(json)).into_response()
        }
        Ok(DispatchResult::SendDenied(reason)) => (
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({"type":"error","code":"send_denied","message": reason})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"type":"error","code":"route_error","message": e.to_string()})),
        )
            .into_response(),
    }
}

/// Validate a bearer token against the room's local token map, then optionally
/// fall back to a global [`UserRegistry`] (daemon mode).
async fn validate_token_with_fallback(
    token: &str,
    room: &RoomState,
    registry: Option<&Arc<Mutex<UserRegistry>>>,
) -> Option<String> {
    if let Some(u) = room.validate_token(token).await {
        return Some(u);
    }
    if let Some(reg) = registry {
        let reg = reg.lock().await;
        return reg.validate_token(token).map(|u| u.to_owned());
    }
    None
}

/// Extract and validate a bearer token from headers, returning the username.
async fn require_auth(
    headers: &HeaderMap,
    room: &RoomState,
    registry: Option<&Arc<Mutex<UserRegistry>>>,
) -> Result<String, axum::response::Response> {
    let token = extract_bearer_token(headers).ok_or_else(|| {
        (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"type":"error","code":"missing_token"})),
        )
            .into_response()
    })?;

    validate_token_with_fallback(token, room, registry)
        .await
        .ok_or_else(|| {
            (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"type":"error","code":"invalid_token"})),
            )
                .into_response()
        })
}

// ── Request/query structs ───────────────────────────────────────────────

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

#[derive(Deserialize)]
pub(super) struct SendRequest {
    pub(super) content: String,
    pub(super) to: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct PollQuery {
    pub(super) since: Option<String>,
}

/// Query parameters for `GET /api/{room_id}/query`.
///
/// All fields are optional. Absent fields impose no constraint on that dimension.
#[derive(Deserialize)]
pub(super) struct QueryParams {
    /// Filter to messages from this user.
    pub(super) user: Option<String>,
    /// Maximum number of messages to return.
    pub(super) n: Option<usize>,
    /// Return only messages with seq strictly greater than this value.
    pub(super) since: Option<u64>,
    /// Return only messages with seq strictly less than this value.
    pub(super) before: Option<u64>,
    /// Substring search on message content (case-sensitive).
    pub(super) content: Option<String>,
    /// Regex search on message content.
    pub(super) regex: Option<String>,
    /// Only include messages that @mention this username.
    pub(super) mention: Option<String>,
    /// If `true`, exclude DirectMessage variants.
    pub(super) public: Option<bool>,
    /// If `true`, return messages oldest-first; otherwise newest-first.
    pub(super) asc: Option<bool>,
    /// ISO-8601 lower bound on message timestamp (exclusive).
    pub(super) after_ts: Option<String>,
    /// ISO-8601 upper bound on message timestamp (exclusive).
    pub(super) before_ts: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct CreateRoomRequest {
    pub(super) room_id: String,
    #[serde(default)]
    pub(super) visibility: Option<String>,
    #[serde(default)]
    pub(super) invite: Option<Vec<String>>,
}

// ── Query helpers ───────────────────────────────────────────────────────

/// Build a [`QueryFilter`] from REST query params, scoped to `room_id`.
///
/// Returns `Err` with a human-readable message if the `regex` param is invalid.
pub(super) fn build_query_filter(
    params: &QueryParams,
    room_id: &str,
) -> Result<QueryFilter, String> {
    let compiled_regex = params
        .regex
        .as_deref()
        .map(|pat| regex::Regex::new(pat).map_err(|e| format!("invalid regex pattern: {e}")))
        .transpose()?;

    Ok(QueryFilter {
        users: params
            .user
            .as_ref()
            .map(|u| vec![u.clone()])
            .unwrap_or_default(),
        limit: params.n,
        after_seq: params.since.map(|seq| (room_id.to_owned(), seq)),
        before_seq: params.before.map(|seq| (room_id.to_owned(), seq)),
        content_search: params.content.clone(),
        content_regex: compiled_regex,
        mention_user: params.mention.clone(),
        public_only: params.public.unwrap_or(false),
        ascending: params.asc.unwrap_or(false),
        after_ts: params.after_ts.as_deref().and_then(|s| s.parse().ok()),
        before_ts: params.before_ts.as_deref().and_then(|s| s.parse().ok()),
        ..QueryFilter::default()
    })
}

/// Apply a [`QueryFilter`] to a history, enforcing DM privacy.
///
/// Returns JSON-serialisable values, limited and ordered per filter settings.
pub(super) fn apply_query_filter(
    history: Vec<Message>,
    filter: &QueryFilter,
    room_id: &str,
    username: &str,
    host: Option<&str>,
) -> Vec<serde_json::Value> {
    let mut messages: Vec<serde_json::Value> = history
        .into_iter()
        .filter(|msg| {
            // Enforce DM privacy before running QueryFilter.
            if !msg.is_visible_to(username, host) {
                return false;
            }
            filter.matches(msg, room_id)
        })
        .filter_map(|msg| serde_json::to_value(&msg).ok())
        .collect();

    if !filter.ascending {
        messages.reverse();
    }

    if let Some(limit) = filter.limit {
        messages.truncate(limit);
    }

    messages
}

// ── Shared endpoint logic ───────────────────────────────────────────────

async fn join_inner(room: &RoomState, username: &str) -> axum::response::Response {
    if let Err(reason) = room.check_join(username) {
        return (
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({"type":"error","code":"join_denied","message": reason})),
        )
            .into_response();
    }
    match room.issue_token(username).await {
        Ok(token) => {
            let resp = serde_json::json!({
                "type":"token","token": token, "username": username
            });
            (StatusCode::OK, Json(resp)).into_response()
        }
        Err(_) => {
            let err = serde_json::json!({
                "type":"error","code":"username_taken","username": username
            });
            (StatusCode::CONFLICT, Json(err)).into_response()
        }
    }
}

async fn send_inner(
    room: &RoomState,
    username: &str,
    body: SendRequest,
) -> axum::response::Response {
    let msg = if let Some(ref to) = body.to {
        make_dm(room.room_id(), username, to, &body.content)
    } else {
        make_message(room.room_id(), username, &body.content)
    };

    dispatch_to_response(room.route_and_dispatch(msg, username).await)
}

async fn poll_inner(
    room: &RoomState,
    username: &str,
    query: PollQuery,
) -> axum::response::Response {
    let history = room.load_history().await;
    let host_name = room.host_name().await;

    // Filter messages after the `since` ID (stateless — no server-side cursor).
    let mut found_since = query.since.is_none();
    let messages: Vec<serde_json::Value> = history
        .into_iter()
        .filter(|msg| {
            if !found_since {
                if msg.id() == query.since.as_deref().unwrap_or_default() {
                    found_since = true;
                }
                return false;
            }
            msg.is_visible_to(username, host_name.as_deref())
        })
        .filter_map(|msg| serde_json::to_value(&msg).ok())
        .collect();

    (
        StatusCode::OK,
        Json(serde_json::json!({ "messages": messages })),
    )
        .into_response()
}

async fn query_inner(
    room: &RoomState,
    username: &str,
    room_id: &str,
    params: QueryParams,
) -> axum::response::Response {
    let filter = match build_query_filter(&params, room_id) {
        Ok(f) => f,
        Err(msg) => {
            return (
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({
                    "type": "error",
                    "code": "invalid_regex",
                    "message": msg
                })),
            )
                .into_response()
        }
    };

    // `public=true` alone is not a valid query — require at least one narrowing param.
    if filter.public_only && !has_narrowing_filter(&filter, false) {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({
                "type": "error",
                "code": "public_requires_filter",
                "message": "public=true requires at least one narrowing filter (user, content, regex, mention, since, before, n)"
            })),
        )
            .into_response();
    }

    let history = room.load_history().await;
    let host_name = room.host_name().await;
    let messages = apply_query_filter(history, &filter, room_id, username, host_name.as_deref());

    (
        StatusCode::OK,
        Json(serde_json::json!({ "messages": messages })),
    )
        .into_response()
}

fn room_not_found() -> axum::response::Response {
    (
        StatusCode::NOT_FOUND,
        Json(serde_json::json!({"type":"error","code":"room_not_found"})),
    )
        .into_response()
}

// ── Single-room REST endpoints ──────────────────────────────────────────

pub(super) async fn api_join(
    Path(room_id): Path<String>,
    State(state): State<WsAppState>,
    Json(body): Json<JoinRequest>,
) -> impl IntoResponse {
    let rs = &*state.room_state;
    if room_id != rs.room_id() {
        return room_not_found();
    }
    join_inner(rs, &body.username).await
}

pub(super) async fn api_send(
    Path(room_id): Path<String>,
    State(state): State<WsAppState>,
    headers: HeaderMap,
    Json(body): Json<SendRequest>,
) -> impl IntoResponse {
    let rs = &*state.room_state;
    if room_id != rs.room_id() {
        return room_not_found();
    }
    let username = match require_auth(&headers, rs, state.user_registry.as_ref()).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    send_inner(rs, &username, body).await
}

pub(super) async fn api_poll(
    Path(room_id): Path<String>,
    State(state): State<WsAppState>,
    headers: HeaderMap,
    Query(query): Query<PollQuery>,
) -> impl IntoResponse {
    let rs = &*state.room_state;
    if room_id != rs.room_id() {
        return room_not_found();
    }
    let username = match require_auth(&headers, rs, state.user_registry.as_ref()).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    poll_inner(rs, &username, query).await
}

pub(super) async fn api_query(
    Path(room_id): Path<String>,
    State(state): State<WsAppState>,
    headers: HeaderMap,
    Query(params): Query<QueryParams>,
) -> impl IntoResponse {
    let rs = &*state.room_state;
    if room_id != rs.room_id() {
        return room_not_found();
    }
    let username = match require_auth(&headers, rs, state.user_registry.as_ref()).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    query_inner(rs, &username, &room_id, params).await
}

pub(super) async fn api_health(State(state): State<WsAppState>) -> impl IntoResponse {
    let rs = &*state.room_state;
    let users = RoomService::status_count(rs).await;
    Json(serde_json::json!({
        "status": "ok",
        "room": rs.room_id(),
        "users": users,
        "version": env!("CARGO_PKG_VERSION"),
    }))
}

// ── Daemon REST endpoints ────────────────────────────────────────────────

pub(super) async fn daemon_api_join(
    Path(room_id): Path<String>,
    State(state): State<DaemonWsState>,
    Json(body): Json<JoinRequest>,
) -> impl IntoResponse {
    let room = match state.get_room(&room_id).await {
        Some(r) => r,
        None => return room_not_found(),
    };
    join_inner(&room, &body.username).await
}

pub(super) async fn daemon_api_send(
    Path(room_id): Path<String>,
    State(state): State<DaemonWsState>,
    headers: HeaderMap,
    Json(body): Json<SendRequest>,
) -> impl IntoResponse {
    let room = match state.get_room(&room_id).await {
        Some(r) => r,
        None => return room_not_found(),
    };
    let username = match require_auth(&headers, &room, Some(&state.user_registry)).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    send_inner(&room, &username, body).await
}

pub(super) async fn daemon_api_poll(
    Path(room_id): Path<String>,
    State(state): State<DaemonWsState>,
    headers: HeaderMap,
    Query(query): Query<PollQuery>,
) -> impl IntoResponse {
    let room = match state.get_room(&room_id).await {
        Some(r) => r,
        None => return room_not_found(),
    };
    let username = match require_auth(&headers, &room, Some(&state.user_registry)).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    poll_inner(&room, &username, query).await
}

pub(super) async fn daemon_api_query(
    Path(room_id): Path<String>,
    State(state): State<DaemonWsState>,
    headers: HeaderMap,
    Query(params): Query<QueryParams>,
) -> impl IntoResponse {
    let room = match state.get_room(&room_id).await {
        Some(r) => r,
        None => return room_not_found(),
    };
    let username = match require_auth(&headers, &room, Some(&state.user_registry)).await {
        Ok(u) => u,
        Err(resp) => return resp,
    };
    query_inner(&room, &username, &room_id, params).await
}

pub(super) async fn daemon_api_health(State(state): State<DaemonWsState>) -> impl IntoResponse {
    let rooms = state.rooms.lock().await;
    let mut room_info = Vec::new();
    for (id, rs) in rooms.iter() {
        let users = RoomService::status_count(&**rs).await;
        room_info.push(serde_json::json!({
            "room": id,
            "users": users,
        }));
    }
    Json(serde_json::json!({
        "status": "ok",
        "rooms": room_info,
        "version": env!("CARGO_PKG_VERSION"),
    }))
}

pub(super) async fn daemon_api_rooms(State(state): State<DaemonWsState>) -> impl IntoResponse {
    let rooms = state.rooms.lock().await;
    let ids: Vec<&String> = rooms.keys().collect();
    Json(serde_json::json!({
        "rooms": ids,
    }))
}

pub(super) async fn daemon_api_users(State(state): State<DaemonWsState>) -> impl IntoResponse {
    let guard = state.user_registry.lock().await;
    let mut names: Vec<String> = guard
        .list_users()
        .iter()
        .map(|u| u.username.clone())
        .collect();
    names.sort();
    Json(serde_json::json!({
        "users": names,
    }))
}

pub(super) async fn daemon_api_create_room(
    State(state): State<DaemonWsState>,
    headers: HeaderMap,
    Json(body): Json<CreateRoomRequest>,
) -> impl IntoResponse {
    let token = match extract_bearer_token(&headers) {
        Some(t) => t,
        None => {
            return (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({"type":"error","code":"missing_token"})),
            )
                .into_response()
        }
    };

    // Validate token against the shared token map.
    if validate_token(token, &state.system_token_map)
        .await
        .is_none()
    {
        return (
            StatusCode::UNAUTHORIZED,
            Json(serde_json::json!({"type":"error","code":"invalid_token"})),
        )
            .into_response();
    }

    let visibility_str = body.visibility.as_deref().unwrap_or("public");
    let invite = body.invite.unwrap_or_default();

    let room_config = match visibility_str {
        "public" => room_protocol::RoomConfig {
            visibility: room_protocol::RoomVisibility::Public,
            max_members: None,
            invite_list: invite.into_iter().collect(),
            created_by: "system".to_owned(),
            created_at: chrono::Utc::now().to_rfc3339(),
        },
        "private" => room_protocol::RoomConfig {
            visibility: room_protocol::RoomVisibility::Private,
            max_members: None,
            invite_list: invite.into_iter().collect(),
            created_by: "system".to_owned(),
            created_at: chrono::Utc::now().to_rfc3339(),
        },
        "dm" => {
            if invite.len() != 2 {
                return (
                    StatusCode::BAD_REQUEST,
                    Json(serde_json::json!({
                        "type": "error",
                        "code": "invalid_config",
                        "message": "dm visibility requires exactly 2 users in invite list"
                    })),
                )
                    .into_response();
            }
            room_protocol::RoomConfig::dm(&invite[0], &invite[1])
        }
        other => {
            return (
                StatusCode::BAD_REQUEST,
                Json(serde_json::json!({
                    "type": "error",
                    "code": "invalid_config",
                    "message": format!("unknown visibility: {other}")
                })),
            )
                .into_response();
        }
    };

    match super::super::daemon::create_room_entry(
        &body.room_id,
        Some(room_config),
        &state.rooms,
        &state.config,
        &state.system_token_map,
        Some(state.user_registry.clone()),
    )
    .await
    {
        Ok(()) => (
            StatusCode::CREATED,
            Json(serde_json::json!({"type":"room_created","room": body.room_id})),
        )
            .into_response(),
        Err(e) if e.contains("room already exists") => (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"type":"error","code":"room_exists","message": e})),
        )
            .into_response(),
        Err(e) => (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({"type":"error","code":"invalid_room_id","message": e})),
        )
            .into_response(),
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::{HeaderMap, HeaderValue, StatusCode};
    use std::{collections::HashMap, sync::Arc};
    use tempfile::TempDir;
    use tokio::sync::Mutex;

    use crate::broker::service::DispatchResult;
    use room_protocol::{make_dm, make_message};

    fn make_state(dir: &TempDir) -> Arc<RoomState> {
        let chat = dir.path().join("chat.ndjson");
        let token_map_path = dir.path().join("room.tokens");
        let sub_map_path = dir.path().join("room.subscriptions");
        RoomState::new(
            "test-room".to_owned(),
            chat,
            token_map_path,
            sub_map_path,
            Arc::new(Mutex::new(HashMap::new())),
            Arc::new(Mutex::new(HashMap::new())),
            None,
        )
        .unwrap()
    }

    fn empty_params() -> QueryParams {
        QueryParams {
            user: None,
            n: None,
            since: None,
            before: None,
            content: None,
            regex: None,
            mention: None,
            public: None,
            asc: None,
            after_ts: None,
            before_ts: None,
        }
    }

    // ── extract_bearer_token ─────────────────────────────────────────────────

    #[test]
    fn extract_bearer_token_valid_header() {
        let mut headers = HeaderMap::new();
        headers.insert("authorization", HeaderValue::from_static("Bearer tok-abc"));
        assert_eq!(extract_bearer_token(&headers), Some("tok-abc"));
    }

    #[test]
    fn extract_bearer_token_missing_header() {
        let headers = HeaderMap::new();
        assert_eq!(extract_bearer_token(&headers), None);
    }

    #[test]
    fn extract_bearer_token_wrong_scheme() {
        let mut headers = HeaderMap::new();
        headers.insert("authorization", HeaderValue::from_static("Basic abc123"));
        assert_eq!(extract_bearer_token(&headers), None);
    }

    #[test]
    fn extract_bearer_token_empty_value_after_bearer() {
        let mut headers = HeaderMap::new();
        headers.insert("authorization", HeaderValue::from_static("Bearer "));
        assert_eq!(
            extract_bearer_token(&headers),
            Some(""),
            "strip_prefix('Bearer ') yields empty string — callers must handle"
        );
    }

    // ── build_query_filter ───────────────────────────────────────────────────

    #[test]
    fn build_query_filter_maps_all_params() {
        let params = QueryParams {
            user: Some("alice".to_owned()),
            n: Some(10),
            since: Some(5),
            before: Some(20),
            content: Some("hello".to_owned()),
            regex: Some(r"\d+".to_owned()),
            mention: Some("bob".to_owned()),
            public: Some(true),
            asc: Some(true),
            after_ts: None,
            before_ts: None,
        };
        let filter = build_query_filter(&params, "myroom").unwrap();
        assert_eq!(filter.users, vec!["alice"]);
        assert_eq!(filter.limit, Some(10));
        assert_eq!(filter.after_seq, Some(("myroom".to_owned(), 5)));
        assert_eq!(filter.before_seq, Some(("myroom".to_owned(), 20)));
        assert_eq!(filter.content_search.as_deref(), Some("hello"));
        assert!(filter.content_regex.is_some());
        assert_eq!(filter.mention_user.as_deref(), Some("bob"));
        assert!(filter.public_only);
        assert!(filter.ascending);
    }

    #[test]
    fn build_query_filter_invalid_regex_returns_err() {
        let params = QueryParams {
            regex: Some("[invalid".to_owned()),
            ..empty_params()
        };
        let result = build_query_filter(&params, "r");
        assert!(result.is_err());
        assert!(
            result.unwrap_err().contains("invalid regex"),
            "error should mention invalid regex"
        );
    }

    #[test]
    fn build_query_filter_defaults_for_empty_params() {
        let filter = build_query_filter(&empty_params(), "r").unwrap();
        assert!(filter.users.is_empty());
        assert!(filter.limit.is_none());
        assert!(filter.after_seq.is_none());
        assert!(filter.before_seq.is_none());
        assert!(filter.content_search.is_none());
        assert!(filter.content_regex.is_none());
        assert!(filter.mention_user.is_none());
        assert!(!filter.public_only);
        assert!(!filter.ascending);
    }

    // ── apply_query_filter ───────────────────────────────────────────────────

    #[test]
    fn apply_query_filter_empty_history_returns_empty() {
        let filter = QueryFilter::default();
        let result = apply_query_filter(vec![], &filter, "r", "alice", None);
        assert!(result.is_empty());
    }

    #[test]
    fn apply_query_filter_respects_limit() {
        let msgs: Vec<Message> = (0..5)
            .map(|i| make_message("r", "alice", &format!("msg-{i}")))
            .collect();
        let filter = QueryFilter {
            limit: Some(2),
            ..QueryFilter::default()
        };
        let result = apply_query_filter(msgs, &filter, "r", "alice", None);
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn apply_query_filter_dm_hidden_from_non_participant() {
        let public_msg = make_message("r", "alice", "hello everyone");
        let dm = make_dm("r", "alice", "bob", "secret");
        let history = vec![public_msg, dm];
        let filter = QueryFilter::default();
        let result = apply_query_filter(history, &filter, "r", "eve", None);
        assert_eq!(result.len(), 1, "eve should only see the public message");
        assert_eq!(result[0]["content"], "hello everyone");
    }

    // ── dispatch_to_response ─────────────────────────────────────────────────

    #[test]
    fn dispatch_to_response_handled_returns_200() {
        let resp = dispatch_to_response(Ok(DispatchResult::Handled));
        assert_eq!(resp.status(), StatusCode::OK);
    }

    #[test]
    fn dispatch_to_response_send_denied_returns_403() {
        let resp = dispatch_to_response(Ok(DispatchResult::SendDenied("not allowed".to_owned())));
        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
    }

    #[test]
    fn dispatch_to_response_error_returns_500() {
        let resp = dispatch_to_response(Err(anyhow::anyhow!("boom")));
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
    }

    // ── require_auth ─────────────────────────────────────────────────────────

    #[tokio::test]
    async fn require_auth_missing_header_returns_unauthorized() {
        let dir = TempDir::new().unwrap();
        let state = make_state(&dir);
        let headers = HeaderMap::new();
        let result = require_auth(&headers, &state, None).await;
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_invalid_token_returns_unauthorized() {
        let dir = TempDir::new().unwrap();
        let state = make_state(&dir);
        let mut headers = HeaderMap::new();
        headers.insert(
            "authorization",
            HeaderValue::from_static("Bearer bad-token"),
        );
        let result = require_auth(&headers, &state, None).await;
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn require_auth_valid_token_returns_username() {
        let dir = TempDir::new().unwrap();
        let state = make_state(&dir);
        let token = state.issue_token("alice").await.unwrap();
        let mut headers = HeaderMap::new();
        headers.insert(
            "authorization",
            HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
        );
        let result = require_auth(&headers, &state, None).await;
        assert_eq!(result.unwrap(), "alice");
    }
}
