use std::sync::Arc;

use axum::Json;
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::{StatusCode, header, request::Parts};
use axum::response::{IntoResponse, Response};
use rand::Rng;
use sha2::{Sha256, Digest};
use uuid::Uuid;

use crate::db;
use crate::state::AppState;

// -- auth extractor --

#[allow(dead_code)]
pub struct ApiAuth {
    pub session_token: String,
    pub permissions: String,
    pub is_api_key: bool,
}

impl ApiAuth {
    pub fn can(&self, perm: &str) -> bool {
        self.permissions.contains(perm) || self.permissions.contains("admin")
    }
}

impl FromRequestParts<Arc<AppState>> for ApiAuth {
    type Rejection = Response;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &Arc<AppState>,
    ) -> Result<Self, Self::Rejection> {
        // check Authorization header first
        if let Some(auth) = parts.headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) {
            if let Some(token) = auth.strip_prefix("Bearer ") {
                if token.starts_with("cv_") {
                    let hash = hex::encode(Sha256::digest(token.as_bytes()));
                    let dbi = state.db.lock().await;
                    if let Some((id, session_token, permissions, rate_limit, is_active, expires_at)) =
                        db::get_api_key_by_hash(&dbi.db, &hash)
                    {
                        if is_active == 0 {
                            return Err(api_err(StatusCode::UNAUTHORIZED, "key_disabled", "API key is disabled"));
                        }
                        if let Some(exp) = expires_at {
                            let now = chrono::Utc::now().to_rfc3339();
                            if now > exp {
                                return Err(api_err(StatusCode::UNAUTHORIZED, "key_expired", "API key has expired"));
                            }
                        }
                        // rate limit
                        let rl = rate_limit as u32;
                        if let Err(_) = crate::security::check_rate_limit(&id, "api", rl, 60) {
                            return Err(api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded"));
                        }
                        if db::is_banned(&dbi.db, &session_token) {
                            return Err(api_err(StatusCode::FORBIDDEN, "suspended", "account suspended"));
                        }
                        db::touch_api_key(&dbi.db, &id);
                        return Ok(ApiAuth { session_token, permissions, is_api_key: true });
                    }
                    return Err(api_err(StatusCode::UNAUTHORIZED, "invalid_key", "invalid API key"));
                }
            }
        }

        // fall back to session cookie
        let token = parts.headers.get(header::COOKIE)
            .and_then(|v| v.to_str().ok())
            .and_then(|cookies| {
                cookies.split(';')
                    .map(|c| c.trim())
                    .find(|c| c.starts_with("cruciverb_session="))
                    .map(|c| c["cruciverb_session=".len()..].to_string())
            });

        match token {
            Some(t) if !t.is_empty() => {
                let dbi = state.db.lock().await;
                let (tok, _) = db::get_or_create_session(&dbi.db, &t);
                if db::is_banned(&dbi.db, &tok) {
                    return Err(api_err(StatusCode::FORBIDDEN, "suspended", "account suspended"));
                }
                Ok(ApiAuth { session_token: tok, permissions: "read,write,generate".into(), is_api_key: false })
            }
            _ => Err(api_err(StatusCode::UNAUTHORIZED, "no_auth", "provide an API key via Authorization header or a session cookie")),
        }
    }
}

// -- response helpers --

fn api_ok(data: serde_json::Value) -> Response {
    Json(serde_json::json!({
        "data": data,
        "meta": { "timestamp": chrono::Utc::now().to_rfc3339() }
    })).into_response()
}

fn api_err(code: StatusCode, error_code: &str, message: &str) -> Response {
    (code, Json(serde_json::json!({
        "error": { "code": error_code, "message": message },
        "meta": { "timestamp": chrono::Utc::now().to_rfc3339() }
    }))).into_response()
}

// -- puzzle endpoints --

