use std::sync::Arc;

use axum::Json;
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use uuid::Uuid;

use cruciverb_core::config::{DifficultyPreset, GenerationConfig};
use cruciverb_core::grid::{Grid, Cell};
use cruciverb_core::grid::slot::{Direction, extract_slots};
use cruciverb_core::puzzle::{Puzzle, generate_full_puzzle, assemble_puzzle};
use cruciverb_core::solver;
use sha2::{Sha256, Digest};

use crate::api::*;
use crate::db;
use crate::session::Session;
use crate::state::AppState;

pub struct AppError {
    msg: String,
    status: StatusCode,
}

#[allow(non_snake_case)]
fn AppError(msg: String) -> AppError {
    AppError { msg, status: StatusCode::INTERNAL_SERVER_ERROR }
}

impl AppError {
    pub fn with_status(code: StatusCode, msg: &str) -> Self {
        Self { msg: msg.to_string(), status: code }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (self.status, self.msg).into_response()
    }
}

async fn check_banned(state: &AppState, token: &str) -> Result<(), AppError> {
    let dbi = state.db.lock().await;
    if db::is_banned(&dbi.db, token) {
        Err(AppError::with_status(StatusCode::FORBIDDEN, "account suspended"))
    } else {
        Ok(())
    }
}

fn sanitize_filename(name: &str) -> String {
    name.chars()
        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
        .take(100)
        .collect()
}

fn with_session_cookie(session: &Session, json: Json<impl serde::Serialize>) -> Response {
    let mut resp = json.into_response();
    if let Ok(val) = session.cookie_header().parse() {
        resp.headers_mut().insert(axum::http::header::SET_COOKIE, val);
    }
    resp
}

pub async fn generate_puzzle(
    State(state): State<Arc<AppState>>,
    headers: axum::http::HeaderMap,
    session: Session,
    Json(req): Json<GenerateRequest>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "generate", 5, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let ip = crate::security::extract_client_ip(&headers);
    crate::security::check_rate_limit_ip(&ip, "generate", 20, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let _permit = state.generation_semaphore.try_acquire()
        .map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?;
    let clue_db = state.clue_db.clone();
    let word_diffs = state.word_diffs.clone();

    let (difficulty_level, config) = 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);
            if size < 3 || size > 25 { return Err(AppError("grid size must be between 3 and 25".into())); }
            let density = req.density.unwrap_or(0.22);
            let diff = req.clue_difficulty.unwrap_or(1);
            let freq_threshold = 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_threshold,
                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()
            })
        }
    };

    let mut config = config;
    if let Some(seed) = req.seed {
        config.seed = Some(seed);
    }
    if req.cryptic.unwrap_or(false) {
        config.cryptic_grid = true;
        // slightly sparser than standard, with longer slots preferred
        config.black_cell_density = (config.black_cell_density + 0.08).min(0.40);
    }

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

    // theme support
    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 {
        let count = dict.boost_theme(tw);
        tracing::info!("theme '{}': boosted {} words", theme_name.as_deref().unwrap_or(""), count);
    }

    // pack support - boost pack words in dictionary
    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()) {
                let mut boosted = 0;
                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);
                        boosted += 1;
                    }
                }
                tracing::info!("pack '{}': boosted {} words", pack_id, boosted);
            }
            db::increment_pack_downloads(&dbi.db, pack_id);
        }
        drop(dbi);
    }

    // apply staleness penalties so recently used words get deprioritized
    config.staleness = state.build_staleness_penalties().await;

    let tw_clone = theme_words.clone();
    let tn_clone = theme_name.clone();
    let puzzle = tokio::task::spawn_blocking(move || {
        cruciverb_core::puzzle::generate_themed_puzzle(
            &config, &dict, &clue_db, difficulty_level,
            tn_clone.as_deref(), tw_clone.as_ref(),
        )
    })
    .await
    .map_err(|e| AppError(format!("task join error: {}", e)))?
    .map_err(|e| AppError(e.to_string()))?;

    // record words so future puzzles deprioritize them
    let used_words: Vec<String> = puzzle.words.iter().map(|w| w.word.clone()).collect();
    state.record_puzzle_words(&used_words).await;

    // cryptic mode: swap clues with cryptic versions
    let mut puzzle = puzzle;
    if is_cryptic {
        if let Some(ref cdb) = state.cryptic_db {
            let count = puzzle.apply_cryptic_clues(cdb);
            tracing::info!("applied {} cryptic clues", count);
        }
    }

    let id = Uuid::new_v4();
    let puzzle_json = puzzle.to_json().map_err(|e| AppError(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 = to_client_puzzle(&puzzle, id);
    state.cache_puzzle(id, puzzle).await;

    Ok(with_session_cookie(&session, Json(GenerateResponse {
        id: id.to_string(),
        short_id: short_id.clone(),
        puzzle: client,
    })))
}

#[axum::debug_handler]
pub async fn get_puzzle(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;

    // try in-memory first
    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 Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
            id: id.clone(),
            short_id,
            puzzle: to_client_puzzle(puzzle, uuid),
        })));
    }
    drop(puzzles);

    // try database
    let dbi = state.db.lock().await;
    let (puzzle_json, short_id) = db::get_puzzle_json(&dbi.db, &id)
        .ok_or(AppError("puzzle not found".into()))?;
    let puzzle: Puzzle = serde_json::from_str(&puzzle_json)
        .map_err(|e| AppError(e.to_string()))?;
    drop(dbi);

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

    Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
        id,
        short_id,
        puzzle: client,
    })))
}

pub async fn get_puzzle_by_short_id(
    State(state): State<Arc<AppState>>,
    Path(short_id): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (id, puzzle_json) = db::get_puzzle_by_short_id(&dbi.db, &short_id)
        .ok_or(AppError("puzzle not found".into()))?;
    let puzzle: Puzzle = serde_json::from_str(&puzzle_json)
        .map_err(|e| AppError(e.to_string()))?;
    drop(dbi);

    let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4());
    let client = to_client_puzzle(&puzzle, uuid);
    state.cache_puzzle(uuid, puzzle).await;

    Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
        id,
        short_id,
        puzzle: client,
    })))
}

pub async fn get_daily_puzzle(
    State(state): State<Arc<AppState>>,
    Path(date): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    get_daily_impl(state, session, &date, "scroll").await
}

pub async fn get_daily_preset(
    State(state): State<Arc<AppState>>,
    Path((date, preset)): Path<(String, String)>,
    session: Session,
) -> Result<Response, AppError> {
    get_daily_impl(state, session, &date, &preset).await
}

async fn get_daily_impl(
    state: Arc<AppState>,
    session: Session,
    date: &str,
    preset: &str,
) -> Result<Response, AppError> {
    // serialize generation with the pre-generator, then re-check under the lock
    let _gen_lock = state.daily_gen_lock(date, preset).await;

    // 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: Puzzle = serde_json::from_str(&puzzle_json)
                .map_err(|e| AppError(e.to_string()))?;
            let uuid = Uuid::parse_str(&id).unwrap_or(Uuid::new_v4());
            let client = to_client_puzzle(&puzzle, uuid);
            let solve_info = db::get_solve_info(&dbi.db, &session.token, &id);
            state.cache_puzzle(uuid, puzzle).await;
            let mut resp = serde_json::to_value(ClientPuzzleResponse {
                id, short_id, puzzle: client,
            }).unwrap();
            if let Some((time, hints, cell_results)) = solve_info {
                resp["already_solved"] = serde_json::json!(true);
                resp["solve_time"] = serde_json::json!(time);
                resp["solve_hints"] = serde_json::json!(hints);
                if let Some(cr) = cell_results {
                    resp["cell_results"] = serde_json::json!(cr);
                }
            }
            return Ok(with_session_cookie(&session, Json(resp)));
        }
    }

    // acquire generation permit before the heavy work
    let _permit = state.generation_semaphore.try_acquire()
        .map_err(|_| AppError::with_status(StatusCode::SERVICE_UNAVAILABLE, "server busy - try again shortly"))?;

    // generate with secret-seeded RNG
    let seed = daily_seed(date, preset, &state.daily_secret);
    let (difficulty_level, mut config) = match preset {
        "glyph" => (0u8, GenerationConfig::from_preset(DifficultyPreset::Glyph)),
        "rune" => (1, GenerationConfig::from_preset(DifficultyPreset::Rune)),
        "scroll" => (2, GenerationConfig::from_preset(DifficultyPreset::Scroll)),
        "codex" => (3, GenerationConfig::from_preset(DifficultyPreset::Codex)),
        "grimoire" => (3, GenerationConfig::from_preset(DifficultyPreset::Grimoire)),
        _ => (2, GenerationConfig::from_preset(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, difficulty_level);
    let wq = state.word_quality.read().await.clone();
    if !wq.is_empty() { dict.apply_quality(&wq); }

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

    let puzzle = tokio::task::spawn_blocking(move || {
        generate_full_puzzle(&config, &dict, &clue_db, difficulty_level)
    })
    .await
    .map_err(|e| AppError(format!("task join error: {}", e)))?
    .map_err(|e| AppError(e.to_string()))?;

    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 = puzzle.to_json().map_err(|e| AppError(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, difficulty_level,
                         Some(date), Some(preset))
    };

    crate::webhooks::fire_event(&state, "daily_puzzle.published", serde_json::json!({
        "description": format!("New {} puzzle ({}x{}) - /p/{}", preset, puzzle.width, puzzle.height, short_id),
        "preset": preset, "date": date, "short_id": short_id,
    })).await;

    // daily AP post is now handled by the pre-generator, not per-preset

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

    Ok(with_session_cookie(&session, Json(ClientPuzzleResponse {
        id: id.to_string(),
        short_id,
        puzzle: client,
    })))
}

pub fn daily_seed(date: &str, preset: &str, secret: &str) -> u64 {
    use sha2::{Sha256, Digest};
    let input = format!("{}-{}-{}", secret, preset, date);
    let hash = Sha256::digest(input.as_bytes());
    u64::from_le_bytes(hash[..8].try_into().unwrap())
}

pub async fn validate_solution(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Json(req): Json<ValidateRequest>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    let uuid = Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;
    let puzzles = state.puzzles.read().await;
    let (puzzle, _) = puzzles.get(&uuid).ok_or(AppError("puzzle not found".into()))?;

    let mut all_correct = true;
    let mut cell_results = Vec::with_capacity(puzzle.height);

    for r in 0..puzzle.height {
        let mut row_results = 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(|row| row.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_results.push(result.to_string());
        }
        cell_results.push(row_results);
    }

    // record solve if correct (respecting privacy settings)
    let mut new_achievements = Vec::new();
    let mut flagged = false;
    if all_correct {
        let dbi = state.db.lock().await;

        // skip if already solved - don't let re-submissions overwrite the record
        if db::has_solve_record(&dbi.db, &session.token, &id) {
            drop(dbi);
            return Ok(with_session_cookie(&session, Json(serde_json::json!({
                "correct": true,
                "already_solved": true,
                "cells": cell_results,
                "new_achievements": [],
            }))));
        }

        // use server-tracked hint count instead of trusting the client
        let server_hints = db::get_hint_count(&dbi.db, &id, &session.token);

        // minimum solve time check - flag suspiciously fast solves (raw client
        // time; anonymized rounding below happens only for what gets stored)
        let elapsed = req.elapsed.unwrap_or(0);
        let daily_info = db::get_puzzle_daily_info(&dbi.db, &id);
        let min_time: i64 = match &daily_info {
            Some((_, preset)) => match preset.as_str() {
                "glyph" => 3,
                "rune" => 5,
                "scroll" => 15,
                "codex" => 30,
                "grimoire" => 60,
                _ => 3,
            },
            None => 3,
        };
        if elapsed < min_time {
            flagged = true;
        }

        let settings_json = db::get_settings(&dbi.db, &session.token);
        let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
        let anon_mode = settings.get("anonymousSolveMode").and_then(|v| v.as_bool()).unwrap_or(false);
        let no_track = settings.get("noTrackMode").and_then(|v| v.as_bool()).unwrap_or(false);
        let anonymize_times = settings.get("anonymizeTimes").and_then(|v| v.as_bool()).unwrap_or(false);

        // anonymizeTimes: round stored solve times to the minute
        let elapsed = if anonymize_times { (elapsed / 60) * 60 } else { elapsed };

        let cr = req.cell_results.as_deref();
        if !no_track && !anon_mode {
            db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr);
            let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
            // archived dailies count toward history and stats, but not the
            // daily streak (matches the archive page copy)
            let is_past_daily = daily_info
                .as_ref()
                .map(|(d, _)| d < &today)
                .unwrap_or(false);
            if !is_past_daily {
                db::update_streak(&dbi.db, &session.token, &today);
            }
            new_achievements = crate::achievements::check_achievements(&dbi.db, &session.token);
        } else if !no_track {
            db::record_solve(&dbi.db, &session.token, &id, elapsed, server_hints, flagged, cr);
        }

        // auto-purge check
        let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
        if purge_enabled {
            let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90).max(7);
            db::auto_purge(&dbi.db, &session.token, days);
        }

        // compute percentile and rank before dropping dbi
        let percentile = if !no_track {
            Some(db::get_solve_percentile(&dbi.db, &id, elapsed))
        } else { None };
        let rank = if !no_track {
            Some(db::get_solve_rank(&dbi.db, &id, elapsed))
        } else { None };
        let total_solvers = if !no_track {
            Some(db::get_total_solvers(&dbi.db, &id))
        } else { None };

        // generate receipt if this is a room solve
        let receipt_json = if req.room_code.is_some() {
            let (pj, _) = db::get_puzzle_json(&dbi.db, &id).unwrap_or_default();
            let puzzle_hash = hex::encode(Sha256::digest(pj.as_bytes()));
            let player_name = session.display_name.clone().unwrap_or_else(|| "Anonymous".into());

            // get other players from the room
            let other_players = if let Some(code) = &req.room_code {
                let rooms = state.rooms.read().await;
                rooms.get(code).map(|room| {
                    room.players.values()
                        .filter(|p| p.id != session.token)
                        .map(|p| p.name.clone())
                        .collect::<Vec<_>>()
                }).unwrap_or_default()
            } else {
                Vec::new()
            };

            let mode_str = if let Some(code) = &req.room_code {
                let rooms = state.rooms.read().await;
                rooms.get(code).map(|room| {
                    match room.mode {
                        crate::rooms::RoomMode::Coop => "coop".to_string(),
                        crate::rooms::RoomMode::Competitive => "competitive".to_string(),
                        crate::rooms::RoomMode::Party => "party".to_string(),
                    }
                })
            } else {
                None
            };

            crate::receipts::generate_receipt(
                &dbi.db,
                &puzzle_hash,
                elapsed,
                server_hints,
                &player_name,
                req.room_code.as_deref(),
                mode_str.as_deref(),
                other_players,
            ).map(|sr| serde_json::json!({
                "receipt": sr.receipt,
                "signature": sr.signature,
                "key_id": sr.key_id,
            }))
        } else {
            None
        };

        drop(dbi);

        // fire webhooks (unless no-track)
        if !no_track {
            crate::webhooks::fire_event(&state, "puzzle.completed", serde_json::json!({
                "description": format!("Puzzle solved in {}s", elapsed),
            })).await;
            for ach in &new_achievements {
                crate::webhooks::fire_event(&state, "achievement.unlocked", serde_json::json!({
                    "description": format!("Achievement unlocked: {}", ach),
                })).await;
            }

            // publish leaderboard to peers via direct API (not AP Notes)
            if let Some((daily_date, daily_preset)) = daily_info.clone() {
                let state2 = state.clone();
                tokio::spawn(async move {
                    let entries = {
                        let dbi = state2.db.lock().await;
                        db::get_daily_leaderboard_filtered(&dbi.db, &daily_date, &daily_preset, 10)
                            .into_iter().map(|(name, time, _)| (name, time)).collect()
                    };
                    crate::activitypub::outbox::publish_leaderboard(
                        &state2, &daily_date, &daily_preset, entries,
                    ).await;
                });
            }
        }

        return Ok(with_session_cookie(&session, Json(serde_json::json!({
            "correct": true,
            "cells": cell_results,
            "new_achievements": new_achievements,
            "flagged": flagged,
            "percentile": percentile,
            "rank": rank,
            "total_solvers": total_solvers,
            "receipt": receipt_json,
        }))));
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "correct": all_correct,
        "cells": cell_results,
        "new_achievements": new_achievements,
        "flagged": flagged,
    }))))
}