pub async fn v1_generate(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
    Json(req): Json<crate::api::GenerateRequest>,
) -> Response {
    if !auth.can("generate") {
        return api_err(StatusCode::FORBIDDEN, "forbidden", "generate permission required");
    }
    if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "generate", 5, 60) {
        return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded");
    }

    let _permit = match state.generation_semaphore.try_acquire() {
        Ok(p) => p,
        Err(_) => return api_err(StatusCode::SERVICE_UNAVAILABLE, "busy", "server busy - try again shortly"),
    };

    let clue_db = state.clue_db.clone();
    let word_diffs = state.word_diffs.clone();

    let (difficulty_level, config) = parse_gen_config(&req);
    let mut config = config;
    if let Some(seed) = req.seed { config.seed = Some(seed); }

    let mut dict = (*state.dict).clone();
    let is_cryptic = req.cryptic.unwrap_or(false);
    if !is_cryptic {
        dict.apply_difficulty(&word_diffs, difficulty_level);
        let wq = state.word_quality.read().await.clone();
        if !wq.is_empty() { dict.apply_quality(&wq); }
    }

    if let Some(ref pack_id) = req.pack {
        let dbi = state.db.lock().await;
        if let Some(pack) = db::get_pack(&dbi.db, pack_id) {
            let pack_json: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default();
            if let Some(words) = pack_json.get("words").and_then(|w| w.as_array()) {
                for w in words {
                    let word = w.get("word").and_then(|w| w.as_str()).unwrap_or("").to_uppercase();
                    if !word.is_empty() { dict.boost_word(&word, 2.0); }
                }
            }
            db::increment_pack_downloads(&dbi.db, pack_id);
        }
    }

    let theme_name = req.theme.clone();
    let theme_words = theme_name.as_ref().and_then(|t| load_theme_words(t));
    if let Some(ref tw) = theme_words {
        dict.boost_theme(tw);
    }

    config.staleness = state.build_staleness_penalties().await;

    let tw = theme_words.clone();
    let tn = theme_name.clone();
    let result = tokio::task::spawn_blocking(move || {
        cruciverb_core::puzzle::generate_themed_puzzle(
            &config, &dict, &clue_db, difficulty_level,
            tn.as_deref(), tw.as_ref(),
        )
    }).await;

    let puzzle = match result {
        Ok(Ok(p)) => p,
        Ok(Err(e)) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "generation_failed", &e.to_string()),
        Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "task_error", &e.to_string()),
    };

    let mut puzzle = puzzle;
    if is_cryptic {
        if let Some(ref cdb) = state.cryptic_db {
            puzzle.apply_cryptic_clues(cdb);
        }
    }

    let used_words: Vec<String> = puzzle.words.iter().map(|w| w.word.clone()).collect();
    state.record_puzzle_words(&used_words).await;

    let id = Uuid::new_v4();
    let puzzle_json = match puzzle.to_json() {
        Ok(j) => j,
        Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "serialize_error", &e.to_string()),
    };

    let content_hash = hex::encode(Sha256::digest(puzzle_json.as_bytes()));
    let short_id = {
        let dbi = state.db.lock().await;
        let short_id = db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json,
            puzzle.width, puzzle.height, difficulty_level, None, req.difficulty.as_deref());
        db::store_puzzle_hash(&dbi.db, &id.to_string(), &content_hash);
        short_id
    };

    let client = crate::api::to_client_puzzle(&puzzle, id);
    state.cache_puzzle(id, puzzle).await;

    api_ok(serde_json::json!({
        "id": id.to_string(),
        "short_id": short_id,
        "puzzle": client,
    }))
}

pub async fn v1_get_puzzle(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    _auth: ApiAuth,
) -> Response {
    let uuid = match Uuid::parse_str(&id) {
        Ok(u) => u,
        Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"),
    };

    // try in-memory
    let puzzles = state.puzzles.read().await;
    if let Some((puzzle, _)) = puzzles.get(&uuid) {
        let short_id = {
            let dbi = state.db.lock().await;
            db::get_puzzle_json(&dbi.db, &id).map(|(_, s)| s).unwrap_or_default()
        };
        return api_ok(serde_json::json!({
            "id": id, "short_id": short_id,
            "puzzle": crate::api::to_client_puzzle(puzzle, uuid),
        }));
    }
    drop(puzzles);

    // try database
    let dbi = state.db.lock().await;
    let (puzzle_json, short_id) = match db::get_puzzle_json(&dbi.db, &id) {
        Some(v) => v,
        None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found"),
    };
    let puzzle: cruciverb_core::puzzle::Puzzle = match serde_json::from_str(&puzzle_json) {
        Ok(p) => p,
        Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string()),
    };
    drop(dbi);

    let client = crate::api::to_client_puzzle(&puzzle, uuid);
    state.cache_puzzle(uuid, puzzle).await;

    api_ok(serde_json::json!({
        "id": id, "short_id": short_id, "puzzle": client,
    }))
}

pub async fn v1_get_solution(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    auth: ApiAuth,
) -> Response {
    if !auth.can("generate") {
        return api_err(StatusCode::FORBIDDEN, "forbidden", "generate permission required");
    }
    let uuid = match Uuid::parse_str(&id) {
        Ok(u) => u,
        Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"),
    };

    let puzzle = match load_puzzle_inner(&state, &id, uuid).await {
        Ok(p) => p,
        Err(r) => return r,
    };

    let solution: Vec<Vec<Option<String>>> = (0..puzzle.height).map(|r| {
        (0..puzzle.width).map(|c| puzzle.grid.get(r, c).letter().map(|ch| ch.to_string())).collect()
    }).collect();

    api_ok(serde_json::json!({
        "id": id, "solution": solution,
    }))
}

pub async fn v1_validate(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    auth: ApiAuth,
    Json(req): Json<crate::api::ValidateRequest>,
) -> Response {
    let uuid = match Uuid::parse_str(&id) {
        Ok(u) => u,
        Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"),
    };

    let puzzles = state.puzzles.read().await;
    let (puzzle, _) = match puzzles.get(&uuid) {
        Some(p) => p,
        None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found - fetch it first"),
    };

    let mut all_correct = true;
    let mut cell_results = Vec::with_capacity(puzzle.height);
    for r in 0..puzzle.height {
        let mut row = Vec::with_capacity(puzzle.width);
        for c in 0..puzzle.width {
            let actual = puzzle.grid.get(r, c).letter();
            let submitted = req.cells.get(r)
                .and_then(|rw| rw.get(c))
                .and_then(|cell| cell.as_ref())
                .and_then(|s| s.chars().next())
                .filter(|c| c.is_alphabetic())
                .map(|c| c.to_ascii_uppercase());
            let result = match (actual, submitted) {
                (Some(a), Some(s)) if a == s => "correct",
                (Some(_), Some(_)) => { all_correct = false; "incorrect" }
                (Some(_), None) => { all_correct = false; "empty" }
                _ => "black",
            };
            row.push(result.to_string());
        }
        cell_results.push(row);
    }

    if all_correct {
        let dbi = state.db.lock().await;
        if db::has_solve_record(&dbi.db, &auth.session_token, &id) {
            return api_ok(serde_json::json!({
                "correct": true,
                "cells": cell_results,
            }));
        }
        let server_hints = db::get_hint_count(&dbi.db, &id, &auth.session_token);
        let elapsed = req.elapsed.unwrap_or(0);
        let min_time: i64 = match db::get_puzzle_daily_info(&dbi.db, &id) {
            Some((_, preset)) => match preset.as_str() {
                "glyph" => 3,
                "rune" => 5,
                "scroll" => 15,
                "codex" => 30,
                "grimoire" => 60,
                _ => 3,
            },
            None => 3,
        };
        let flagged = elapsed < min_time;
        // anonymizeTimes: round stored solve times to the minute
        let settings: serde_json::Value =
            serde_json::from_str(&db::get_settings(&dbi.db, &auth.session_token)).unwrap_or_default();
        let elapsed = if settings.get("anonymizeTimes").and_then(|v| v.as_bool()).unwrap_or(false) {
            (elapsed / 60) * 60
        } else {
            elapsed
        };
        db::record_solve(&dbi.db, &auth.session_token, &id, elapsed, server_hints, flagged, None);
        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
        db::update_streak(&dbi.db, &auth.session_token, &today);
    }

    api_ok(serde_json::json!({
        "correct": all_correct,
        "cells": cell_results,
    }))
}