pub async fn get_hint(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Json(req): Json<HintRequest>,
) -> Result<Response, AppError> {
    Uuid::parse_str(&id).map_err(|_| AppError("invalid puzzle id".into()))?;

    // rate limit check_cell to prevent brute-force solution extraction
    if req.kind == "check_cell" {
        crate::security::check_rate_limit(&session.token, &format!("check_cell:{}", id), 50, 60)
            .map_err(|s| AppError::with_status(s, "too many check requests"))?;
    } else {
        let dbi = state.db.lock().await;
        let hint_count = db::get_hint_count(&dbi.db, &id, &session.token);
        if hint_count >= 20 {
            return Err(AppError::with_status(StatusCode::TOO_MANY_REQUESTS, "hint limit reached for this puzzle"));
        }
        db::increment_hint_count(&dbi.db, &id, &session.token);
        drop(dbi);
    }

    let puzzle = load_puzzle(&state, &id).await?;

    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());
            let is_correct = actual == submitted;
            Ok(with_session_cookie(&session, Json(HintResponse::CheckCell { is_correct })))
        }
        "reveal_letter" => {
            let letter = puzzle.grid.get(req.row, req.col)
                .letter()
                .ok_or(AppError("not a letter cell".into()))?;
            Ok(with_session_cookie(&session, Json(HintResponse::RevealLetter {
                row: req.row, col: req.col, letter: letter.to_string(),
            })))
        }
        "reveal_word" => {
            let dir = match req.direction.as_deref() {
                Some("down") => Direction::Down,
                _ => Direction::Across,
            };
            let word = puzzle.words.iter()
                .find(|w| w.direction == dir && word_contains(w, req.row, req.col))
                .ok_or(AppError("no word at that position".into()))?;
            let letters: Vec<LetterPosition> = word.word.chars().enumerate().map(|(i, ch)| {
                let (r, c) = match word.direction {
                    Direction::Across => (word.row, word.col + i),
                    Direction::Down => (word.row + i, word.col),
                };
                LetterPosition { row: r, col: c, letter: ch.to_string() }
            }).collect();
            Ok(with_session_cookie(&session, Json(HintResponse::RevealWord { letters })))
        }
        _ => Err(AppError("unknown hint kind".into())),
    }
}

pub async fn save_progress(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Json(req): Json<ProgressRequest>,
) -> Result<Response, AppError> {
    let grid_json = serde_json::to_string(&req.cells).map_err(|e| AppError(e.to_string()))?;
    let dbi = state.db.lock().await;

    // #9: encrypt if user has encryptSolveData enabled
    let settings_json = db::get_settings(&dbi.db, &session.token);
    let encrypt = serde_json::from_str::<serde_json::Value>(&settings_json)
        .ok()
        .and_then(|v| v.get("encryptSolveData")?.as_bool())
        .unwrap_or(false);

    if encrypt {
        match encrypt_progress(grid_json.as_bytes()) {
            Some(encrypted) => {
                let stored = format!("ENC2:{}", hex::encode(&encrypted));
                db::save_progress(&dbi.db, &session.token, &id, &stored);
            }
            None => {
                tracing::warn!("encryptSolveData enabled but DATA_ENC_KEY missing or invalid - storing plaintext");
                db::save_progress(&dbi.db, &session.token, &id, &grid_json);
            }
        }
    } else {
        db::save_progress(&dbi.db, &session.token, &id, &grid_json);
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}

pub async fn load_progress(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let progress = db::load_progress(&dbi.db, &session.token, &id);

    // #9: decrypt if data is encrypted (ENC2 = AES-256-GCM, ENC = legacy XOR)
    let cells = progress.and_then(|p| {
        if let Some(hex_data) = p.strip_prefix("ENC2:") {
            let encrypted = hex::decode(hex_data).ok()?;
            let decrypted = decrypt_progress(&encrypted)?;
            let json_str = String::from_utf8(decrypted).ok()?;
            serde_json::from_str::<serde_json::Value>(&json_str).ok()
        } else if let Some(hex_data) = p.strip_prefix("ENC:") {
            let encrypted = hex::decode(hex_data).ok()?;
            let key = derive_key(&session.token);
            let decrypted = xor_crypt(&encrypted, &key);
            let json_str = String::from_utf8(decrypted).ok()?;
            serde_json::from_str::<serde_json::Value>(&json_str).ok()
        } else {
            serde_json::from_str::<serde_json::Value>(&p).ok()
        }
    });

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "cells": cells
    }))))
}

pub async fn get_me(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (current, best, last_date) = db::get_streak(&dbi.db, &session.token);
    let has_passkey = db::has_passkey(&dbi.db, &session.token);
    let preferred_preset = db::get_preferred_preset(&dbi.db, &session.token);
    let settings_json = db::get_settings(&dbi.db, &session.token);
    let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
    let private = settings.get("privateProfile").and_then(|v| v.as_bool()).unwrap_or(false);
    let display = if private { None } else { session.display_name.clone() };

    // auto-purge on session load
    let purge_enabled = settings.get("autoPurgeEnabled").and_then(|v| v.as_bool()).unwrap_or(false);
    if purge_enabled {
        let days = settings.get("autoPurgeDays").and_then(|v| v.as_i64()).unwrap_or(90);
        db::auto_purge(&dbi.db, &session.token, days);
    }

    let freezes = db::get_streak_freezes(&dbi.db, &session.token);

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "display_name": display,
        "is_new": session.is_new,
        "has_passkey": has_passkey,
        "preferred_preset": preferred_preset,
        "streak": {
            "current": current,
            "best": best,
            "last_date": last_date,
        },
        "streak_freezes": freezes,
    }))))
}

pub async fn update_me(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<UpdateMeRequest>,
) -> Result<Response, AppError> {
    if let Some(name) = &req.display_name {
        if name.len() > 100 {
            return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name too long"));
        }
        let clean = crate::security::sanitize_html(name);
        if state.blocklist.read().unwrap().contains_blocked(&clean) {
            return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "name contains inappropriate content"));
        }
        let dbi = state.db.lock().await;
        db::set_display_name(&dbi.db, &session.token, &clean);
    }
    Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}

pub async fn get_stats(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (current, best, last_date) = db::get_streak(&dbi.db, &session.token);
    let solves = db::get_stats(&dbi.db, &session.token);
    let total = solves.len();

    Ok(with_session_cookie(&session, Json(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<_>>(),
    }))))
}

pub async fn get_leaderboard(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_leaderboard_filtered(&dbi.db, &id, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, time, hints)| {
            serde_json::json!({ "name": name, "time": time, "hints": hints })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_daily_leaderboard(
    State(state): State<Arc<AppState>>,
    Path((date, preset)): Path<(String, String)>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    session: Session,
) -> Result<Response, AppError> {
    let clean_only = params.get("clean").map(|v| v == "true").unwrap_or(false);
    let dbi = state.db.lock().await;
    let entries = if clean_only {
        db::get_daily_leaderboard_by_date(&dbi.db, &date, &preset, true, 20)
    } else {
        db::get_daily_leaderboard_filtered(&dbi.db, &date, &preset, 20)
    };
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, time, hints)| {
            serde_json::json!({ "name": name, "time": time, "hints": hints })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_federated_leaderboard(
    State(state): State<Arc<AppState>>,
    Path((date, preset)): Path<(String, String)>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_federated_leaderboard(&dbi.db, &date, &preset, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, origin, time)| {
            serde_json::json!({ "name": name, "instance": origin, "time": time })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_alltime_leaderboard(
    State(state): State<Arc<AppState>>,
    Path(preset): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_alltime_leaderboard(&dbi.db, &preset, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, time, hints, date)| {
            serde_json::json!({ "name": name, "time": time, "hints": hints, "date": date })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_weekly_leaderboard(
    State(state): State<Arc<AppState>>,
    Path(preset): Path<String>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_weekly_leaderboard(&dbi.db, &preset, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, time, hints)| {
            serde_json::json!({ "name": name, "time": time, "hints": hints })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_streak_leaderboard(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_streak_leaderboard(&dbi.db, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, current, best)| {
            serde_json::json!({ "name": name, "current": current, "best": best })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_completionist_leaderboard(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_completionist_leaderboard(&dbi.db, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, total)| {
            serde_json::json!({ "name": name, "total_solves": total })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_clean_leaderboard(
    State(state): State<Arc<AppState>>,
    Path((date, preset)): Path<(String, String)>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let entries = db::get_clean_leaderboard(&dbi.db, &date, &preset, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "entries": entries.iter().map(|(name, time)| {
            serde_json::json!({ "name": name, "time": time })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn get_detailed_stats(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "stats", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let dbi = state.db.lock().await;
    let stats = db::get_personal_stats(&dbi.db, &session.token);
    Ok(with_session_cookie(&session, Json(stats)))
}

pub async fn get_analytics(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "analytics", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let dbi = state.db.lock().await;
    let data = db::get_analytics_data(&dbi.db, &session.token);
    Ok(with_session_cookie(&session, Json(data)))
}

pub async fn get_history(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "history", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let dbi = state.db.lock().await;
    let history = db::get_solve_history(&dbi.db, &session.token, 50);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "history": history
    }))))
}

pub async fn get_solve_percentile(
    State(state): State<Arc<AppState>>,
    Path((id, time)): Path<(String, i64)>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let percentile = db::get_solve_percentile(&dbi.db, &id, time);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "percentile": percentile
    }))))
}

pub async fn get_settings(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let settings = db::get_settings(&dbi.db, &session.token);
    let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap_or_default();
    Ok(with_session_cookie(&session, Json(parsed)))
}

pub async fn save_settings(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(settings): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let json = serde_json::to_string(&settings).map_err(|e| AppError(e.to_string()))?;
    let dbi = state.db.lock().await;
    db::save_settings(&dbi.db, &session.token, &json);
    Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}

// ---- achievement endpoints ----

pub async fn get_achievements(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let unlocked = db::get_achievements(&dbi.db, &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();

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "achievements": result }))))
}

pub async fn list_achievements(
    State(_state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
    let all = crate::achievements::all_achievements();
    let result: Vec<serde_json::Value> = all.iter().map(|(id, name, desc, icon, c1, c2)| {
        serde_json::json!({
            "id": id, "name": name, "description": desc,
            "icon": icon, "color1": c1, "color2": c2,
        })
    }).collect();
    Ok(Json(serde_json::json!({ "achievements": result })))
}

// ---- import puzzle from .puz / .ipuz ----

pub async fn import_puzzle(
    State(state): State<Arc<AppState>>,
    headers: axum::http::HeaderMap,
    session: Session,
    body: axum::body::Bytes,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "import", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let ip = crate::security::extract_client_ip(&headers);
    crate::security::check_rate_limit_ip(&ip, "import", 30, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;

    if body.len() > 1_000_000 {
        return Err(AppError::with_status(StatusCode::PAYLOAD_TOO_LARGE, "file too large (max 1MB)"));
    }

    let puzzle = cruciverb_core::import::detect_and_parse(&body)
        .map_err(|e| AppError::with_status(StatusCode::BAD_REQUEST, &e))?;

    let id = Uuid::new_v4();
    let puzzle_json = puzzle.to_json().map_err(|e| AppError(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, 0, None, Some("imported"))
    };

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

    Ok(with_session_cookie(&session, Json(GenerateResponse {
        id: id.to_string(),
        short_id: short_id.clone(),
        puzzle: client,
    })))
}

// ---- export endpoints ----

pub async fn export_puz(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "export", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let puzzle = load_puzzle(&state, &id).await?;
    let data = cruciverb_core::export::puz::to_puz(&puzzle, "cruciverb puzzle", "cruciverb");
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/octet-stream"),
         (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.puz\"")],
        data,
    ).into_response())
}

pub async fn export_ipuz(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "export", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let puzzle = load_puzzle(&state, &id).await?;
    let data = cruciverb_core::export::ipuz::to_ipuz(&puzzle, "cruciverb puzzle");
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/json"),
         (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"puzzle.ipuz\"")],
        serde_json::to_string_pretty(&data).unwrap_or_default(),
    ).into_response())
}

#[derive(serde::Deserialize)]
pub struct PdfQuery {
    pub variant: Option<String>,
}