pub async fn v1_hint(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    _auth: ApiAuth,
    Json(req): Json<crate::api::HintRequest>,
) -> Response {
    let uuid = match Uuid::parse_str(&id) {
        Ok(u) => u,
        Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"),
    };

    let puzzles = state.puzzles.read().await;
    let (puzzle, _) = match puzzles.get(&uuid) {
        Some(p) => p,
        None => return api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found - fetch it first"),
    };

    match req.kind.as_str() {
        "check_cell" => {
            let actual = puzzle.grid.get(req.row, req.col).letter();
            let submitted = req.letter.as_ref()
                .and_then(|s| s.chars().next())
                .map(|c| c.to_ascii_uppercase());
            api_ok(serde_json::json!({ "is_correct": actual == submitted }))
        }
        "reveal_letter" => {
            match puzzle.grid.get(req.row, req.col).letter() {
                Some(ch) => api_ok(serde_json::json!({ "row": req.row, "col": req.col, "letter": ch.to_string() })),
                None => api_err(StatusCode::BAD_REQUEST, "not_letter", "not a letter cell"),
            }
        }
        "reveal_word" => {
            let dir = match req.direction.as_deref() {
                Some("down") => cruciverb_core::grid::slot::Direction::Down,
                _ => cruciverb_core::grid::slot::Direction::Across,
            };
            let word = puzzle.words.iter().find(|w| {
                w.direction == dir && word_contains(w, req.row, req.col)
            });
            match word {
                Some(w) => {
                    let letters: Vec<serde_json::Value> = w.word.chars().enumerate().map(|(i, ch)| {
                        let (r, c) = match w.direction {
                            cruciverb_core::grid::slot::Direction::Across => (w.row, w.col + i),
                            cruciverb_core::grid::slot::Direction::Down => (w.row + i, w.col),
                        };
                        serde_json::json!({ "row": r, "col": c, "letter": ch.to_string() })
                    }).collect();
                    api_ok(serde_json::json!({ "letters": letters }))
                }
                None => api_err(StatusCode::BAD_REQUEST, "no_word", "no word at that position"),
            }
        }
        _ => api_err(StatusCode::BAD_REQUEST, "invalid_kind", "unknown hint kind"),
    }
}

pub async fn v1_export(
    State(state): State<Arc<AppState>>,
    Path((id, fmt)): Path<(String, String)>,
    auth: ApiAuth,
) -> Response {
    if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "export", 10, 60) {
        return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded");
    }

    let uuid = match Uuid::parse_str(&id) {
        Ok(u) => u,
        Err(_) => return api_err(StatusCode::BAD_REQUEST, "invalid_id", "invalid puzzle id"),
    };

    let puzzle = match load_puzzle_inner(&state, &id, uuid).await {
        Ok(p) => p,
        Err(r) => return r,
    };

    match fmt.as_str() {
        "puz" => {
            let data = cruciverb_core::export::puz::to_puz(&puzzle, "cruciverb puzzle", "cruciverb");
            ([(header::CONTENT_TYPE, "application/octet-stream"),
              (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.puz\"")], data).into_response()
        }
        "ipuz" => {
            let data = cruciverb_core::export::ipuz::to_ipuz(&puzzle, "cruciverb puzzle");
            ([(header::CONTENT_TYPE, "application/json"),
              (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.ipuz\"")],
             serde_json::to_string_pretty(&data).unwrap_or_default()).into_response()
        }
        "pdf" => {
            let data = cruciverb_core::export::pdf::to_pdf(&puzzle, "cruciverb puzzle", "puzzle");
            ([(header::CONTENT_TYPE, "application/pdf"),
              (header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.pdf\"")], data).into_response()
        }
        "json" => {
            let client = crate::api::to_client_puzzle(&puzzle, uuid);
            api_ok(serde_json::json!({ "puzzle": client }))
        }
        _ => api_err(StatusCode::BAD_REQUEST, "invalid_format", "format must be puz, ipuz, pdf, or json"),
    }
}

// -- daily endpoints --

pub async fn v1_get_daily(
    State(state): State<Arc<AppState>>,
    Path(date): Path<String>,
    auth: ApiAuth,
) -> Response {
    v1_daily_inner(state, &auth, &date, "scroll").await
}

pub async fn v1_get_daily_preset(
    State(state): State<Arc<AppState>>,
    Path((date, preset)): Path<(String, String)>,
    auth: ApiAuth,
) -> Response {
    v1_daily_inner(state, &auth, &date, &preset).await
}

async fn v1_daily_inner(state: Arc<AppState>, _auth: &ApiAuth, date: &str, preset: &str) -> Response {
    // check if already generated
    {
        let dbi = state.db.lock().await;
        if let Some((id, short_id, puzzle_json)) = db::get_daily_puzzle(&dbi.db, date, preset) {
            let puzzle: cruciverb_core::puzzle::Puzzle = match serde_json::from_str(&puzzle_json) {
                Ok(p) => p,
                Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string()),
            };
            let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4());
            let client = crate::api::to_client_puzzle(&puzzle, uuid);
            state.cache_puzzle(uuid, puzzle).await;
            return api_ok(serde_json::json!({
                "id": id, "short_id": short_id, "puzzle": client,
            }));
        }
    }

    let _permit = match state.generation_semaphore.try_acquire() {
        Ok(p) => p,
        Err(_) => return api_err(StatusCode::SERVICE_UNAVAILABLE, "busy", "server busy - try again shortly"),
    };

    let seed = crate::routes::daily_seed(date, preset, &state.daily_secret);
    let (diff, mut config) = match preset {
        "glyph" => (0u8, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Glyph)),
        "rune" => (1, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Rune)),
        "scroll" => (2, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Scroll)),
        "codex" => (3, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Codex)),
        "grimoire" => (3, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Grimoire)),
        _ => (2, cruciverb_core::config::GenerationConfig::from_preset(cruciverb_core::config::DifficultyPreset::Scroll)),
    };
    config.seed = Some(seed);

    let clue_db = state.clue_db.clone();
    let word_diffs = state.word_diffs.clone();
    let mut dict = (*state.dict).clone();
    dict.apply_difficulty(&word_diffs, diff);
    let wq = state.word_quality.read().await.clone();
    if !wq.is_empty() { dict.apply_quality(&wq); }

    let result = tokio::task::spawn_blocking(move || {
        cruciverb_core::puzzle::generate_full_puzzle(&config, &dict, &clue_db, diff)
    }).await;

    let puzzle = match result {
        Ok(Ok(p)) => p,
        Ok(Err(e)) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "generation_failed", &e.to_string()),
        Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "task_error", &e.to_string()),
    };

    let id = Uuid::new_v4();
    let puzzle_json = match puzzle.to_json() {
        Ok(j) => j,
        Err(e) => return api_err(StatusCode::INTERNAL_SERVER_ERROR, "serialize_error", &e.to_string()),
    };

    let short_id = {
        let dbi = state.db.lock().await;
        db::store_puzzle(&dbi.db, &id.to_string(), &puzzle_json,
            puzzle.width, puzzle.height, diff, Some(date), Some(preset))
    };

    let client = crate::api::to_client_puzzle(&puzzle, id);
    state.cache_puzzle(id, puzzle).await;

    api_ok(serde_json::json!({
        "id": id.to_string(), "short_id": short_id, "puzzle": client,
    }))
}

// -- community endpoints --

pub async fn v1_community_puzzles(
    State(state): State<Arc<AppState>>,
    _auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let puzzles = db::list_published_puzzles(&dbi.db, 50, 0);
    let result: Vec<serde_json::Value> = puzzles.iter().map(|(id, title, author, w, h, updated, origin)| {
        serde_json::json!({
            "id": id, "title": title, "author": author,
            "width": w, "height": h, "updated_at": updated,
            "origin": origin,
        })
    }).collect();
    api_ok(serde_json::json!({ "puzzles": result }))
}

pub async fn v1_community_clues(
    State(state): State<Arc<AppState>>,
    Path(word): Path<String>,
    _auth: ApiAuth,
) -> Response {
    if !word.chars().all(|c| c.is_alphabetic()) {
        return api_err(StatusCode::BAD_REQUEST, "invalid_word", "word must contain only letters");
    }
    let dbi = state.db.lock().await;
    let clues = db::get_community_clues(&dbi.db, &word);
    let result: Vec<serde_json::Value> = clues.iter().map(|(id, text, up, down, status, created, origin)| {
        serde_json::json!({
            "id": id, "clue": text, "upvotes": up, "downvotes": down,
            "status": status, "created_at": created, "origin_domain": origin,
        })
    }).collect();
    api_ok(serde_json::json!({ "clues": result }))
}