pub async fn export_pdf(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Query(q): Query<PdfQuery>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "export", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let puzzle = load_puzzle(&state, &id).await?;
    let variant = match q.variant.as_deref() {
        Some("solution") => "solution",
        _ => "puzzle",
    };
    let data = cruciverb_core::export::pdf::to_pdf(&puzzle, "cruciverb puzzle", variant);
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/pdf"),
         (axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"puzzle-{}.pdf\"", variant))],
        data,
    ).into_response())
}

async fn load_puzzle(state: &AppState, id: &str) -> Result<Puzzle, AppError> {
    let uuid = Uuid::parse_str(id).map_err(|_| AppError("invalid puzzle id".into()))?;

    // try in-memory
    let puzzles = state.puzzles.read().await;
    if let Some((p, _)) = puzzles.get(&uuid) {
        return Ok(p.clone());
    }
    drop(puzzles);

    // try database
    let dbi = state.db.lock().await;
    let (json, _) = db::get_puzzle_json(&dbi.db, id).ok_or(AppError("puzzle not found".into()))?;
    serde_json::from_str(&json).map_err(|e| AppError(e.to_string()))
}

// ---- puzzle editor endpoints ----

pub async fn editor_autofill(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let width = req.get("width").and_then(|v| v.as_u64()).ok_or(AppError("missing width".into()))? as usize;
    let height = req.get("height").and_then(|v| v.as_u64()).ok_or(AppError("missing height".into()))? as usize;
    let cells = req.get("cells").and_then(|v| v.as_array()).ok_or(AppError("missing cells".into()))?;

    if width > 25 || height > 25 { return Err(AppError("max grid size is 25x25".into())); }
    if width < 3 || height < 3 { return Err(AppError("min grid size is 3x3".into())); }

    // check for offensive patterns
    let cell_strs: Vec<Vec<&str>> = cells.iter().map(|row| {
        row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect())
            .unwrap_or_default()
    }).collect();
    if has_offensive_pattern(&cell_strs, width, height) {
        return Err(AppError("this grid pattern is not allowed".into()));
    }

    let mut grid = Grid::new(width, height);
    for (r, row) in cells.iter().enumerate() {
        if let Some(row_arr) = row.as_array() {
            for (c, cell) in row_arr.iter().enumerate() {
                if cell.as_str() == Some("black") {
                    grid.set(r, c, Cell::Black);
                }
            }
        }
    }

    let locked: Vec<(usize, usize, char)> = req.get("locked")
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|v| {
            let r = v.get("row")?.as_u64()? as usize;
            let c = v.get("col")?.as_u64()? as usize;
            let ch = v.get("letter")?.as_str()?.chars().next()?;
            Some((r, c, ch))
        }).collect())
        .unwrap_or_default();

    for &(r, c, ch) in &locked {
        grid.set(r, c, Cell::Letter(ch));
    }

    let word_diffs = state.word_diffs.clone();
    let mut dict = (*state.dict).clone();
    dict.apply_difficulty(&word_diffs, 1); // easy words for editor fills
    let wq = state.word_quality.read().await.clone();
    if !wq.is_empty() { dict.apply_quality(&wq); }

    let clue_db = state.clue_db.clone();
    let (bt, restarts) = if width >= 19 { (30000, 30) }
                          else if width >= 13 { (15000, 20) }
                          else { (5000, 10) };
    let staleness = state.build_staleness_penalties().await;
    let config = GenerationConfig {
        width, height,
        min_word_length: 3,
        frequency_threshold: 0.1,
        max_backtrack: bt,
        max_restarts: restarts,
        timeout_seconds: 60,
        rand_top_base: GenerationConfig::rand_top_for_size(width),
        staleness,
        ..Default::default()
    };

    let result = tokio::task::spawn_blocking(move || {
        let slots = extract_slots(&grid, 3);
        match solver::solve(&grid, &slots, &dict, &config) {
            Ok(solve_result) => {
                let puzzle = assemble_puzzle(&solve_result, &slots, &dict, &clue_db, &config, 2, 0, None, None);
                Ok(puzzle)
            }
            Err(e) => Err(e),
        }
    }).await.map_err(|e| AppError(format!("task error: {}", e)))?;

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

            let clues: Vec<serde_json::Value> = puzzle.clues_across.iter().chain(puzzle.clues_down.iter())
                .map(|c| serde_json::json!({
                    "number": c.number, "direction": format!("{:?}", c.direction).to_lowercase(),
                    "text": c.text, "answer": c.answer,
                })).collect();

            Ok(with_session_cookie(&session, Json(serde_json::json!({
                "ok": true, "letters": filled, "clues": clues,
            }))))
        }
        Err(_) => Ok(with_session_cookie(&session, Json(serde_json::json!({
            "ok": false, "error": "could not fill this grid pattern - try adjusting the black cells"
        }))))
    }
}

pub async fn editor_clue_suggestions(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(word): Path<String>,
) -> Result<Response, AppError> {
    let upper = word.to_uppercase();
    if !upper.chars().all(|c| c.is_alphabetic()) {
        return Err(AppError("invalid word".into()));
    }

    let mut suggestions = state.clue_db.suggestions(&upper, 5);
    if let Some(ref cdb) = state.cryptic_db {
        for s in cdb.suggestions(&upper, 3) {
            if !suggestions.contains(&s) { suggestions.push(s); }
        }
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "suggestions": suggestions }))))
}

pub async fn editor_check_words(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let words = req.get("words").and_then(|v| v.as_array())
        .ok_or(AppError("missing words".into()))?;

    // check against the in-memory blocklist (the DATA_DIR-aware one), not a
    // hardcoded relative path that ignores the configured data directory
    let blocklist = state.blocklist.read().unwrap();
    let mut blocked: Vec<String> = Vec::new();
    for w in words {
        if let Some(word) = w.as_str() {
            let upper = word.to_uppercase();
            if upper.len() < 2 || !upper.chars().all(|c| c.is_alphabetic()) { continue; }
            if blocklist.contains_blocked(&upper) {
                blocked.push(upper);
            }
        }
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "blocked": blocked,
    }))))
}

pub async fn editor_save(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    let id = req.get("id").and_then(|v| v.as_str())
        .unwrap_or("").to_string();
    let id = if id.is_empty() { Uuid::new_v4().to_string() } else { id };
    let raw_title = req.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled");
    let title = crate::security::sanitize_html(raw_title);
    if state.blocklist.read().unwrap().contains_blocked(&title) {
        return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "title contains inappropriate content"));
    }
    let width = req.get("width").and_then(|v| v.as_u64()).unwrap_or(15) as usize;
    let height = req.get("height").and_then(|v| v.as_u64()).unwrap_or(15) as usize;
    if width > 25 || height > 25 || width < 3 || height < 3 {
        return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "grid size must be between 3 and 25"));
    }
    let grid_json = req.get("grid").map(|v| v.to_string()).unwrap_or_else(|| "[]".into());

    // check for offensive patterns in saved grid
    if let Some(grid_arr) = req.get("grid").and_then(|v| v.as_array()) {
        let cell_strs: Vec<Vec<&str>> = grid_arr.iter().map(|row| {
            row.as_array().map(|a| a.iter().map(|c| c.as_str().unwrap_or("white")).collect())
                .unwrap_or_default()
        }).collect();
        if has_offensive_pattern(&cell_strs, width, height) {
            return Err(AppError("this grid pattern is not allowed".into()));
        }
    }

    let clues_json = req.get("clues").map(|v| v.to_string());
    let puzzle_json = req.get("puzzle").map(|v| v.to_string());
    let status = req.get("status").and_then(|v| v.as_str()).unwrap_or("draft");
    if status == "published" {
        return Err(AppError::with_status(
            axum::http::StatusCode::BAD_REQUEST,
            "use the publish endpoint to publish puzzles",
        ));
    }

    let dbi = state.db.lock().await;
    db::save_user_puzzle(&dbi.db, &id, &session.token, &title, width, height,
        &grid_json, clues_json.as_deref(), puzzle_json.as_deref(), status);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}

pub async fn editor_load(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let puzzle = db::get_user_puzzle(&dbi.db, &id, &session.token)
        .ok_or(AppError("puzzle not found".into()))?;
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "id": puzzle.0, "title": puzzle.1, "width": puzzle.2, "height": puzzle.3,
        "grid": serde_json::from_str::<serde_json::Value>(&puzzle.4).unwrap_or_default(),
        "clues": puzzle.5.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()),
        "puzzle": puzzle.6.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()),
        "status": puzzle.7,
    }))))
}

pub async fn editor_list(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let puzzles = db::list_user_puzzles(&dbi.db, &session.token);
    let result: Vec<serde_json::Value> = puzzles.iter().map(|(id, title, w, h, status, updated)| {
        serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "status": status, "updated_at": updated })
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result }))))
}

pub async fn editor_delete(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    // check if puzzle was published before deleting (need to send delete to federation)
    let was_published = {
        let dbi = state.db.lock().await;
        let status: Option<String> = dbi.db.query_row(
            "SELECT status FROM user_puzzles WHERE id = ?1 AND session_token = ?2",
            rusqlite::params![id, session.token], |row| row.get(0),
        ).ok();
        status.as_deref() == Some("published")
    };

    {
        let dbi = state.db.lock().await;
        db::delete_user_puzzle(&dbi.db, &id, &session.token);
    }

    // notify federation of deletion
    if was_published {
        if let Some(ap) = state.ap.as_ref() {
            let object_uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, id);
            crate::activitypub::outbox::publish_delete(&state, &object_uri).await;
        }
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn editor_publish(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    {
        let dbi = state.db.lock().await;
        let ok = db::publish_user_puzzle(&dbi.db, &id, &session.token);
        if !ok { return Err(AppError("puzzle needs clues before publishing".into())); }
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn editor_export(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (title, w, h, grid_json, clues_json) = db::get_user_puzzle(&dbi.db, &id, &session.token)
        .map(|(_, title, w, h, grid, clues, _, _)| (title, w, h, grid, clues))
        .ok_or(AppError("puzzle not found".into()))?;

    let grid: Vec<Vec<String>> = serde_json::from_str(&grid_json).unwrap_or_default();
    let clues_data: Vec<serde_json::Value> = clues_json.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();

    // build ipuz
    let mut puzzle_grid = Vec::new();
    let mut solution_grid = Vec::new();
    let mut cell_numbers = std::collections::HashMap::new();
    for c in &clues_data {
        let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
        let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        cell_numbers.entry((sr, sc)).or_insert(num);
    }

    // build letter map from clues
    let mut letter_map: Vec<Vec<Option<char>>> = vec![vec![None; w as usize]; h as usize];
    for c in &clues_data {
        let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or("");
        let dir = c.get("direction").and_then(|v| v.as_str()).unwrap_or("");
        let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        for (i, ch) in answer.chars().enumerate() {
            let (r, c) = if dir == "across" { (sr, sc + i) } else { (sr + i, sc) };
            if r < h as usize && c < w as usize { letter_map[r][c] = Some(ch); }
        }
    }

    for r in 0..h as usize {
        let mut pr = Vec::new();
        let mut sr = Vec::new();
        for c in 0..w as usize {
            if grid.get(r).and_then(|row| row.get(c)).map(|v| v.as_str()) == Some("black") {
                pr.push(serde_json::json!("#"));
                sr.push(serde_json::json!("#"));
            } else {
                let num = cell_numbers.get(&(r, c));
                pr.push(if let Some(&n) = num { serde_json::json!(n) } else { serde_json::json!(0) });
                sr.push(if let Some(ch) = letter_map[r][c] { serde_json::json!(ch.to_string()) } else { serde_json::json!(null) });
            }
        }
        puzzle_grid.push(pr);
        solution_grid.push(sr);
    }

    let across: Vec<serde_json::Value> = clues_data.iter()
        .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
        .map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")]))
        .collect();
    let down: Vec<serde_json::Value> = clues_data.iter()
        .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
        .map(|c| serde_json::json!([c.get("number").and_then(|v| v.as_u64()).unwrap_or(0), c.get("text").and_then(|v| v.as_str()).unwrap_or("")]))
        .collect();

    let ipuz = serde_json::json!({
        "version": "http://ipuz.org/v2",
        "kind": ["http://ipuz.org/crossword#1"],
        "title": title,
        "dimensions": { "width": w, "height": h },
        "puzzle": puzzle_grid,
        "solution": solution_grid,
        "clues": { "Across": across, "Down": down },
    });

    let body = serde_json::to_string_pretty(&ipuz).map_err(|e| AppError(e.to_string()))?;
    let safe_title = sanitize_filename(&title.replace(' ', "_").to_lowercase());
    let filename = if safe_title.is_empty() { "puzzle.ipuz".to_string() } else { format!("{}.ipuz", safe_title) };
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/json"),
         (axum::http::header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename))],
        body,
    ).into_response())
}

pub async fn editor_export_all(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let puzzles = db::list_user_puzzles(&dbi.db, &session.token);
    let mut files: Vec<(String, String)> = Vec::new();

    for (id, _title, _, _, _, _) in &puzzles {
        if let Some((_, t, w, h, grid, clues, _, _)) = db::get_user_puzzle(&dbi.db, id, &session.token) {
            let ipuz = serde_json::json!({
                "version": "http://ipuz.org/v2",
                "kind": ["http://ipuz.org/crossword#1"],
                "title": t,
                "dimensions": { "width": w, "height": h },
                "grid": grid,
                "clues_data": clues,
            });
            let fname = format!("{}.ipuz", t.replace(' ', "_").to_lowercase());
            files.push((fname, serde_json::to_string_pretty(&ipuz).unwrap_or_default()));
        }
    }

    // build a simple concatenated JSON array as export (zip would need another dep)
    let export = serde_json::json!({ "puzzles": files.iter().map(|(name, data)| serde_json::json!({ "filename": name, "data": data })).collect::<Vec<_>>() });
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/json"),
         (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-puzzles-export.json\"")],
        serde_json::to_string_pretty(&export).unwrap_or_default(),
    ).into_response())
}