pub async fn v1_submit_clue(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
    Json(req): Json<serde_json::Value>,
) -> Response {
    if !auth.can("write") {
        return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required");
    }
    if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "submit_clue", 10, 60) {
        return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded");
    }

    let word = match req.get("word").and_then(|v| v.as_str()) {
        Some(w) => w,
        None => return api_err(StatusCode::BAD_REQUEST, "missing_field", "missing word"),
    };
    let clue_text = match req.get("clue").and_then(|v| v.as_str()) {
        Some(c) => c,
        None => return api_err(StatusCode::BAD_REQUEST, "missing_field", "missing clue"),
    };

    if word.is_empty() || clue_text.is_empty() {
        return api_err(StatusCode::BAD_REQUEST, "empty_field", "word and clue must not be empty");
    }
    if !word.chars().all(|c| c.is_alphabetic()) {
        return api_err(StatusCode::BAD_REQUEST, "invalid_word", "word must contain only letters");
    }
    if clue_text.len() > 500 {
        return api_err(StatusCode::BAD_REQUEST, "too_long", "clue too long (max 500 characters)");
    }
    if db::text_contains_url(clue_text) {
        return api_err(StatusCode::BAD_REQUEST, "url_forbidden", "clues cannot contain URLs");
    }

    let clean = crate::security::sanitize_html(clue_text);
    if state.blocklist.read().unwrap().contains_blocked(&clean) {
        return api_err(StatusCode::BAD_REQUEST, "inappropriate", "clue contains inappropriate content");
    }
    let dbi = state.db.lock().await;
    let id = db::submit_clue(&dbi.db, word, &clean, &auth.session_token);
    api_ok(serde_json::json!({ "id": id }))
}

pub async fn v1_vote_clue(
    State(state): State<Arc<AppState>>,
    Path(id): Path<i64>,
    auth: ApiAuth,
    Json(req): Json<serde_json::Value>,
) -> Response {
    if !auth.can("write") {
        return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required");
    }
    if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "vote_clue", 30, 60) {
        return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded");
    }

    let vote = match req.get("vote").and_then(|v| v.as_i64()) {
        Some(v) if v == 1 || v == -1 => v as i32,
        _ => return api_err(StatusCode::BAD_REQUEST, "invalid_vote", "vote must be 1 or -1"),
    };

    let dbi = state.db.lock().await;
    db::vote_clue(&dbi.db, &auth.session_token, id, vote);
    api_ok(serde_json::json!({ "ok": true }))
}

// -- packs --

pub async fn v1_list_packs(
    State(state): State<Arc<AppState>>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    _auth: ApiAuth,
) -> Response {
    let search = params.get("search").map(|s| s.as_str());
    let limit: usize = params.get("limit").and_then(|l| l.parse().ok()).unwrap_or(50);
    let dbi = state.db.lock().await;
    let rows = db::list_packs(&dbi.db, search, limit);
    let packs: Vec<serde_json::Value> = rows.into_iter().map(|(id, name, desc, author, tags, wc, dl, avg, rc, created)| {
        let tag_list: Vec<&str> = tags.as_deref().unwrap_or("").split(',').filter(|s| !s.is_empty()).collect();
        serde_json::json!({
            "id": id, "name": name, "description": desc,
            "author": author, "tags": tag_list, "word_count": wc,
            "downloads": dl, "rating": avg, "rating_count": rc,
            "created_at": created,
        })
    }).collect();
    api_ok(serde_json::json!({ "packs": packs }))
}

pub async fn v1_get_pack(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    _auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let pack = match db::get_pack(&dbi.db, &id) {
        Some(p) => p,
        None => return api_err(StatusCode::NOT_FOUND, "not_found", "pack not found"),
    };
    let tag_list: Vec<&str> = pack.tags.as_deref().unwrap_or("").split(',').filter(|s| !s.is_empty()).collect();
    let parsed: serde_json::Value = serde_json::from_str(&pack.pack_data).unwrap_or_default();
    api_ok(serde_json::json!({
        "id": pack.id, "name": pack.name, "description": pack.description,
        "author": pack.author_name, "tags": tag_list, "word_count": pack.word_count,
        "downloads": pack.download_count, "rating": pack.rating_avg, "rating_count": pack.rating_count,
        "words": parsed.get("words"), "created_at": pack.created_at,
    }))
}

pub async fn v1_create_pack(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
    Json(req): Json<serde_json::Value>,
) -> Response {
    if !auth.can("write") {
        return api_err(StatusCode::FORBIDDEN, "forbidden", "write permission required");
    }
    if let Err(_) = crate::security::check_rate_limit(&auth.session_token, "pack_upload", 5, 3600) {
        return api_err(StatusCode::TOO_MANY_REQUESTS, "rate_limited", "rate limit exceeded");
    }

    let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("");
    if name.len() < 3 || name.len() > 100 {
        return api_err(StatusCode::BAD_REQUEST, "invalid_name", "name must be 3-100 characters");
    }
    let desc = req.get("description").and_then(|v| v.as_str());
    let tags_str = req.get("tags").and_then(|v| v.as_array()).map(|arr| {
        arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")
    });

    let words = match req.get("words").and_then(|v| v.as_array()) {
        Some(w) if !w.is_empty() && w.len() <= 5000 => w,
        _ => return api_err(StatusCode::BAD_REQUEST, "invalid_words", "words array must have 1-5000 entries"),
    };

    let mut cleaned = Vec::new();
    for w in words {
        let word = w.get("word").and_then(|v| v.as_str()).unwrap_or("").to_uppercase();
        if word.len() < 3 || word.len() > 21 || !word.chars().all(|c| c.is_ascii_alphabetic()) {
            return api_err(StatusCode::BAD_REQUEST, "invalid_word", &format!("word '{}' is invalid", word));
        }
        let clues = match w.get("clues").and_then(|v| v.as_array()) {
            Some(c) if !c.is_empty() => c,
            _ => return api_err(StatusCode::BAD_REQUEST, "missing_clues", &format!("word '{}' needs clues", word)),
        };
        for clue in clues {
            let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or("");
            if text.len() < 3 || text.len() > 200 {
                return api_err(StatusCode::BAD_REQUEST, "invalid_clue", "each clue must be 3-200 characters");
            }
        }
        cleaned.push(serde_json::json!({ "word": word, "clues": clues }));
    }

    let pack_data = serde_json::json!({ "words": cleaned });
    let word_count = cleaned.len() as i64;
    let id = Uuid::new_v4().to_string();

    let dbi = state.db.lock().await;
    let (_, author_name) = db::get_or_create_session(&dbi.db, &auth.session_token);
    let ok = db::create_pack(&dbi.db, &id,
        &crate::security::sanitize_html(name),
        desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
        &auth.session_token, author_name.as_deref(),
        tags_str.as_deref(), word_count, &pack_data.to_string());

    if !ok {
        return api_err(StatusCode::INTERNAL_SERVER_ERROR, "create_failed", "failed to create pack");
    }

    api_ok(serde_json::json!({ "id": id, "word_count": word_count }))
}

// -- user endpoints --

pub async fn v1_me(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
    let has_passkey = db::has_passkey(&dbi.db, &auth.session_token);
    let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token);

    api_ok(serde_json::json!({
        "display_name": name,
        "has_passkey": has_passkey,
        "streak": { "current": current, "best": best, "last_date": last_date },
    }))
}

pub async fn v1_achievements(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let unlocked = db::get_achievements(&dbi.db, &auth.session_token);
    let all = crate::achievements::all_achievements();
    let result: Vec<serde_json::Value> = all.iter().map(|(id, name, desc, icon, c1, c2)| {
        let is_unlocked = unlocked.iter().any(|(uid, _)| uid == id);
        let unlocked_at = unlocked.iter().find(|(uid, _)| uid == id).map(|(_, t)| t.clone());
        serde_json::json!({
            "id": id, "name": name, "description": desc,
            "icon": icon, "color1": c1, "color2": c2,
            "unlocked": is_unlocked, "unlocked_at": unlocked_at,
        })
    }).collect();
    api_ok(serde_json::json!({ "achievements": result }))
}

pub async fn v1_stats(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
    let solves = db::get_stats(&dbi.db, &auth.session_token);
    let total = solves.len();
    api_ok(serde_json::json!({
        "streak": { "current": current, "best": best, "last_date": last_date },
        "total_solved": total,
        "recent": solves.iter().map(|(pid, preset, time, hints)| {
            serde_json::json!({ "puzzle_id": pid, "preset": preset, "time": time, "hints": hints })
        }).collect::<Vec<_>>(),
    }))
}

// -- instance endpoints --

pub async fn v1_instance(
    State(state): State<Arc<AppState>>,
    _auth: ApiAuth,
) -> Response {
    let ap_enabled = state.ap.is_some();
    let peer_count: i64 = {
        let dbi = state.db.lock().await;
        dbi.db.query_row("SELECT COUNT(*) FROM ap_peers", [], |r| r.get(0)).unwrap_or(0)
    };
    api_ok(serde_json::json!({
        "software": "cruciverb",
        "version": env!("CARGO_PKG_VERSION"),
        "federation": ap_enabled,
        "connected_instances": peer_count,
    }))
}

pub async fn v1_themes(_auth: ApiAuth) -> Response {
    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
    let themes_dir = std::path::PathBuf::from(&data_dir).join("themes");
    let mut themes = Vec::new();
    if let Ok(entries) = std::fs::read_dir(&themes_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("txt") { continue; }
            if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
                let count = std::fs::read_to_string(&path)
                    .map(|c| c.lines().filter(|l| !l.trim().is_empty()).count())
                    .unwrap_or(0);
                themes.push(serde_json::json!({
                    "id": name, "name": name.replace('_', " "), "word_count": count,
                }));
            }
        }
    }
    themes.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
    api_ok(serde_json::json!({ "themes": themes }))
}

// -- API key management --

pub async fn v1_create_key(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
    Json(req): Json<serde_json::Value>,
) -> Response {
    let name = req.get("name").and_then(|v| v.as_str()).unwrap_or("Unnamed key");
    let permissions = req.get("permissions").and_then(|v| v.as_str()).unwrap_or("read");

    // validate permissions
    for perm in permissions.split(',') {
        match perm.trim() {
            "read" | "write" | "generate" | "admin" => {}
            _ => return api_err(StatusCode::BAD_REQUEST, "invalid_permission", &format!("unknown permission: {}", perm)),
        }
    }

    let id = Uuid::new_v4().to_string();
    let raw_key = generate_api_key();
    let key_hash = hex::encode(Sha256::digest(raw_key.as_bytes()));

    let dbi = state.db.lock().await;
    let ok = db::create_api_key(&dbi.db, &id, &auth.session_token, &key_hash, name, permissions);
    if !ok {
        return api_err(StatusCode::INTERNAL_SERVER_ERROR, "create_failed", "failed to create API key");
    }

    api_ok(serde_json::json!({
        "id": id,
        "key": raw_key,
        "name": name,
        "permissions": permissions,
        "note": "save this key now - it will not be shown again",
    }))
}

pub async fn v1_list_keys(
    State(state): State<Arc<AppState>>,
    auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    let keys = db::list_api_keys(&dbi.db, &auth.session_token);
    let result: Vec<serde_json::Value> = keys.iter().map(|(id, name, perms, created, active, last_used)| {
        serde_json::json!({
            "id": id, "name": name, "permissions": perms,
            "created_at": created, "is_active": *active != 0,
            "last_used_at": last_used,
        })
    }).collect();
    api_ok(serde_json::json!({ "keys": result }))
}