pub async fn editor_import(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let puzzles = req.get("puzzles").and_then(|v| v.as_array());
    let single = if puzzles.is_none() { Some(&req) } else { None };

    let items: Vec<&serde_json::Value> = if let Some(arr) = puzzles {
        arr.iter().collect()
    } else if let Some(s) = single {
        vec![s]
    } else {
        return Err(AppError("no puzzle data found".into()));
    };

    let mut imported = 0;
    let dbi = state.db.lock().await;

    for item in items {
        // parse ipuz format
        let ipuz = if let Some(data) = item.get("data") {
            // bulk export format: { filename, data }
            if let Some(s) = data.as_str() {
                serde_json::from_str::<serde_json::Value>(s).unwrap_or_default()
            } else {
                data.clone()
            }
        } else {
            item.clone()
        };

        let title = ipuz.get("title").and_then(|v| v.as_str()).unwrap_or("Imported puzzle");
        let dims = ipuz.get("dimensions");
        let w = dims.and_then(|d| d.get("width")).and_then(|v| v.as_u64()).unwrap_or(15) as usize;
        let h = dims.and_then(|d| d.get("height")).and_then(|v| v.as_u64()).unwrap_or(15) as usize;

        // build grid from ipuz puzzle array
        let mut grid: Vec<Vec<String>> = Vec::new();
        let mut letters: Vec<Vec<Option<String>>> = Vec::new();
        if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) {
            for row in puzzle_arr {
                let mut gr = Vec::new();
                let mut lr = Vec::new();
                if let Some(cells) = row.as_array() {
                    for cell in cells {
                        if cell.as_str() == Some("#") {
                            gr.push("black".to_string());
                            lr.push(None);
                        } else {
                            gr.push("white".to_string());
                            lr.push(None);
                        }
                    }
                }
                grid.push(gr);
                letters.push(lr);
            }
        }

        // extract solution letters
        if let Some(sol) = ipuz.get("solution").and_then(|v| v.as_array()) {
            for (r, row) in sol.iter().enumerate() {
                if let Some(cells) = row.as_array() {
                    for (c, cell) in cells.iter().enumerate() {
                        if let Some(s) = cell.as_str() {
                            if s != "#" && r < letters.len() && c < letters[r].len() {
                                letters[r][c] = Some(s.to_string());
                            }
                        }
                    }
                }
            }
        }

        // extract clues, recovering each answer from the solution grid so
        // imported puzzles are playable instead of shipping empty answers
        let mut editor_clues: Vec<serde_json::Value> = Vec::new();
        let answer_at = |r: usize, c: usize, dir_key: &str| -> String {
            let mut answer = String::new();
            if dir_key == "across" {
                let mut cc = c;
                while cc < w {
                    if let Some(Some(ch)) = letters.get(r).and_then(|row| row.get(cc)) {
                        answer.push_str(ch);
                        cc += 1;
                    } else {
                        break;
                    }
                }
            } else {
                let mut rr = r;
                while rr < h {
                    if let Some(Some(ch)) = letters.get(rr).and_then(|row| row.get(c)) {
                        answer.push_str(ch);
                        rr += 1;
                    } else {
                        break;
                    }
                }
            }
            answer
        };
        for (dir_name, dir_key) in [("Across", "across"), ("Down", "down")] {
            if let Some(clue_arr) = ipuz.get("clues").and_then(|c| c.get(dir_name)).and_then(|v| v.as_array()) {
                for clue in clue_arr {
                    if let Some(arr) = clue.as_array() {
                        if arr.len() >= 2 {
                            let num = arr[0].as_u64().unwrap_or(0);
                            let text = arr[1].as_str().unwrap_or("");
                            // find the numbered cell in the ipuz puzzle grid
                            let mut answer = String::new();
                            'find: {
                                if let Some(puzzle_arr) = ipuz.get("puzzle").and_then(|v| v.as_array()) {
                                    for (r, row) in puzzle_arr.iter().enumerate() {
                                        if let Some(cells) = row.as_array() {
                                            for (c, cell) in cells.iter().enumerate() {
                                                if cell.as_u64() == Some(num) {
                                                    answer = answer_at(r, c, dir_key);
                                                    break 'find;
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                            editor_clues.push(serde_json::json!({
                                "number": num, "direction": dir_key, "text": text, "answer": answer
                            }));
                        }
                    }
                }
            }
        }

        let id = Uuid::new_v4().to_string();
        let grid_str = serde_json::to_string(&grid).unwrap_or_default();
        let clues_str = if editor_clues.is_empty() { None } else { Some(serde_json::to_string(&editor_clues).unwrap_or_default()) };
        db::save_user_puzzle(&dbi.db, &id, &session.token, title, w, h,
            &grid_str, clues_str.as_deref(), None, "draft");
        imported += 1;
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": imported }))))
}

// ---- webhook endpoints ----

pub async fn create_webhook(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let url = req.get("url").and_then(|v| v.as_str()).ok_or(AppError("missing url".into()))?;
    if crate::security::is_internal_url(url) {
        return Err(AppError("webhook URL cannot target internal addresses".into()));
    }
    let events = req.get("events").and_then(|v| v.as_str()).unwrap_or("daily_puzzle.published");
    let secret = req.get("secret").and_then(|v| v.as_str());
    let format = req.get("format").and_then(|v| v.as_str()).unwrap_or("json");
    let id = Uuid::new_v4().to_string();

    let dbi = state.db.lock().await;
    db::create_webhook(&dbi.db, &id, url, events, secret, format);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}

pub async fn list_webhooks(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let hooks = db::list_webhooks(&dbi.db);
    let result: Vec<serde_json::Value> = hooks.iter().map(|(id, url, events, _, format, enabled, failures)| {
        serde_json::json!({ "id": id, "url": url, "events": events, "format": format, "enabled": enabled, "failure_count": failures })
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "webhooks": result }))))
}

pub async fn delete_webhook(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    db::delete_webhook(&dbi.db, &id);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn browse_community_puzzles(
    State(state): State<Arc<AppState>>,
    Query(params): Query<std::collections::HashMap<String, String>>,
    session: Session,
) -> Result<Response, AppError> {
    let limit: usize = params.get("limit").and_then(|l| l.parse().ok()).unwrap_or(50).min(100);
    let offset: usize = params.get("offset").and_then(|o| o.parse().ok()).unwrap_or(0);
    let dbi = state.db.lock().await;
    let puzzles = db::list_published_puzzles(&dbi.db, limit, offset);
    let result: Vec<serde_json::Value> = puzzles.iter().map(|(id, title, author, w, h, updated, origin)| {
        let check_domain = origin.as_deref().unwrap_or("");
        let rep = if !check_domain.is_empty() {
            Some(crate::db::get_instance_reputation(&dbi.db, check_domain))
        } else {
            None
        };
        let low_rep = rep.map(|s| s < 50.0).unwrap_or(false);
        serde_json::json!({
            "id": id, "title": title, "author": author,
            "width": w, "height": h, "updated_at": updated,
            "origin": origin, "origin_reputation": rep, "low_reputation": low_rep,
        })
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": result }))))
}

pub async fn community_puzzles_today(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT id, title, session_token, width, height, updated_at FROM user_puzzles
         WHERE status = 'published' AND created_at >= datetime('now', '-24 hours')
         ORDER BY created_at DESC LIMIT 50"
    ).map_err(|e| AppError(e.to_string()))?;
    let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
        let id: String = row.get(0)?;
        let title: String = row.get(1)?;
        let author: String = row.get(2)?;
        let w: i64 = row.get(3)?;
        let h: i64 = row.get(4)?;
        let updated: String = row.get(5)?;
        Ok(serde_json::json!({
            "id": id, "title": title, "author": author,
            "width": w, "height": h, "updated_at": updated,
        }))
    }).map_err(|e| AppError(e.to_string()))?.filter_map(|r| r.ok()).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "puzzles": puzzles }))))
}