pub async fn v1_delete_key(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    auth: ApiAuth,
) -> Response {
    let dbi = state.db.lock().await;
    if db::delete_api_key(&dbi.db, &id, &auth.session_token) {
        api_ok(serde_json::json!({ "deleted": true }))
    } else {
        api_err(StatusCode::NOT_FOUND, "not_found", "key not found or not yours")
    }
}

// -- OpenAPI spec --

pub async fn v1_openapi() -> Response {
    let spec = openapi_spec();
    ([(header::CONTENT_TYPE, "application/json")],
     serde_json::to_string_pretty(&spec).unwrap_or_default()).into_response()
}

pub async fn swagger_ui() -> Response {
    let html = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cruciverb API docs</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
    url: '/api/v1/openapi.json',
    dom_id: '#swagger-ui',
    presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
    layout: 'BaseLayout',
});
</script>
</body>
</html>"#;
    (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}

// -- helpers --

fn generate_api_key() -> String {
    let mut rng = rand::rng();
    let hex: String = (0..16).map(|_| format!("{:02x}", rng.random::<u8>())).collect();
    format!("cv_{}", hex)
}

fn word_contains(word: &cruciverb_core::puzzle::PlacedWord, row: usize, col: usize) -> bool {
    match word.direction {
        cruciverb_core::grid::slot::Direction::Across => row == word.row && col >= word.col && col < word.col + word.word.len(),
        cruciverb_core::grid::slot::Direction::Down => col == word.col && row >= word.row && row < word.row + word.word.len(),
    }
}

fn load_theme_words(theme: &str) -> Option<std::collections::HashSet<String>> {
    let safe: String = theme.chars().filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-').collect();
    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
    let path = std::path::PathBuf::from(&data_dir).join("themes").join(format!("{}.txt", safe));
    let content = std::fs::read_to_string(&path).ok()?;
    let words: std::collections::HashSet<String> = content.lines()
        .map(|l| l.trim().to_uppercase())
        .filter(|l| !l.is_empty() && l.chars().all(|c| c.is_alphabetic()))
        .collect();
    if words.is_empty() { None } else { Some(words) }
}

async fn load_puzzle_inner(state: &AppState, id: &str, uuid: Uuid) -> Result<cruciverb_core::puzzle::Puzzle, Response> {
    let puzzles = state.puzzles.read().await;
    if let Some((p, _)) = puzzles.get(&uuid) {
        return Ok(p.clone());
    }
    drop(puzzles);

    let dbi = state.db.lock().await;
    let (json, _) = db::get_puzzle_json(&dbi.db, id)
        .ok_or_else(|| api_err(StatusCode::NOT_FOUND, "not_found", "puzzle not found"))?;
    serde_json::from_str(&json)
        .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, "parse_error", &e.to_string()))
}

fn parse_gen_config(req: &crate::api::GenerateRequest) -> (u8, cruciverb_core::config::GenerationConfig) {
    use cruciverb_core::config::{DifficultyPreset, GenerationConfig};
    match req.difficulty.as_deref() {
        Some("glyph") => (req.clue_difficulty.unwrap_or(0), GenerationConfig::from_preset(DifficultyPreset::Glyph)),
        Some("rune") => (req.clue_difficulty.unwrap_or(1), GenerationConfig::from_preset(DifficultyPreset::Rune)),
        Some("scroll") => (req.clue_difficulty.unwrap_or(2), GenerationConfig::from_preset(DifficultyPreset::Scroll)),
        Some("codex") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Codex)),
        Some("grimoire") => (req.clue_difficulty.unwrap_or(3), GenerationConfig::from_preset(DifficultyPreset::Grimoire)),
        _ => {
            let size = req.size.unwrap_or(9);
            let density = req.density.unwrap_or(0.22);
            let diff = req.clue_difficulty.unwrap_or(1);
            let freq = match diff { 0 => 0.1, 1 => 0.05, _ => 0.0 };
            let (bt, restarts) = if size >= 19 { (30000, 30) }
                else if size >= 13 { (15000, 20) }
                else { (5000, 10) };
            (diff, GenerationConfig {
                width: size, height: size,
                black_cell_density: density,
                frequency_threshold: freq,
                obscurity_budget: match diff { 1 => 0.0, 2 => 0.10, _ => 0.20 },
                max_backtrack: bt, max_restarts: restarts,
                seed: req.seed,
                rand_top_base: GenerationConfig::rand_top_for_size(size),
                ..Default::default()
            })
        }
    }
}