pub async fn upload_daily_image(
    State(_state): State<Arc<AppState>>,
    ConnectInfo(source): ConnectInfo<std::net::SocketAddr>,
    headers: axum::http::HeaderMap,
    Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, AppError> {
    // the daily image script runs on the local machine; the socket address is
    // the only trustworthy way to prove that (request headers are not)
    let is_local = source.ip().is_loopback();
    let has_admin = headers.get("x-admin-session")
        .and_then(|v| v.to_str().ok())
        .map(|t| crate::admin::verify_admin_session(t))
        .unwrap_or(false);
    if !is_local && !has_admin {
        return Err(AppError::with_status(StatusCode::FORBIDDEN, "not authorized"));
    }
    let data = body.get("data").and_then(|d| d.as_str())
        .ok_or_else(|| AppError("missing data field".into()))?;
    // strip data:image/jpeg;base64, prefix if present
    let b64 = if let Some(idx) = data.find(",") { &data[idx + 1..] } else { data };
    use base64::Engine;
    let bytes = base64::engine::general_purpose::STANDARD.decode(b64)
        .map_err(|e| AppError(format!("invalid base64: {}", e)))?;
    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
    let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg");
    std::fs::write(&path, &bytes).map_err(|e| AppError(format!("failed to write image: {}", e)))?;
    tracing::info!("saved daily image ({} bytes)", bytes.len());
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn serve_daily_image(
    State(_state): State<Arc<AppState>>,
) -> Result<Response, AppError> {
    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
    let path = std::path::PathBuf::from(&data_dir).join("daily-image.jpg");
    if !path.exists() {
        return Err(AppError::with_status(StatusCode::NOT_FOUND, "no daily image"));
    }
    let bytes = std::fs::read(&path).map_err(|e| AppError(e.to_string()))?;
    Ok((
        [(axum::http::header::CONTENT_TYPE, "image/jpeg"),
         (axum::http::header::CACHE_CONTROL, "public, max-age=3600")],
        bytes,
    ).into_response())
}

pub async fn ap_post_permalink(
    State(state): State<Arc<AppState>>,
    headers: axum::http::HeaderMap,
    axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
) -> Result<Response, AppError> {
    let path = uri.path().to_string();

    // if the request wants AP JSON, look up the outbox activity
    let accept = headers.get("accept").and_then(|v| v.to_str().ok()).unwrap_or("");
    if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
        let dbi = state.db.lock().await;
        let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("");
        let note_uri = format!("https://{}{}", domain, path);
        let activity_id = format!("{}/activity", note_uri);
        if let Ok(json) = dbi.db.query_row(
            "SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0),
        ) {
            return Ok((
                [(axum::http::header::CONTENT_TYPE, "application/activity+json")],
                json,
            ).into_response());
        }
        return Err(AppError::with_status(StatusCode::NOT_FOUND, "post not found"));
    }

    // HTML permalink page
    let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("");
    let note_uri = format!("https://{}{}", domain, path);
    let activity_id = format!("{}/activity", note_uri);

    let dbi = state.db.lock().await;
    let activity_json = dbi.db.query_row(
        "SELECT activity FROM ap_outbox WHERE id = ?1", [&activity_id], |r| r.get::<_, String>(0),
    ).ok();
    drop(dbi);

    let (content, published, image_url) = if let Some(json) = &activity_json {
        let v: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
        let content = v.pointer("/object/content").and_then(|c| c.as_str()).unwrap_or("").to_string();
        let content = crate::security::sanitize_note_html(&content);
        let published = v.get("published").and_then(|p| p.as_str()).unwrap_or("").to_string();
        let image = v.pointer("/object/attachment/0/url").and_then(|u| u.as_str()).unwrap_or("").to_string();
        (content, published, image)
    } else {
        // post not in outbox - might be an old deleted post, show a minimal page
        (String::new(), String::new(), String::new())
    };

    let title = "cruciverb - daily puzzles";
    let description = "Five crossword puzzles in five sizes, from very easy to expert.";
    let og_image = if image_url.is_empty() { format!("https://{}/og-image.png", domain) } else { image_url.clone() };

    let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta property="og:title" content="{title}">
<meta property="og:description" content="{description}">
<meta property="og:image" content="{og_image}">
<meta property="og:url" content="{note_uri}">
<meta property="og:type" content="article">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{ background: #faf5f0; color: #1a1410; font-family: 'DM Sans', system-ui, sans-serif; }}
  .page {{ max-width: 36rem; margin: 0 auto; padding: 2rem 1.5rem; }}
  .masthead {{ text-align: center; border-bottom: 3px double #1a1410; padding-bottom: 1rem; margin-bottom: 1.5rem; }}
  .masthead h1 {{ font-family: 'Playfair Display', Georgia, serif; font-size: 1.5rem; font-weight: 900; text-transform: uppercase; letter-spacing: 0.05em; }}
  .masthead .date {{ font-family: 'JetBrains Mono', monospace; font-size: 0.625rem; color: #8a8078; text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.25rem; }}
  .content {{ font-size: 0.9rem; line-height: 1.7; color: #3a342e; }}
  .content a {{ color: #c9503e; }}
  .image {{ margin: 1.5rem 0; }}
  .image img {{ width: 100%; height: auto; border: 1px solid #d4cfc8; }}
  .cta {{ text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #d4cfc8; }}
  .cta a {{ display: inline-block; padding: 0.625rem 1.5rem; background: #1a1410; color: #faf5f0; text-decoration: none; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; }}
  .cta a:hover {{ background: #c9503e; }}
</style>
</head>
<body>
<div class="page">
  <div class="masthead">
    <h1>cruciverb</h1>
    {published_html}
  </div>
  {content_html}
  {image_html}
  <div class="cta">
    <a href="https://{domain}">play today's puzzles</a>
  </div>
</div>
</body>
</html>"#,
        title = crate::security::sanitize_html(title),
        description = crate::security::sanitize_html(description),
        og_image = crate::security::sanitize_html(&og_image),
        note_uri = crate::security::sanitize_html(&note_uri),
        domain = crate::security::sanitize_html(domain),
        published_html = if published.is_empty() { String::new() } else {
            format!("<div class=\"date\">{}</div>", crate::security::sanitize_html(&published))
        },
        content_html = if content.is_empty() {
            "<p style=\"text-align:center;color:#8a8078;font-style:italic;\">This post is no longer available.</p>".to_string()
        } else {
            format!("<div class=\"content\">{}</div>", content)
        },
        image_html = if image_url.is_empty() { String::new() } else {
            format!("<div class=\"image\"><img src=\"{}\" alt=\"Daily puzzle preview\"></div>", crate::security::sanitize_html(&image_url))
        },
    );

    Ok((
        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
        html,
    ).into_response())
}

pub async fn daily_post_page(
    State(state): State<Arc<AppState>>,
    Path(date): Path<String>,
) -> Result<Response, AppError> {
    // validate date format
    if chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").is_err() {
        return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid date"));
    }

    let domain = state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("localhost");
    let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d").unwrap();
    let today = chrono::Utc::now().date_naive();
    let is_past = parsed_date < today;
    let days_ago = (today - parsed_date).num_days();
    let formatted_date = parsed_date.format("%A, %B %-d, %Y").to_string();

    // get tagline/signoff for this date
    let (tagline, signoff) = {
        let dbi = state.db.lock().await;
        let ct = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_taglines");
        let cs = crate::activitypub::outbox::load_custom_lines(&dbi.db, "ap_daily_signoffs");
        let t = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::TAGLINES, &ct, "tagline").to_string();
        let s = crate::activitypub::outbox::pick_line(&date, crate::activitypub::outbox::SIGNOFFS, &cs, "signoff").to_string();
        (t, s)
    };

    let page_url = format!("https://{}/post/{}", domain, date);
    let image_url = format!("https://{}/daily-image", domain);
    let site_url = format!("https://{}", domain);
    let title = format!("cruciverb - {}", formatted_date);
    let description = format!("{}. {}", tagline, signoff);

    let archive_banner = if is_past {
        let ago = if days_ago == 1 { "yesterday".to_string() } else { format!("{} days ago", days_ago) };
        let today_url = format!("https://{}/post/{}", domain, today.format("%Y-%m-%d"));
        format!(
            r#"<div class="archive-banner"><div class="archive-banner-title">From the archive</div><div class="archive-banner-text">These puzzles were published {}. Solving them counts toward your history and stats, but not your daily streak.<br><a href="{}">See today's puzzles instead</a></div></div>"#,
            crate::security::sanitize_html(&ago),
            crate::security::sanitize_html(&today_url),
        )
    } else {
        String::new()
    };

    let html = format!(r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<meta property="og:title" content="{title}">
<meta property="og:description" content="{og_desc}">
<meta property="og:image" content="{image_url}">
<meta property="og:image:width" content="2560">
<meta property="og:image:height" content="1440">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:alt" content="Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert.">
<meta property="og:url" content="{page_url}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="cruciverb">
<meta property="article:published_time" content="{date}T00:00:00Z">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{title}">
<meta name="twitter:description" content="{og_desc}">
<meta name="twitter:image" content="{image_url}">
<meta name="description" content="{og_desc}">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="canonical" href="{page_url}">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{{margin:0;padding:0;box-sizing:border-box}}
:root{{
  --font-display:'Playfair Display',Georgia,serif;
  --font-body:'DM Sans',system-ui,sans-serif;
  --font-mono:'JetBrains Mono',ui-monospace,monospace;
  --bg:#f8f6f3;--bg-alt:#efecea;--surface:#ffffff;
  --text:#0d0d0d;--text-secondary:#454545;--text-tertiary:#4a4a4a;
  --border:#e0dcd8;--border-light:#c5c0ba;
  --accent:#d4432f;--accent-hover:#ba3825;
}}
@media(prefers-color-scheme:dark){{:root{{
  --bg:#111111;--bg-alt:#191919;--surface:#1a1a1a;
  --text:#ededed;--text-secondary:#b5b5b5;--text-tertiary:#ababab;
  --border:#2a2a2a;--border-light:#222;
  --accent:#e8594a;--accent-hover:#d04a3c;
}}}}
body{{font-family:var(--font-body);background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh;display:flex;flex-direction:column;align-items:center}}
.page{{width:100%;max-width:40rem;padding:3rem 2rem 4rem}}
.masthead{{text-align:center;margin-bottom:2rem}}
.masthead-rule{{height:2px;background:var(--text);width:100%}}
.masthead h1{{font-family:var(--font-display);font-size:3rem;font-weight:900;letter-spacing:-0.03em;line-height:1;margin:0.75rem 0 0.25rem;text-transform:uppercase}}
.masthead-date{{font-family:var(--font-mono);font-size:0.563rem;text-transform:uppercase;letter-spacing:0.2em;color:var(--text-tertiary)}}
.masthead-sub{{font-family:var(--font-display);font-size:0.875rem;font-weight:400;font-style:italic;color:var(--text-secondary);margin:0.5rem 0 0.75rem}}
.tagline{{font-family:var(--font-display);font-style:italic;font-size:0.9rem;color:var(--text-secondary);text-align:center;margin:1.5rem 0}}
.puzzle-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:var(--border);border:1px solid var(--border);margin:1.5rem 0}}
.puzzle-card{{background:var(--surface);padding:1rem 0.5rem;text-align:center;text-decoration:none;color:var(--text);transition:background 200ms}}
.puzzle-card:hover{{background:var(--bg-alt)}}
.puzzle-card-name{{font-family:var(--font-display);font-size:0.875rem;font-weight:700;display:block}}
.puzzle-card-meta{{font-family:var(--font-mono);font-size:0.5rem;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.08em;margin-top:0.25rem;display:block}}
.signoff{{font-family:var(--font-body);font-size:0.75rem;color:var(--text-tertiary);text-align:center;margin:0.5rem 0 1.5rem}}
.post-image{{margin:1.5rem 0;border:1px solid var(--border)}}
.post-image img{{width:100%;height:auto;display:block}}
.hashtags{{display:flex;gap:0.5rem;justify-content:center;margin:1rem 0}}
.hashtags a{{font-family:var(--font-mono);font-size:0.563rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-tertiary);text-decoration:none;padding:0.25rem 0.625rem;border:1px solid var(--border)}}
.hashtags a:hover{{color:var(--accent);border-color:var(--accent)}}
.cta{{text-align:center;margin-top:2rem;padding-top:1.5rem;border-top:1px solid var(--border)}}
.cta a{{display:inline-block;padding:0.75rem 2rem;background:var(--text);color:var(--bg);text-decoration:none;font-family:var(--font-mono);font-size:0.688rem;text-transform:uppercase;letter-spacing:0.1em;transition:background 200ms}}
.cta a:hover{{background:var(--accent)}}
.footer{{text-align:center;margin-top:2rem;font-family:var(--font-mono);font-size:0.5rem;color:var(--text-tertiary);letter-spacing:0.1em;text-transform:uppercase}}
.footer a{{color:var(--text-tertiary);text-decoration:none}}
.footer a:hover{{color:var(--accent)}}
@media(max-width:640px){{
  .page{{padding:1.5rem 1rem 3rem}}
  .masthead h1{{font-size:2rem}}
  .puzzle-grid{{grid-template-columns:repeat(3,1fr)}}
}}
@media(max-width:380px){{.puzzle-grid{{grid-template-columns:repeat(2,1fr)}}}}
.archive-banner{{background:var(--bg-alt);border:1px solid var(--border);padding:1rem 1.25rem;margin-bottom:1.5rem;text-align:center}}
.archive-banner-title{{font-family:var(--font-display);font-size:0.875rem;font-style:italic;color:var(--text);margin-bottom:0.375rem}}
.archive-banner-text{{font-family:var(--font-body);font-size:0.75rem;color:var(--text-secondary);line-height:1.6}}
.archive-banner a{{color:var(--accent);text-decoration:none}}
.archive-banner a:hover{{text-decoration:underline}}
</style>
</head>
<body>
<div class="page">
  <div class="masthead">
    <div class="masthead-rule"></div>
    <h1>cruciverb</h1>
    <div class="masthead-date">{formatted_date}</div>
    <div class="masthead-sub">daily puzzles</div>
    <div class="masthead-rule"></div>
  </div>
  {archive_banner}
  <div class="tagline">{tagline}</div>
  <div class="puzzle-grid">
    <a class="puzzle-card" href="{site_url}/daily/glyph"><span class="puzzle-card-name">Glyph</span><span class="puzzle-card-meta">5x5 very easy</span></a>
    <a class="puzzle-card" href="{site_url}/daily/rune"><span class="puzzle-card-name">Rune</span><span class="puzzle-card-meta">5x5 easy</span></a>
    <a class="puzzle-card" href="{site_url}/daily/scroll"><span class="puzzle-card-name">Scroll</span><span class="puzzle-card-meta">9x9 medium</span></a>
    <a class="puzzle-card" href="{site_url}/daily/codex"><span class="puzzle-card-name">Codex</span><span class="puzzle-card-meta">15x15 hard</span></a>
    <a class="puzzle-card" href="{site_url}/daily/grimoire"><span class="puzzle-card-name">Grimoire</span><span class="puzzle-card-meta">21x21 expert</span></a>
  </div>
  <div class="signoff">{signoff}</div>
  <div class="post-image"><img src="{image_url}" alt="Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert."></div>
  <div class="hashtags">
    <a href="{site_url}/tags/crossword">#crossword</a>
    <a href="{site_url}/tags/puzzle">#puzzle</a>
    <a href="{site_url}/tags/cruciverbDaily">#cruciverbDaily</a>
  </div>
  <div class="cta"><a href="{site_url}">play today's puzzles</a></div>
  <div class="footer"><a href="{site_url}">cruciverb.lashman.live</a></div>
</div>
</body>
</html>"##,
        title = crate::security::sanitize_html(&title),
        og_desc = crate::security::sanitize_html(&description),
        image_url = crate::security::sanitize_html(&image_url),
        page_url = crate::security::sanitize_html(&page_url),
        site_url = crate::security::sanitize_html(&site_url),
        formatted_date = crate::security::sanitize_html(&formatted_date),
        date = crate::security::sanitize_html(&date),
        tagline = crate::security::sanitize_html(&tagline),
        signoff = crate::security::sanitize_html(&signoff),
        archive_banner = archive_banner,
    );

    Ok((
        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
         (axum::http::header::CACHE_CONTROL, "public, max-age=86400")],
        html,
    ).into_response())
}

pub async fn list_federated_puzzles(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let puzzles = db::list_federated_puzzles(&dbi.db, 20, 0);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "puzzles": puzzles.iter().map(|(id, title, w, h, origin, date)| {
            serde_json::json!({ "id": id, "title": title, "width": w, "height": h, "origin": origin, "updated_at": date })
        }).collect::<Vec<_>>()
    }))))
}

pub async fn play_community_puzzle(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (title, w, h, grid_json, clues_json) = db::get_published_puzzle(&dbi.db, &id)
        .ok_or(AppError("puzzle not found".into()))?;

    let grid: Vec<Vec<String>> = serde_json::from_str(&grid_json).unwrap_or_default();
    let clues: Vec<serde_json::Value> = serde_json::from_str(&clues_json).unwrap_or_default();

    // build cells array for client
    let cells: Vec<Vec<&str>> = grid.iter().map(|row| {
        row.iter().map(|c| if c == "black" { "black" } else { "empty" }).collect()
    }).collect();

    // build cell_numbers and extract letters for solution checking
    let mut cell_numbers = std::collections::HashMap::new();
    let mut solution: Vec<Vec<Option<String>>> = vec![vec![None; w as usize]; h as usize];

    for clue in &clues {
        let num = clue.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
        let dir = clue.get("direction").and_then(|v| v.as_str()).unwrap_or("");
        let answer = clue.get("answer").and_then(|v| v.as_str()).unwrap_or("");
        let start_row = clue.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let start_col = clue.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;

        cell_numbers.insert(format!("{},{}", start_row, start_col), num);

        for (i, ch) in answer.chars().enumerate() {
            let (r, c) = if dir == "across" { (start_row, start_col + i) } else { (start_row + i, start_col) };
            if r < h as usize && c < w as usize {
                solution[r][c] = Some(ch.to_string());
            }
        }
    }

    let client_clues_across: Vec<serde_json::Value> = clues.iter()
        .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
        .map(|c| serde_json::json!({
            "number": c.get("number"), "direction": "across",
            "text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""),
        })).collect();

    let client_clues_down: Vec<serde_json::Value> = clues.iter()
        .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
        .map(|c| serde_json::json!({
            "number": c.get("number"), "direction": "down",
            "text": c.get("text").and_then(|v| v.as_str()).unwrap_or(""),
        })).collect();

    // store as a playable puzzle so validate/hint routes work
    let puzzle_id = Uuid::new_v4();
    let mut grid_obj = cruciverb_core::grid::Grid::new(w as usize, h as usize);
    for (r, row) in grid.iter().enumerate() {
        for (c, cell) in row.iter().enumerate() {
            if cell == "black" {
                grid_obj.set(r, c, cruciverb_core::grid::Cell::Black);
            } else if let Some(ref letter) = solution[r][c] {
                if let Some(ch) = letter.chars().next() {
                    grid_obj.set(r, c, cruciverb_core::grid::Cell::Letter(ch));
                }
            }
        }
    }

    // build the placed-words list as well, so hint reveal-word and other
    // word-based routes behave like generated puzzles
    let mut placed_words: Vec<cruciverb_core::puzzle::PlacedWord> = Vec::new();
    for c in clues.iter() {
        let num = c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
        let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or("");
        if answer.is_empty() {
            continue;
        }
        let sr = c.get("startRow").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let sc = c.get("startCol").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let direction = if c.get("direction").and_then(|v| v.as_str()) == Some("down") {
            cruciverb_core::grid::slot::Direction::Down
        } else {
            cruciverb_core::grid::slot::Direction::Across
        };
        placed_words.push(cruciverb_core::puzzle::PlacedWord {
            word: answer.to_string(),
            row: sr,
            col: sc,
            direction,
            clue_number: num,
        });
    }

    let puzzle = cruciverb_core::puzzle::Puzzle {
        grid: grid_obj,
        words: placed_words,
        clues_across: clues.iter()
            .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("across"))
            .map(|c| cruciverb_core::puzzle::Clue {
                number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
                direction: cruciverb_core::grid::slot::Direction::Across,
                text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                definition: None, wordplay_type: None,
            }).collect(),
        clues_down: clues.iter()
            .filter(|c| c.get("direction").and_then(|v| v.as_str()) == Some("down"))
            .map(|c| cruciverb_core::puzzle::Clue {
                number: c.get("number").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
                direction: cruciverb_core::grid::slot::Direction::Down,
                text: c.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                answer: c.get("answer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
                definition: None, wordplay_type: None,
            }).collect(),
        width: w as usize, height: h as usize,
        symmetry: cruciverb_core::config::SymmetryType::None,
        difficulty_score: 0, generation_time_ms: 0,
        theme: None, theme_entries: vec![], is_cryptic: false,
    };

    let short_id = db::make_short_id(&puzzle_id);
    state.cache_puzzle(puzzle_id, puzzle).await;

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "id": puzzle_id.to_string(),
        "short_id": short_id,
        "puzzle": {
            "width": w, "height": h,
            "cells": cells,
            "clues_across": client_clues_across,
            "clues_down": client_clues_down,
            "difficulty_score": 0,
            "cell_numbers": cell_numbers,
        },
        "title": title,
        "community": true,
    }))))
}

fn has_offensive_pattern(cells: &[Vec<&str>], width: usize, height: usize) -> bool {
    // detect swastika-like patterns by checking for the characteristic
    // bent cross shape at various positions and scales
    let is_black = |r: i32, c: i32| -> bool {
        if r < 0 || c < 0 || r >= height as i32 || c >= width as i32 { return false; }
        cells[r as usize][c as usize] == "black"
    };

    // check at different scales (arm length 2-5)
    for arm in 2..=5i32 {
        for cr in 0..height as i32 {
            for cc in 0..width as i32 {
                // a swastika has 4 arms from a center, each bending at a right angle
                // check both clockwise and counterclockwise variants
                for &dir in &[1i32, -1] {
                    // center must be black
                    if !is_black(cr, cc) { continue; }

                    // 4 arms: up-right, right-down, down-left, left-up (or mirrored)
                    let arms_ok = (1..=arm).all(|i| is_black(cr - i, cc))      // up
                        && is_black(cr - arm, cc + dir)                         // up arm bends
                        && (1..=arm).all(|i| is_black(cr, cc + i))             // right
                        && is_black(cr + dir, cc + arm)                        // right arm bends
                        && (1..=arm).all(|i| is_black(cr + i, cc))             // down
                        && is_black(cr + arm, cc - dir)                        // down arm bends
                        && (1..=arm).all(|i| is_black(cr, cc - i))             // left
                        && is_black(cr - dir, cc - arm);                       // left arm bends

                    if arms_ok { return true; }
                }
            }
        }
    }
    // also check for SS bolts (two parallel zigzag / lightning bolt shapes)
    // the sig rune looks like: go down, shift right, go down (or mirrored)
    for arm in 2..=4i32 {
        for cr in 0..height as i32 - arm * 2 {
            for cc in 0..width as i32 - arm - 2 {
                for &dir in &[1i32, -1] {
                    // single bolt: vertical-diagonal-vertical pattern
                    let bolt = |sc: i32| -> bool {
                        (0..arm).all(|i| is_black(cr + i, sc))
                            && (1..=arm).all(|i| is_black(cr + arm - 1 + i, sc + dir * i))
                            && (0..arm).all(|i| is_black(cr + arm * 2 - 1 + i, sc + dir * arm))
                    };
                    // two parallel bolts = SS
                    if bolt(cc) && bolt(cc + arm + 1) {
                        return true;
                    }
                }
            }
        }
    }

    false
}

fn load_theme_words(theme: &str) -> Option<std::collections::HashSet<String>> {
    let safe_name: 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_name));
    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) }
}

pub async fn list_themes() -> Json<serde_json::Value> {
    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 = match std::fs::read_to_string(&path) {
                    Ok(c) => c.lines().filter(|l| !l.trim().is_empty()).count(),
                    Err(e) => {
                        tracing::warn!("failed to read theme {}: {}", name, e);
                        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()));
    Json(serde_json::json!({ "themes": themes }))
}

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

// ---- puzzle rating endpoints ----

pub async fn rate_puzzle(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    let rating = req.get("rating").and_then(|v| v.as_i64())
        .ok_or(AppError("missing rating".into()))?;
    if !(1..=5).contains(&rating) {
        return Err(AppError("rating must be 1-5".into()));
    }

    let dbi = state.db.lock().await;
    let prev = db::get_my_rating(&dbi.db, &session.token, &id);
    db::rate_puzzle(&dbi.db, &session.token, &id, rating as i32);
    // first rating counts fully; changing an existing rating applies only the delta
    let delta = rating as i64 - prev.unwrap_or(0) as i64;
    db::update_quality_from_rating(&dbi.db, &id, delta, prev.is_none());
    let (avg, count) = db::get_puzzle_rating(&dbi.db, &id);

    // refresh quality caches
    *state.word_quality.write().await = db::get_word_quality_scores(&dbi.db);
    *state.clue_quality.write().await = db::get_clue_quality_scores(&dbi.db);

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "average": (avg * 10.0).round() / 10.0,
        "count": count,
        "my_rating": rating,
    }))))
}

pub async fn get_rating(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let (avg, count) = db::get_puzzle_rating(&dbi.db, &id);
    let my = db::get_my_rating(&dbi.db, &session.token, &id);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "average": (avg * 10.0).round() / 10.0,
        "count": count,
        "my_rating": my,
    }))))
}

pub async fn get_daily_ratings(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(date): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let ratings = db::get_daily_ratings(&dbi.db, &date);
    let result: serde_json::Map<String, serde_json::Value> = ratings.iter().map(|(preset, avg, count)| {
        (preset.clone(), serde_json::json!({
            "average": (*avg * 10.0).round() / 10.0,
            "count": count,
        }))
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!(result))))
}

// ---- community clue endpoints ----

pub async fn submit_community_clue(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "submit_clue", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let word = req.get("word").and_then(|v| v.as_str())
        .ok_or(AppError("missing word".into()))?;
    let clue_text = req.get("clue").and_then(|v| v.as_str())
        .ok_or(AppError("missing clue".into()))?;

    if word.is_empty() || clue_text.is_empty() {
        return Err(AppError("word and clue must not be empty".into()));
    }
    if !word.chars().all(|c| c.is_alphabetic()) {
        return Err(AppError("word must contain only letters".into()));
    }
    if clue_text.len() > 500 {
        return Err(AppError("clue too long (max 500 characters)".into()));
    }

    if db::text_contains_url(clue_text) {
        return Err(AppError("clues cannot contain URLs".into()));
    }

    let clean_clue = crate::security::sanitize_html(clue_text);
    if state.blocklist.read().unwrap().contains_blocked(&clean_clue) {
        return Err(AppError::with_status(axum::http::StatusCode::BAD_REQUEST, "clue contains inappropriate content"));
    }
    let dbi = state.db.lock().await;
    let id = db::submit_clue(&dbi.db, word, &clean_clue, &session.token);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}

pub async fn get_community_clues(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(word): Path<String>,
) -> Result<Response, AppError> {
    if !word.chars().all(|c| c.is_alphabetic()) {
        return Err(AppError("invalid word".into()));
    }

    let dbi = state.db.lock().await;

    // check federated content filter
    let settings_json = db::get_settings(&dbi.db, &session.token);
    let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
    let filter = settings.get("federatedContentFilter").and_then(|v| v.as_str()).unwrap_or("all");
    let blocked_instances: Vec<String> = settings.get("blockedInstances")
        .and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
        .unwrap_or_default();

    let clues = db::get_community_clues(&dbi.db, &word);
    let clue_ids: Vec<i64> = clues.iter().map(|(id, ..)| *id).collect();
    let user_votes = db::get_user_votes(&dbi.db, &session.token, &clue_ids);

    let result: Vec<serde_json::Value> = clues.iter()
        .filter(|(_, _, _, _, _, _, origin)| {
            match filter {
                "local" => origin.is_none(),
                "approved" => origin.is_none() || true, // show all but could filter more
                _ => true,
            }
        })
        .filter(|(_, _, _, _, _, _, origin)| {
            if let Some(d) = origin { !blocked_instances.contains(d) } else { true }
        })
        .map(|(id, text, up, down, status, created, origin)| {
            let my_vote = user_votes.iter().find(|(cid, _)| cid == id).map(|(_, v)| *v).unwrap_or(0);
            serde_json::json!({
                "id": id, "clue": text, "upvotes": up, "downvotes": down,
                "status": status, "created_at": created, "my_vote": my_vote,
                "origin_domain": origin,
            })
        }).collect();

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "clues": result }))))
}

pub async fn vote_community_clue(
    State(state): State<Arc<AppState>>,
    session: Session,
    headers: axum::http::HeaderMap,
    Path(id): Path<i64>,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "vote_clue", 30, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let ip = crate::security::extract_client_ip(&headers);
    crate::security::check_rate_limit_ip(&ip, "vote_clue", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let vote = req.get("vote").and_then(|v| v.as_i64())
        .ok_or(AppError("missing vote".into()))?;
    if vote != 1 && vote != -1 {
        return Err(AppError("vote must be 1 or -1".into()));
    }

    let dbi = state.db.lock().await;
    db::vote_clue(&dbi.db, &session.token, id, vote as i32);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn my_submissions(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let subs = db::get_my_submissions(&dbi.db, &session.token);
    let result: Vec<serde_json::Value> = subs.iter().map(|(id, word, clue, up, down, status)| {
        serde_json::json!({
            "id": id, "word": word, "clue": clue,
            "upvotes": up, "downvotes": down, "status": status,
        })
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "submissions": result }))))
}

pub async fn delete_submission(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<i64>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    db::delete_submission(&dbi.db, id, &session.token);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

// ---- passkey endpoints ----

pub async fn passkey_register_start(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let challenge = crate::webauthn::generate_challenge();
    let name = session.display_name.as_deref().unwrap_or("cruciverb player");
    let options = crate::webauthn::create_registration_challenge(
        &challenge, &state.rp_id, "cruciverb", &session.token, name,
    );

    let now = chrono::Utc::now().timestamp();
    state.reg_challenges.lock().await.insert(challenge, (session.token.clone(), now));

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "options": options }))))
}

pub async fn passkey_register_finish(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(response): Json<crate::webauthn::RegistrationResponse>,
) -> Result<Response, AppError> {
    // find and consume the challenge (clean expired first)
    let mut challenges = state.reg_challenges.lock().await;
    let now = chrono::Utc::now().timestamp();
    challenges.retain(|_, (_, ts)| now - *ts < 300);
    let challenge = challenges.iter()
        .find(|(_, (tok, _))| tok == &session.token)
        .map(|(c, _)| c.clone());
    let challenge = challenge.ok_or(AppError("no pending registration".into()))?;
    challenges.remove(&challenge);
    drop(challenges);

    let credential = crate::webauthn::verify_registration(
        &response, &challenge, &state.rp_origin, &state.rp_id,
    ).map_err(|e| AppError(format!("registration failed: {}", e)))?;

    let cred_id = base64::Engine::encode(
        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
        &credential.credential_id,
    );
    let passkey_json = serde_json::to_string(&credential)
        .map_err(|e| AppError(e.to_string()))?;

    let dbi = state.db.lock().await;
    db::save_passkey(&dbi.db, &cred_id, &session.token, &passkey_json, "My passkey");

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn passkey_auth_start(
    State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
    let challenge = crate::webauthn::generate_challenge();
    let now = chrono::Utc::now().timestamp();

    // for discoverable credentials, we send an empty allow list
    let options = crate::webauthn::create_authentication_challenge(&challenge, &state.rp_id, &[]);

    state.auth_challenges.lock().await.insert(challenge, now);

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

pub async fn passkey_auth_finish(
    State(state): State<Arc<AppState>>,
    Json(response): Json<crate::webauthn::AuthenticationResponse>,
) -> Result<Response, AppError> {
    // look up the credential by ID
    let dbi = state.db.lock().await;
    let (passkey_json, session_token) = db::get_passkey_json(&dbi.db, &response.id)
        .ok_or(AppError("unknown credential".into()))?;
    drop(dbi);

    let credential: crate::webauthn::StoredCredential = serde_json::from_str(&passkey_json)
        .map_err(|e| AppError(format!("corrupt credential: {}", e)))?;

    // find and consume a valid challenge
    let mut challenges = state.auth_challenges.lock().await;
    let now = chrono::Utc::now().timestamp();
    // clean up expired challenges (> 2 min)
    challenges.retain(|_, ts| now - *ts < 120);

    // try each challenge until one matches (the client embeds the challenge in clientDataJSON)
    let mut matched: Option<(String, u32)> = None;
    for (ch, _) in challenges.iter() {
        match crate::webauthn::verify_authentication(
            &response, &credential, ch, &state.rp_origin, &state.rp_id,
        ) {
            Ok(counter) => {
                matched = Some((ch.clone(), counter));
                break;
            }
            Err(_) => continue,
        }
    }

    let (challenge, auth_counter) = matched.ok_or(AppError("authentication failed".into()))?;
    challenges.remove(&challenge);
    drop(challenges);

    // signature counter: catch clones of device-bound keys without breaking
    // synced passkeys, which always report zero
    let mut credential = credential;
    {
        let dbi = state.db.lock().await;
        if auth_counter > 0 && credential.counter > 0 && auth_counter <= credential.counter {
            tracing::warn!("possible cloned authenticator: counter did not advance");
            crate::db::log_admin_action(&dbi.db, "possible_cloned_key", None, None);
            return Err(AppError::with_status(
                axum::http::StatusCode::FORBIDDEN,
                "authentication failed - possible cloned authenticator",
            ));
        }
        if auth_counter > credential.counter {
            credential.counter = auth_counter;
            if let Ok(json) = serde_json::to_string(&credential) {
                crate::db::update_passkey_json(&dbi.db, &response.id, &json);
            }
        }
    }

    // create a session with the original token
    let dbi = state.db.lock().await;
    let (token, name) = db::get_or_create_session(&dbi.db, &session_token);
    let settings_json = db::get_settings(&dbi.db, &token);
    let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
        .ok()
        .and_then(|v| v.get("cookieLifetime")?.as_u64())
        .unwrap_or(2_592_000);

    let session = Session {
        token, display_name: name, is_new: false, cookie_max_age,
    };

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "ok": true,
        "display_name": session.display_name,
    }))))
}

pub async fn list_passkeys(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let passkeys = db::get_passkeys(&dbi.db, &session.token);
    let result: Vec<serde_json::Value> = passkeys.iter().map(|(id, name, created)| {
        serde_json::json!({ "id": id, "name": name, "created_at": created })
    }).collect();
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "passkeys": result }))))
}

// ---- privacy / account endpoints ----

pub async fn get_session_info(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let info = dbi.db.query_row(
        "SELECT created_at, last_seen FROM sessions WHERE token = ?1",
        [&session.token],
        |row| Ok(serde_json::json!({
            "created_at": row.get::<_, String>(0)?,
            "last_seen": row.get::<_, String>(1)?,
        })),
    ).unwrap_or(serde_json::json!(null));
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "session": info }))))
}