fn openapi_spec() -> serde_json::Value {
    use serde_json::json;

    let mut paths = serde_json::Map::new();

    // helper for simple GET endpoint
    let simple_get = |summary: &str, tag: &str| json!({
        "get": { "summary": summary, "tags": [tag], "responses": { "200": { "description": "OK" } } }
    });

    let id_param = json!({ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } });

    paths.insert("/api/v1/puzzles/generate".into(), json!({
        "post": { "summary": "Generate a new puzzle", "tags": ["Puzzles"],
            "responses": { "200": { "description": "Generated puzzle" }, "403": { "description": "Generate permission required" } } }
    }));
    paths.insert("/api/v1/puzzles/{id}".into(), json!({
        "get": { "summary": "Get puzzle by ID (no solution)", "tags": ["Puzzles"],
            "parameters": [id_param.clone()],
            "responses": { "200": { "description": "Puzzle data" }, "404": { "description": "Not found" } } }
    }));
    paths.insert("/api/v1/puzzles/{id}/solution".into(), json!({
        "get": { "summary": "Get puzzle solution", "tags": ["Puzzles"],
            "parameters": [id_param.clone()],
            "responses": { "200": { "description": "Solution grid" }, "403": { "description": "Generate permission required" } } }
    }));
    paths.insert("/api/v1/puzzles/{id}/validate".into(), json!({
        "post": { "summary": "Validate solution attempt", "tags": ["Puzzles"],
            "parameters": [id_param.clone()], "responses": { "200": { "description": "Validation result" } } }
    }));
    paths.insert("/api/v1/puzzles/{id}/hint".into(), json!({
        "post": { "summary": "Get a hint", "tags": ["Puzzles"],
            "parameters": [id_param.clone()], "responses": { "200": { "description": "Hint data" } } }
    }));
    paths.insert("/api/v1/puzzles/{id}/export/{fmt}".into(), json!({
        "get": { "summary": "Export puzzle (puz, ipuz, pdf, json)", "tags": ["Puzzles"],
            "parameters": [id_param.clone(),
                {"name": "fmt", "in": "path", "required": true, "schema": {"type": "string"}}],
            "responses": { "200": { "description": "Exported file" } } }
    }));
    paths.insert("/api/v1/daily/{date}".into(), json!({
        "get": { "summary": "Get daily puzzle (default scroll)", "tags": ["Daily"],
            "parameters": [{"name": "date", "in": "path", "required": true, "schema": {"type": "string", "format": "date"}}],
            "responses": { "200": { "description": "Daily puzzle" } } }
    }));
    paths.insert("/api/v1/daily/{date}/{preset}".into(), json!({
        "get": { "summary": "Get daily puzzle by preset", "tags": ["Daily"],
            "parameters": [
                {"name": "date", "in": "path", "required": true, "schema": {"type": "string"}},
                {"name": "preset", "in": "path", "required": true, "schema": {"type": "string"}}],
            "responses": { "200": { "description": "Daily puzzle" } } }
    }));
    paths.insert("/api/v1/community/puzzles".into(), simple_get("List community puzzles", "Community"));
    paths.insert("/api/v1/community/clues/{word}".into(), json!({
        "get": { "summary": "Get community clues for a word", "tags": ["Community"],
            "parameters": [{"name": "word", "in": "path", "required": true, "schema": {"type": "string"}}],
            "responses": { "200": { "description": "Clue list" } } }
    }));
    paths.insert("/api/v1/community/clues".into(), json!({
        "post": { "summary": "Submit a community clue", "tags": ["Community"],
            "responses": { "200": { "description": "Clue ID" }, "403": { "description": "Write permission required" } } }
    }));
    paths.insert("/api/v1/community/clues/{id}/vote".into(), json!({
        "post": { "summary": "Vote on a community clue", "tags": ["Community"],
            "parameters": [id_param.clone()],
            "responses": { "200": { "description": "Vote recorded" }, "403": { "description": "Write permission required" } } }
    }));
    paths.insert("/api/v1/packs".into(), json!({
        "get": { "summary": "List wordlist packs", "tags": ["Packs"],
            "parameters": [
                {"name": "search", "in": "query", "schema": {"type": "string"}},
                {"name": "limit", "in": "query", "schema": {"type": "integer"}}],
            "responses": { "200": { "description": "Pack list" } } },
        "post": { "summary": "Upload a new pack", "tags": ["Packs"],
            "responses": { "200": { "description": "Pack created" }, "403": { "description": "Write permission required" } } }
    }));
    paths.insert("/api/v1/packs/{id}".into(), json!({
        "get": { "summary": "Get pack details", "tags": ["Packs"],
            "parameters": [id_param.clone()],
            "responses": { "200": { "description": "Pack details" }, "404": { "description": "Not found" } } }
    }));
    paths.insert("/api/v1/me".into(), simple_get("Current user info", "User"));
    paths.insert("/api/v1/me/achievements".into(), simple_get("User achievements", "User"));
    paths.insert("/api/v1/me/stats".into(), simple_get("User solve stats", "User"));
    paths.insert("/api/v1/instance".into(), simple_get("Instance info", "Instance"));
    paths.insert("/api/v1/themes".into(), simple_get("List available themes", "Instance"));
    paths.insert("/api/v1/keys".into(), json!({
        "post": { "summary": "Create a new API key", "tags": ["API Keys"],
            "responses": { "200": { "description": "API key (shown once)" } } },
        "get": { "summary": "List your API keys", "tags": ["API Keys"],
            "responses": { "200": { "description": "Key list (no raw keys)" } } }
    }));
    paths.insert("/api/v1/keys/{id}".into(), json!({
        "delete": { "summary": "Revoke an API key", "tags": ["API Keys"],
            "parameters": [id_param], "responses": { "200": { "description": "Key deleted" } } }
    }));

    let components = json!({
        "securitySchemes": {
            "apiKey": { "type": "http", "scheme": "bearer", "description": "API key with cv_ prefix" },
            "cookie": { "type": "apiKey", "in": "cookie", "name": "cruciverb_session" }
        }
    });

    json!({
        "openapi": "3.1.0",
        "info": { "title": "cruciverb API", "version": "1.0.0",
            "description": "Public API for the cruciverb crossword puzzle server." },
        "servers": [{ "url": "/" }],
        "components": components,
        "security": [{ "apiKey": [] }, { "cookie": [] }],
        "paths": paths
    })
}