pub async fn delete_account(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let puzzle_ids = {
        let dbi = state.db.lock().await;
        db::get_user_published_puzzles(&dbi.db, &session.token)
    };

    // send federation deletes for published puzzles
    for pid in &puzzle_ids {
        if let Some(ap) = state.ap.as_ref() {
            let uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, pid);
            crate::activitypub::outbox::publish_delete(&state, &uri).await;
        }
    }

    let dbi = state.db.lock().await;
    db::cleanup_user_content(&dbi.db, &session.token);
    db::delete_account(&dbi.db, &session.token);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true }))))
}

pub async fn export_data(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "export_data", 3, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let dbi = state.db.lock().await;
    let data = db::export_user_data(&dbi.db, &session.token);
    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/json"),
         (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"cruciverb-export.json\"")],
        serde_json::to_string_pretty(&data).unwrap_or_default(),
    ).into_response())
}

pub async fn import_data(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(data): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "import_data", 3, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;

    let dbi = state.db.lock().await;
    let (imported, skipped) = db::import_user_data(&dbi.db, &session.token, &data);

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "imported": imported,
        "skipped": skipped,
    }))))
}

// ---- user migration ----

pub async fn migrate_account(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "migrate", 2, 300)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;

    let target_domain = req.get("target_domain").and_then(|v| v.as_str())
        .ok_or_else(|| AppError::with_status(StatusCode::BAD_REQUEST, "missing target_domain"))?;

    if target_domain.is_empty() || !target_domain.contains('.') {
        return Err(AppError::with_status(StatusCode::BAD_REQUEST, "invalid target domain"));
    }

    // get published puzzles before cleanup
    let puzzle_ids = {
        let dbi = state.db.lock().await;
        db::get_user_published_puzzles(&dbi.db, &session.token)
    };

    // send federation deletes for published puzzles
    for pid in &puzzle_ids {
        if let Some(ap) = state.ap.as_ref() {
            let uri = format!("https://{}/ap/community-puzzles/{}", ap.domain, pid);
            crate::activitypub::outbox::publish_delete(&state, &uri).await;
        }
    }

    // send Move activity to all peers
    crate::activitypub::outbox::publish_move(&state, target_domain).await;

    // export user data before cleanup
    let dbi = state.db.lock().await;
    let data = db::export_user_data(&dbi.db, &session.token);
    db::cleanup_user_content(&dbi.db, &session.token);
    drop(dbi);

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "ok": true,
        "target": target_domain,
        "export": data,
    }))))
}

// ---- user blocklist import/export ----

pub async fn export_user_blocklist(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let settings_json = db::get_settings(&dbi.db, &session.token);
    let settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();
    let blocked = settings.get("blockedInstances").and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>())
        .unwrap_or_default();

    let body = serde_json::json!({
        "format": "cruciverb-user-blocklist-v1",
        "exported_at": chrono::Utc::now().to_rfc3339(),
        "count": blocked.len(),
        "domains": blocked,
    });

    Ok((
        [(axum::http::header::CONTENT_TYPE, "application/json"),
         (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"my-blocklist.json\"")],
        serde_json::to_string_pretty(&body).unwrap_or_default(),
    ).into_response())
}

pub async fn import_user_blocklist(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let domains = req.get("domains").and_then(|v| v.as_array())
        .or_else(|| req.get("blocked").and_then(|v| v.as_array()));

    let new_domains: Vec<String> = match domains {
        Some(arr) => arr.iter().filter_map(|v| {
            let d = if let Some(s) = v.as_str() { s.to_string() }
                    else if let Some(s) = v.get("domain").and_then(|d| d.as_str()) { s.to_string() }
                    else { return None };
            let d = d.trim().to_lowercase();
            if d.is_empty() { None } else { Some(d) }
        }).collect(),
        None => return Err(AppError("missing domains array".into())),
    };

    let dbi = state.db.lock().await;
    let settings_json = db::get_settings(&dbi.db, &session.token);
    let mut settings: serde_json::Value = serde_json::from_str(&settings_json).unwrap_or_default();

    let mut existing: Vec<String> = settings.get("blockedInstances")
        .and_then(|v| v.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
        .unwrap_or_default();

    let mut added = 0;
    for d in &new_domains {
        if !existing.contains(d) { existing.push(d.clone()); added += 1; }
    }

    settings["blockedInstances"] = serde_json::to_value(&existing).unwrap_or_default();
    db::save_settings(&dbi.db, &session.token, &settings.to_string());

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "imported": added, "total": existing.len() }))))
}

// ---- word of the day ----

pub async fn word_of_the_day(
    State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();

    if state.wotd_words.is_empty() {
        return Json(serde_json::json!({
            "word": "CRUCIVERB",
            "definition": "One who creates or solves crossword puzzles.",
            "clue": "Crossword enthusiast",
            "length": 9
        }));
    }

    // deterministic pick based on date hash
    let hash = Sha256::digest(format!("wotd-{}", today).as_bytes());
    let idx = u64::from_le_bytes(hash[..8].try_into().unwrap()) as usize % state.wotd_words.len();
    let word = &state.wotd_words[idx];

    let definition = state.definitions.get(word)
        .or_else(|| state.definitions.get(&word.to_lowercase()))
        .or_else(|| {
            // try case-insensitive lookup
            state.definitions.iter()
                .find(|(k, _)| k.to_uppercase() == *word)
                .map(|(_, v)| v)
        })
        .cloned()
        .unwrap_or_default();

    // trim long definitions to something readable
    let def_short = if definition.len() > 300 {
        let cut = definition[..300].rfind(|c: char| c == '.' || c == ';').unwrap_or(300);
        format!("{}...", &definition[..cut])
    } else {
        definition
    };

    let clue = state.clue_db.get_clue(word, 1).unwrap_or_else(|_| "Crossword word".into());

    Json(serde_json::json!({
        "word": word,
        "definition": def_short,
        "clue": clue,
        "length": word.len()
    }))
}

// ---- clue trainer ----

#[derive(serde::Deserialize)]
pub struct TrainerQuery {
    difficulty: Option<String>,
}

pub async fn trainer_clue(
    State(state): State<Arc<AppState>>,
    Query(q): Query<TrainerQuery>,
) -> Json<serde_json::Value> {
    let difficulty = q.difficulty.as_deref().unwrap_or("easy");

    let clues = state.clue_db.random_clues(1);
    if clues.is_empty() {
        return Json(serde_json::json!({
            "clue": "No clues available",
            "length": 0,
            "pattern": "",
            "difficulty": difficulty
        }));
    }

    let (clue_text, word) = &clues[0];
    let chars: Vec<char> = word.chars().collect();

    let pattern = match difficulty {
        "easy" => {
            // show first and last letter
            chars.iter().enumerate().map(|(i, c)| {
                if i == 0 || i == chars.len() - 1 { c.to_string() } else { "_".to_string() }
            }).collect::<Vec<_>>().join(" ")
        }
        "medium" => {
            // just underscores
            chars.iter().map(|_| "_".to_string()).collect::<Vec<_>>().join(" ")
        }
        _ => String::new(), // hard: no pattern
    };

    Json(serde_json::json!({
        "clue": clue_text,
        "length": word.len(),
        "pattern": pattern,
        "difficulty": difficulty
    }))
}

#[derive(serde::Deserialize)]
pub struct TrainerCheckRequest {
    clue: String,
    answer: String,
}

pub async fn trainer_check(
    State(state): State<Arc<AppState>>,
    Json(req): Json<TrainerCheckRequest>,
) -> Json<serde_json::Value> {
    let answer_upper = req.answer.trim().to_uppercase();

    // look up what word goes with this clue
    // we stored the clue text, so search for a matching word
    let mut correct_word = None;
    if let Some(ref db) = state.clue_db.db_ref() {
        if let Ok(mut stmt) = db.prepare(
            "SELECT word FROM clues WHERE clue = ?1 LIMIT 1"
        ) {
            if let Ok(rows) = stmt.query_map(rusqlite::params![req.clue], |row| row.get::<_, String>(0)) {
                for w in rows.flatten() {
                    correct_word = Some(w);
                    break;
                }
            }
        }
    }

    // fallback: search json clues
    if correct_word.is_none() {
        correct_word = state.clue_db.find_word_for_clue(&req.clue);
    }

    let answer = correct_word.unwrap_or_default();
    let is_correct = answer_upper == answer.to_uppercase();

    Json(serde_json::json!({
        "correct": is_correct,
        "answer": answer.to_uppercase()
    }))
}

// ---- content reporting ----

pub async fn submit_report(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "report", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let content_type = req.get("content_type").and_then(|v| v.as_str())
        .ok_or(AppError("missing content_type".into()))?;
    let content_id = req.get("content_id").and_then(|v| v.as_str())
        .ok_or(AppError("missing content_id".into()))?;
    let reason = req.get("reason").and_then(|v| v.as_str());

    if content_type != "clue" && content_type != "puzzle" {
        return Err(AppError("content_type must be 'clue' or 'puzzle'".into()));
    }

    let dbi = state.db.lock().await;
    let id = db::submit_report(&dbi.db, &session.token, content_type, content_id, reason);

    // if reporting a federated clue, update instance reputation
    if content_type == "clue" {
        if let Ok(domain) = dbi.db.query_row(
            "SELECT origin_domain FROM community_clues WHERE id = ?1",
            [content_id], |row| row.get::<_, Option<String>>(0),
        ) {
            if let Some(d) = domain {
                db::increment_instance_reports(&dbi.db, &d);
            }
        }
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({ "id": id }))))
}

// #9: XOR encryption helpers (legacy format, kept for reading old rows)
fn xor_crypt(data: &[u8], key: &[u8]) -> Vec<u8> {
    data.iter().enumerate().map(|(i, b)| b ^ key[i % key.len()]).collect()
}

fn derive_key(token: &str) -> Vec<u8> {
    Sha256::digest(token.as_bytes()).to_vec()
}

fn enc_key() -> Option<[u8; 32]> {
    let raw = std::env::var("DATA_ENC_KEY").ok()?;
    let bytes = hex::decode(raw.trim()).ok()?;
    bytes.try_into().ok()
}

/// AES-256-GCM with the nonce prepended to the ciphertext. Returns None when
/// the server key is not configured, so callers can fall back to plaintext.
fn encrypt_progress(plain: &[u8]) -> Option<Vec<u8>> {
    use aes_gcm::aead::{Aead, AeadCore, OsRng};
    use aes_gcm::{Aes256Gcm, KeyInit};
    let key = enc_key()?;
    let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
    let ciphertext = cipher.encrypt(&nonce, plain).ok()?;
    let mut out = Vec::with_capacity(nonce.len() + ciphertext.len());
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&ciphertext);
    Some(out)
}

fn decrypt_progress(data: &[u8]) -> Option<Vec<u8>> {
    use aes_gcm::aead::Aead;
    use aes_gcm::{Aes256Gcm, KeyInit};
    let key = enc_key()?;
    let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
    let (nonce, ciphertext) = data.split_at(12);
    cipher.decrypt(nonce.into(), ciphertext).ok()
}

// #18: instance about page
pub async fn about_page(
    State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
    let contact = std::env::var("INSTANCE_CONTACT").unwrap_or_default();
    let (rules, peer_count) = {
        let dbi = state.db.lock().await;
        let r = db::get_server_config(&dbi.db, "instance_rules")
            .or_else(|| std::env::var("INSTANCE_RULES").ok())
            .unwrap_or_else(|| "Be respectful. Submit quality clues. No spam.".into());
        let pc: i64 = dbi.db.query_row("SELECT COUNT(*) FROM ap_peers", [], |row| row.get(0)).unwrap_or(0);
        (r, pc)
    };
    let ap_enabled = state.ap.is_some();

    Json(serde_json::json!({
        "software": "cruciverb",
        "version": env!("CARGO_PKG_VERSION"),
        "contact": contact,
        "federation": ap_enabled,
        "connected_instances": peer_count,
        "rules": rules,
    }))
}

// #17: public moderation log
pub async fn public_moderation_log(
    State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    if std::env::var("PUBLIC_MOD_LOG").as_deref() != Ok("true") {
        return Err(StatusCode::NOT_FOUND);
    }

    let dbi = state.db.lock().await;
    let entries = db::get_audit_log(&dbi.db, 50);
    let moderation_actions = ["moderate_clue", "approve_follower", "reject_follower",
        "block_instance", "unblock_instance", "auto_block_instance", "federation_freeze", "federation_unfreeze"];

    let filtered: Vec<serde_json::Value> = entries.iter()
        .filter(|(_, action, _, _, _)| moderation_actions.contains(&action.as_str()))
        .map(|(id, action, target, detail, ts)| {
            serde_json::json!({
                "id": id, "action": action, "target": target, "detail": detail, "timestamp": ts,
            })
        })
        .collect();

    Ok(Json(serde_json::json!({ "entries": filtered })))
}

// #19: federation health dashboard
pub async fn federation_health(
    State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
    let ap_enabled = state.ap.is_some();
    if !ap_enabled {
        return Json(serde_json::json!({ "enabled": false }));
    }

    let dbi = state.db.lock().await;
    let queue_depth: i64 = dbi.db.query_row(
        "SELECT COUNT(*) FROM ap_delivery_queue", [], |r| r.get(0)
    ).unwrap_or(0);

    let total_outbound: i64 = dbi.db.query_row(
        "SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'outbound'",
        [], |r| r.get(0),
    ).unwrap_or(0);
    let total_inbound: i64 = dbi.db.query_row(
        "SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'inbound'",
        [], |r| r.get(0),
    ).unwrap_or(0);

    let peer_count: i64 = dbi.db.query_row(
        "SELECT COUNT(*) FROM ap_peers", [], |r| r.get(0)
    ).unwrap_or(0);

    let last_delivery: String = dbi.db.query_row(
        "SELECT COALESCE(MAX(last_activity), '') FROM ap_stats WHERE direction = 'outbound'",
        [], |r| r.get(0),
    ).unwrap_or_default();

    let failed_deliveries: i64 = dbi.db.query_row(
        "SELECT COUNT(*) FROM ap_delivery_queue WHERE attempts > 0", [], |r| r.get(0)
    ).unwrap_or(0);

    let success_rate = if total_outbound + failed_deliveries > 0 {
        (total_outbound as f64 / (total_outbound + failed_deliveries) as f64 * 100.0).round()
    } else {
        100.0
    };

    Json(serde_json::json!({
        "enabled": true,
        "frozen": crate::activitypub::is_frozen(),
        "peers": peer_count,
        "queue_depth": queue_depth,
        "total_inbound": total_inbound,
        "total_outbound": total_outbound,
        "delivery_success_rate": success_rate,
        "failed_in_queue": failed_deliveries,
        "last_delivery": last_delivery,
    }))
}

pub async fn receive_federated_leaderboard(
    State(state): State<Arc<AppState>>,
    headers: axum::http::HeaderMap,
    body: String,
) -> Result<Json<serde_json::Value>, Response> {
    // require HTTP signature from a known peer
    let sig_header = headers.iter()
        .find(|(k, _)| k.as_str().to_lowercase() == "signature")
        .and_then(|(_, v)| v.to_str().ok())
        .ok_or_else(|| (StatusCode::UNAUTHORIZED, "missing signature").into_response())?;

    let key_domain = sig_header.split("keyId=\"")
        .nth(1).and_then(|s| s.split('"').next())
        .and_then(|uri| uri.split("//").nth(1))
        .and_then(|s| s.split('/').next())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "bad signature format").into_response())?
        .to_string();

    let public_pem = {
        let dbi = state.db.lock().await;
        let blocked: bool = dbi.db.query_row(
            "SELECT COUNT(*) > 0 FROM ap_blocked WHERE domain = ?1",
            [&key_domain], |r| r.get(0),
        ).unwrap_or(false);
        if blocked {
            return Err((StatusCode::FORBIDDEN, "blocked").into_response());
        }
        dbi.db.query_row(
            "SELECT public_pem FROM ap_peers WHERE domain = ?1",
            [&key_domain], |r| r.get::<_, String>(0),
        ).ok()
    };

    let public_pem = public_pem
        .ok_or_else(|| (StatusCode::FORBIDDEN, "unknown peer").into_response())?;

    let header_pairs: Vec<(String, String)> = headers.iter()
        .filter_map(|(k, v)| Some((k.as_str().to_string(), v.to_str().ok()?.to_string())))
        .collect();

    crate::activitypub::signatures::verify_request(
        &public_pem, "POST", "/api/federation/leaderboard",
        &header_pairs, Some(body.as_bytes()),
    ).map_err(|e| (StatusCode::UNAUTHORIZED, format!("signature error: {}", e)).into_response())?;

    let req: serde_json::Value = serde_json::from_str(&body)
        .map_err(|_| (StatusCode::BAD_REQUEST, "invalid JSON").into_response())?;

    let origin = req.get("origin_domain").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing origin_domain").into_response())?;

    if origin != key_domain {
        return Err((StatusCode::FORBIDDEN, "origin mismatch").into_response());
    }

    let date = req.get("puzzle_date").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing puzzle_date").into_response())?;
    let preset = req.get("preset").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing preset").into_response())?;
    let entries = req.get("entries").and_then(|v| v.as_array())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing entries").into_response())?;

    if entries.len() > 100 {
        return Err((StatusCode::BAD_REQUEST, "too many entries").into_response());
    }

    let dbi = state.db.lock().await;
    let mut count = 0;
    for entry in entries {
        let name = entry.get("name").and_then(|n| n.as_str()).unwrap_or("Anonymous");
        let name = if name.len() > 100 { &name[..100] } else { name };
        let time = entry.get("time").and_then(|t| t.as_i64()).unwrap_or(0);
        if time > 0 && time < 172_800 {
            db::insert_federated_leaderboard(&dbi.db, origin, date, preset, name, time);
            count += 1;
        }
    }

    Ok(Json(serde_json::json!({ "received": count })))
}

pub async fn get_puzzle_replies(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Json<serde_json::Value> {
    let dbi = state.db.lock().await;
    let replies = db::get_ap_replies(&dbi.db, &id, 50);
    let items: Vec<serde_json::Value> = replies.into_iter().map(|(uri, name, content, published)| {
        // turn actor URI into user@domain format
        let author = uri.split("//").nth(1)
            .map(|s| {
                let domain = s.split('/').next().unwrap_or("");
                let user = s.rsplit('/').next().unwrap_or("");
                let user = user.trim_start_matches('@');
                format!("{}@{}", user, domain)
            })
            .unwrap_or(uri);
        serde_json::json!({
            "author": author,
            "name": name,
            "content": content,
            "published": published,
        })
    }).collect();
    Json(serde_json::json!({ "replies": items }))
}

// wordlist packs

pub async fn list_packs(
    State(state): State<Arc<AppState>>,
    Query(params): Query<std::collections::HashMap<String, String>>,
) -> Json<serde_json::Value> {
    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();
    Json(serde_json::json!({ "packs": packs }))
}

pub async fn get_pack_detail(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
    let dbi = state.db.lock().await;
    let pack = db::get_pack(&dbi.db, &id)
        .ok_or(AppError("pack not found".into()))?;
    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();
    Ok(Json(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 create_pack(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    crate::security::check_rate_limit(&session.token, "pack_upload", 5, 3600)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;

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

    let words = req.get("words").and_then(|v| v.as_array())
        .ok_or(AppError::with_status(StatusCode::BAD_REQUEST, "missing words array"))?;
    if words.is_empty() || words.len() > 5000 {
        return Err(AppError::with_status(StatusCode::BAD_REQUEST, "words array must have 1-5000 entries"));
    }

    // validate each word
    let mut cleaned_words = Vec::new();
    for w in words {
        let word = w.get("word").and_then(|v| v.as_str()).unwrap_or("");
        let upper = word.to_uppercase();
        if upper.len() < 3 || upper.len() > 21 {
            return Err(AppError::with_status(StatusCode::BAD_REQUEST,
                &format!("word '{}' must be 3-21 characters", word)));
        }
        if !upper.chars().all(|c| c.is_ascii_alphabetic()) {
            return Err(AppError::with_status(StatusCode::BAD_REQUEST,
                &format!("word '{}' must be alphabetic only", word)));
        }
        let clues = w.get("clues").and_then(|v| v.as_array());
        if clues.is_none() || clues.unwrap().is_empty() {
            return Err(AppError::with_status(StatusCode::BAD_REQUEST,
                &format!("word '{}' must have at least one clue", word)));
        }
        for clue in clues.unwrap() {
            let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or("");
            if text.len() < 3 || text.len() > 200 {
                return Err(AppError::with_status(StatusCode::BAD_REQUEST, "each clue must be 3-200 characters"));
            }
        }
        cleaned_words.push(serde_json::json!({
            "word": upper,
            "clues": clues.unwrap(),
        }));
    }

    let pack_data = serde_json::json!({ "words": cleaned_words });
    let word_count = cleaned_words.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, &session.token);
    let success = db::create_pack(
        &dbi.db, &id,
        &crate::security::sanitize_html(name),
        desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
        &session.token,
        author_name.as_deref(),
        tags_str.as_deref(),
        word_count,
        &pack_data.to_string(),
    );
    if !success {
        return Err(AppError("failed to create pack".into()));
    }

    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "id": id, "word_count": word_count,
    }))))
}

pub async fn delete_pack_handler(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    if db::delete_pack(&dbi.db, &id, &session.token) {
        Ok(with_session_cookie(&session, Json(serde_json::json!({"deleted": true}))))
    } else {
        Err(AppError::with_status(StatusCode::FORBIDDEN, "not your pack or pack not found"))
    }
}

pub async fn rate_pack_handler(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    check_banned(&state, &session.token).await?;
    let rating = req.get("rating").and_then(|v| v.as_i64())
        .ok_or(AppError("missing rating".into()))?;
    if !(1..=5).contains(&rating) {
        return Err(AppError::with_status(StatusCode::BAD_REQUEST, "rating must be 1-5"));
    }
    let dbi = state.db.lock().await;
    db::rate_pack(&dbi.db, &id, &session.token, rating);
    Ok(with_session_cookie(&session, Json(serde_json::json!({"ok": true}))))
}

pub async fn editor_puzzle_analytics(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path(id): Path<String>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "analytics", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let dbi = state.db.lock().await;
    match db::get_puzzle_analytics(&dbi.db, &id, &session.token) {
        Some(data) => Ok(with_session_cookie(&session, Json(data))),
        None => Err(AppError::with_status(StatusCode::NOT_FOUND, "not found or not your puzzle")),
    }
}

pub async fn get_puzzle_archive(
    State(state): State<Arc<AppState>>,
    session: Session,
    Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Response, AppError> {
    crate::security::check_rate_limit(&session.token, "archive", 10, 60)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;
    let preset = params.get("preset").map(|s| s.as_str());
    let page: usize = params.get("page").and_then(|s| s.parse().ok()).unwrap_or(0);
    let dbi = state.db.lock().await;
    let entries = db::get_daily_archive(&dbi.db, preset, &session.token, page, 20);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "entries": entries }))))
}

pub async fn embed_page(
    Path(short_id): Path<String>,
    Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response {
    let theme = params.get("theme").map(|s| s.as_str()).unwrap_or("");
    let timer = params.get("timer").map(|s| s.as_str()).unwrap_or("true");
    let hints = params.get("hints").map(|s| s.as_str()).unwrap_or("true");

    let html = format!(r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cruciverb</title>
<link rel="stylesheet" href="/embed/embed.css">
</head>
<body>
<div id="root" data-puzzle-id="{}" data-theme="{}" data-timer="{}" data-hints="{}"></div>
<script type="module" src="/embed/embed.js"></script>
</body>
</html>"#, short_id, theme, timer, hints);

    let mut resp = (StatusCode::OK, html).into_response();
    let h = resp.headers_mut();
    h.insert(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8".parse().unwrap());
    h.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*".parse().unwrap());
    h.insert(axum::http::header::X_FRAME_OPTIONS, "ALLOWALL".parse().unwrap());
    h.insert("content-security-policy", "frame-ancestors *".parse().unwrap());
    resp
}

// streak freezes

pub async fn get_freezes(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let freezes = db::get_streak_freezes(&dbi.db, &session.token);
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "freezes": freezes }))))
}

// daily challenges

pub async fn get_daily_challenge(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path((date, preset)): Path<(String, String)>,
) -> Result<Response, AppError> {
    let dbi = state.db.lock().await;
    let challenge = db::get_daily_challenge(&dbi.db, &date, &preset);
    let completed = db::has_completed_challenge(&dbi.db, &session.token, &date, &preset);
    Ok(with_session_cookie(&session, Json(serde_json::json!({
        "challenge": challenge,
        "completed": completed,
    }))))
}

pub async fn complete_daily_challenge(
    State(state): State<Arc<AppState>>,
    session: Session,
    Path((date, preset)): Path<(String, String)>,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    let challenge_type = req.get("challenge_type").and_then(|v| v.as_str())
        .ok_or(AppError("missing challenge_type".into()))?;

    let dbi = state.db.lock().await;
    // verify the challenge against the server-side solve record where that is
    // possible (times, hints, hour of the day); challenges that only the client
    // can observe (deletes, pauses, letter streaks) are stored as attested
    let mut verified = false;
    let mut client_attested = true;
    if let Some((elapsed, hints, completed_at)) =
        db::get_challenge_solve_info(&dbi.db, &session.token, &date, &preset)
    {
        verified = challenge_met(challenge_type, elapsed, hints, &completed_at, &preset);
        client_attested = !verified;
    }
    db::complete_challenge(
        &dbi.db,
        &session.token,
        &date,
        &preset,
        challenge_type,
        verified,
        client_attested,
    );
    Ok(with_session_cookie(&session, Json(serde_json::json!({ "ok": true, "verified": verified }))))
}

fn challenge_met(challenge: &str, elapsed: i64, hints: i64, completed_at: &str, preset: &str) -> bool {
    use chrono::Timelike;
    let hour = chrono::DateTime::parse_from_rfc3339(completed_at)
        .map(|t| t.hour())
        .unwrap_or(0);
    let speed_limit: i64 = match preset {
        "glyph" => 45,
        "rune" => 60,
        "scroll" => 300,
        "codex" => 1200,
        "grimoire" => 2700,
        _ => 300,
    };
    match challenge {
        "purist" => hints == 0,
        "minimalist" => hints > 0 && hints < 3,
        "night_owl" => hour < 5,
        "early_bird" => (5..8).contains(&hour),
        "rush_hour" => (12..13).contains(&hour),
        "night_shift" => (20..24).contains(&hour),
        "afternoon_tea" => (15..17).contains(&hour),
        "blitz" => elapsed < 60,
        "sunday_stroll" => elapsed >= 180,
        "overtime" => elapsed >= 600,
        "lucky_seven" => (elapsed - 420).abs() <= 10,
        "even_steven" => elapsed % 2 == 0,
        "speed_demon" => elapsed < speed_limit,
        _ => false,
    }
}

pub async fn get_today_challenges(
    State(state): State<Arc<AppState>>,
    session: Session,
) -> Result<Response, AppError> {
    let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
    let dbi = state.db.lock().await;
    let completions = db::get_today_challenge_completions(&dbi.db, &session.token, &today);
    Ok(with_session_cookie(&session, Json(serde_json::json!(completions))))
}

#[cfg(test)]
mod challenge_tests {
    use super::challenge_met;

    #[test]
    fn time_and_hint_challenges_are_checked() {
        assert!(challenge_met("purist", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
        assert!(!challenge_met("purist", 100, 1, "2026-01-01T10:00:00Z", "scroll"));
        assert!(challenge_met("minimalist", 100, 2, "2026-01-01T10:00:00Z", "scroll"));
        assert!(!challenge_met("minimalist", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
        assert!(challenge_met("night_owl", 100, 0, "2026-01-01T03:00:00Z", "scroll"));
        assert!(!challenge_met("night_owl", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
        assert!(challenge_met("blitz", 59, 0, "2026-01-01T10:00:00Z", "scroll"));
        assert!(!challenge_met("blitz", 61, 0, "2026-01-01T10:00:00Z", "scroll"));
        assert!(challenge_met("speed_demon", 40, 0, "2026-01-01T10:00:00Z", "glyph"));
        assert!(!challenge_met("speed_demon", 50, 0, "2026-01-01T10:00:00Z", "glyph"));
        assert!(!challenge_met("surgeon", 100, 0, "2026-01-01T10:00:00Z", "scroll"));
    }
}
