mod activitypub;
mod admin;
mod achievements;
mod api;
mod api_v1;
mod db;
mod receipts;
mod security;
mod feeds;
mod rooms;
mod routes;
mod session;
mod state;
mod webauthn;
mod webhooks;

use std::path::PathBuf;
use std::net::SocketAddr;

use axum::Router;
use axum::routing::{get, post};
use axum::middleware;
use axum::extract::DefaultBodyLimit;
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::timeout::TimeoutLayer;

use cruciverb_core::clue::seed::load_clue_database;
use cruciverb_core::dict::Dictionary;

use crate::state::AppState;

async fn daily_pregenerator(state: std::sync::Arc<state::AppState>) {
    use cruciverb_core::config::{DifficultyPreset, GenerationConfig};

    let preset_configs: Vec<(&str, u8, GenerationConfig)> = vec![
        ("glyph", 0, 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)),
    ];

    // small delay to let the server finish starting
    tokio::time::sleep(std::time::Duration::from_secs(5)).await;

    let mut generated_this_hour = 0u32;
    loop {
        let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
        let tomorrow = (chrono::Utc::now() + chrono::Duration::days(1)).format("%Y-%m-%d").to_string();

        // generate today's daily image if it doesn't exist yet
        {
            let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
            let today_image = std::path::Path::new(&data_dir).join(format!("daily-image-{}.jpg", today));
            if !today_image.exists() {
                if let Ok(mut child) = tokio::process::Command::new("node")
                    .arg("/app/scripts/generate-daily-image.js")
                    .spawn()
                {
                    tracing::info!("generating daily image for {}...", today);
                    let _ = tokio::time::timeout(
                        std::time::Duration::from_secs(30),
                        child.wait(),
                    ).await;
                }
                // copy dated file to the canonical name for the /daily-image endpoint
                let canonical = std::path::Path::new(&data_dir).join("daily-image.jpg");
                if today_image.exists() {
                    std::fs::copy(&today_image, &canonical).ok();
                }
            }
        }

        // publish today's AP post (image is ready now)
        if state.ap.is_some() {
            let already_posted = {
                let dbi = state.db.lock().await;
                let post_id = format!("https://{}/ap/daily/{}", state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or(""), &today);
                dbi.db.query_row("SELECT COUNT(*) > 0 FROM ap_outbox WHERE id LIKE ?1 || '%'", [&post_id], |r| r.get::<_, bool>(0)).unwrap_or(false)
            };
            if !already_posted {
                activitypub::outbox::publish_daily_puzzle(&state, &today).await;
                activitypub::outbox::publish_community_digest(&state, &today).await;
            }
        }

        for date in [&today, &tomorrow] {
            for (preset, diff_level, base_config) in &preset_configs {
                // serialize generation per (date, preset) with the API path,
                // then re-check existence under the lock
                let _gen_lock = state.daily_gen_lock(date, preset).await;

                // check if already exists
                let exists = {
                    let dbi = state.db.lock().await;
                    db::get_daily_puzzle(&dbi.db, date, preset).is_some()
                };
                if exists { continue; }

                // rate limit
                if generated_this_hour >= 20 {
                    tracing::warn!("daily pre-generator rate limit hit, waiting");
                    break;
                }

                // acquire semaphore so we don't starve user requests
                let permit = match state.generation_semaphore.acquire().await {
                    Ok(p) => p,
                    Err(_) => continue,
                };

                tracing::info!("pre-generating {} daily for {}", preset, date);

                let seed = routes::daily_seed(date, preset, &state.daily_secret);
                let mut config = base_config.clone();
                config.seed = Some(seed);

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

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

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

                drop(permit);

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

                        let id = uuid::Uuid::new_v4();
                        if let Ok(json) = puzzle.to_json() {
                            let dbi = state.db.lock().await;
                            let short_id = db::store_puzzle(&dbi.db, &id.to_string(), &json,
                                             puzzle.width, puzzle.height, diff,
                                             Some(date), Some(preset));
                            tracing::info!("pre-generated {} for {} -> /p/{}", preset, date, short_id);
                        }
                        generated_this_hour += 1;
                    }
                    Ok(Err(e)) => {
                        tracing::warn!("failed to pre-generate {} for {}: {}", preset, date, e);
                    }
                    Err(e) => {
                        tracing::warn!("pre-generation task failed for {} {}: {}", preset, date, e);
                    }
                }
            }

        }

        generated_this_hour = 0;
        tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
    }
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let port: u16 = std::env::var("PORT")
        .ok()
        .and_then(|p| p.parse().ok())
        .unwrap_or(8080);

    let data_dir = std::env::var("DATA_DIR")
        .unwrap_or_else(|_| "../data".into());
    let wordlist = std::env::var("WORDLIST")
        .unwrap_or_else(|_| format!("{}/wordlists/enable.txt", data_dir));
    let static_dir = std::env::var("STATIC_DIR")
        .unwrap_or_else(|_| "../frontend/dist".into());

    let banned_words = PathBuf::from(&data_dir).join("banned_words.txt");
    tracing::info!("loading dictionary from {}...", wordlist);
    let mut dict = Dictionary::load(
        &PathBuf::from(&wordlist),
        Some(&banned_words),
        3,
    ).expect("failed to load dictionary");
    tracing::info!("loaded {} words", dict.word_count());

    let clue_db = load_clue_database(&PathBuf::from(&data_dir))
        .unwrap_or_else(|_| cruciverb_core::clue::ClueDatabase::empty());
    tracing::info!("loaded {} clue entries", clue_db.word_count());

    let clue_words = clue_db.clueable_words();
    tracing::info!("boosting {} words with clue coverage", clue_words.len());
    dict.boost_from_clue_words(&clue_words);

    let word_diffs = clue_db.word_difficulties();
    tracing::info!("loaded {} word difficulty scores", word_diffs.len());

    // cryptic clues live in the same clues.db with source='cryptic'
    let cryptic_path = PathBuf::from(&data_dir).join("clues.db");
    let cryptic_db = if cryptic_path.exists() {
        match cruciverb_core::clue::CrypticClueDb::load(&cryptic_path) {
            Ok(db) => {
                tracing::info!("loaded {} cryptic clue entries", db.word_count());
                Some(db)
            }
            Err(e) => {
                tracing::warn!("failed to load cryptic clues: {}", e);
                None
            }
        }
    } else {
        None
    };

    // download PDF fonts if missing
    let fonts_dir = PathBuf::from(&data_dir).join("fonts");
    std::fs::create_dir_all(&fonts_dir).ok();
    for (filename, css_family, weight) in [
        ("PlayfairDisplay-Bold.ttf", "Playfair+Display", "700"),
        ("DMSans-Regular.ttf", "DM+Sans", "400"),
    ] {
        let path = fonts_dir.join(filename);
        if !path.exists() {
            tracing::info!("downloading font {}...", filename);
            if let Ok(font_data) = download_font(css_family, weight) {
                std::fs::write(&path, &font_data).ok();
                tracing::info!("saved {} ({} bytes)", filename, font_data.len());
            }
        }
    }

    let db_path = PathBuf::from(&data_dir).join("cruciverb.db");
    tracing::info!("opening database at {}...", db_path.display());
    let db = db::init_db(&db_path);

    // seed default ActivityPub profile config
    if db::get_server_config(&db, "ap_actor_name").is_none() {
        db::set_server_config(&db, "ap_actor_name", "crossword");
    }
    if db::get_server_config(&db, "ap_bio").is_none() {
        db::set_server_config(&db, "ap_bio", "Daily crossword puzzles in five sizes, four game modes, cryptic clues, multiplayer rooms, party games, a built-in puzzle editor for constructors, and a clue trainer for the stubborn. Self-hosted on whatever you call a server. Federated via ActivityPub because crosswords are social infrastructure. CC0 because charging for grid squares is morally dubious.");
    }

    let rp_id = std::env::var("RP_ID").unwrap_or_else(|_| "localhost".into());
    let rp_origin = std::env::var("RP_ORIGIN")
        .unwrap_or_else(|_| format!("http://localhost:{}", port));
    tracing::info!("WebAuthn RP: {} ({})", rp_id, rp_origin);

    // ActivityPub federation
    let ap_state = if let Ok(ap_domain) = std::env::var("AP_DOMAIN") {
        let actor_name = std::env::var("AP_ACTOR_NAME").unwrap_or_else(|_| "cruciverb".into());
        tracing::info!("ActivityPub enabled: @{}@{}", actor_name, ap_domain);
        let ap_keys = activitypub::keys::load_or_generate(&db);
        Some(activitypub::ApState::new(ap_domain, actor_name, ap_keys))
    } else {
        None
    };

    // load or generate instance secret for daily puzzle seeds
    let daily_secret = {
        if let Some(s) = db::get_server_config(&db, "daily_secret") {
            s
        } else {
            let secret = format!("{:016x}{:016x}", rand::random::<u64>(), rand::random::<u64>());
            db::set_server_config(&db, "daily_secret", &secret);
            tracing::info!("generated new daily puzzle secret");
            secret
        }
    };

    let content_blocklist = if banned_words.exists() {
        cruciverb_core::dict::blocklist::Blocklist::load(&banned_words).unwrap_or_else(|_| cruciverb_core::dict::blocklist::Blocklist::empty())
    } else {
        cruciverb_core::dict::blocklist::Blocklist::empty()
    };

    // load Webster's definitions for word-of-the-day
    let defs_path = PathBuf::from(&data_dir).join("definitions.json");
    let definitions: std::collections::HashMap<String, String> = if defs_path.exists() {
        let raw = std::fs::read_to_string(&defs_path).unwrap_or_default();
        serde_json::from_str(&raw).unwrap_or_default()
    } else {
        tracing::warn!("definitions.json not found, word-of-the-day will be empty");
        std::collections::HashMap::new()
    };
    tracing::info!("loaded {} dictionary definitions", definitions.len());

    // build crossword-relevant word list for wotd (words in both definitions + clue db, 3-8 letters, alphabetic)
    let clue_words_set = clue_db.all_words();
    let mut wotd_words: Vec<String> = definitions.keys()
        .filter(|w| {
            let upper = w.to_uppercase();
            upper.len() >= 3 && upper.len() <= 8
                && upper.chars().all(|c| c.is_alphabetic())
                && clue_words_set.contains(&upper)
        })
        .map(|w| w.to_uppercase())
        .collect();
    wotd_words.sort();
    wotd_words.dedup();
    tracing::info!("word-of-the-day pool: {} words", wotd_words.len());

    let state = AppState::new(dict, clue_db, cryptic_db, word_diffs, db, db_path.to_string_lossy().into_owned(), rp_id, rp_origin, ap_state, daily_secret, content_blocklist, definitions, wotd_words);

    // load quality scores from ratings
    {
        let dbi = state.db.lock().await;
        let wq = db::get_word_quality_scores(&dbi.db);
        let cq = db::get_clue_quality_scores(&dbi.db);
        if !wq.is_empty() { tracing::info!("loaded {} word quality scores", wq.len()); }
        if !cq.is_empty() { tracing::info!("loaded {} clue quality scores", cq.len()); }
        *state.word_quality.write().await = wq;
        *state.clue_quality.write().await = cq;
    }

    // spawn daily puzzle pre-generator
    {
        let state = state.clone();
        tokio::spawn(async move {
            daily_pregenerator(state).await;
        });
    }

    // room cleanup
    rooms::start_room_cleanup(state.rooms.clone());

    let api = Router::new()
        .route("/api/puzzles/generate", post(routes::generate_puzzle))
        .route("/api/puzzles/daily/{date}", get(routes::get_daily_puzzle))
        .route("/api/puzzles/daily/{date}/{preset}", get(routes::get_daily_preset))
        .route("/api/puzzles/daily/{date}/{preset}/challenge", get(routes::get_daily_challenge))
        .route("/api/puzzles/daily/{date}/{preset}/challenge/complete", post(routes::complete_daily_challenge))
        .route("/api/puzzles/s/{short_id}", get(routes::get_puzzle_by_short_id))
        .route("/api/puzzles/{id}", get(routes::get_puzzle))
        .route("/api/puzzles/{id}/validate", post(routes::validate_solution))
        .route("/api/puzzles/{id}/hint", post(routes::get_hint))
        .route("/api/puzzles/{id}/progress", get(routes::load_progress).put(routes::save_progress))
        .route("/api/me", get(routes::get_me).put(routes::update_me))
        .route("/api/me/freezes", get(routes::get_freezes))
        .route("/api/me/stats", get(routes::get_stats))
        .route("/api/me/settings", get(routes::get_settings).put(routes::save_settings))
        .route("/api/me/challenges/today", get(routes::get_today_challenges))
        .route("/api/me/achievements", get(routes::get_achievements))
        .route("/api/achievements", get(routes::list_achievements))
        .route("/api/leaderboards/{id}", get(routes::get_leaderboard))
        .route("/api/leaderboards/daily/{date}/{preset}", get(routes::get_daily_leaderboard))
        .route("/api/leaderboards/federated/{date}/{preset}", get(routes::get_federated_leaderboard))
        .route("/api/leaderboards/alltime/{preset}", get(routes::get_alltime_leaderboard))
        .route("/api/leaderboards/weekly/{preset}", get(routes::get_weekly_leaderboard))
        .route("/api/leaderboards/streaks", get(routes::get_streak_leaderboard))
        .route("/api/leaderboards/completionist", get(routes::get_completionist_leaderboard))
        .route("/api/leaderboards/clean/{date}/{preset}", get(routes::get_clean_leaderboard))
        .route("/api/me/stats/detailed", get(routes::get_detailed_stats))
        .route("/api/me/analytics", get(routes::get_analytics))
        .route("/api/me/history", get(routes::get_history))
        .route("/api/puzzles/{id}/percentile/{time}", get(routes::get_solve_percentile))
        .route("/api/themes", get(routes::list_themes))
        .route("/api/editor/autofill", post(routes::editor_autofill))
        .route("/api/editor/check-words", post(routes::editor_check_words))
        .route("/api/editor/clue-suggestions/{word}", get(routes::editor_clue_suggestions))
        .route("/api/editor/puzzles", post(routes::editor_save).get(routes::editor_list))
        .route("/api/editor/puzzles/{id}", get(routes::editor_load).delete(routes::editor_delete))
        .route("/api/editor/puzzles/{id}/publish", post(routes::editor_publish))
        .route("/api/editor/puzzles/{id}/analytics", get(routes::editor_puzzle_analytics))
        .route("/api/editor/puzzles/{id}/export", get(routes::editor_export))
        .route("/api/editor/export-all", get(routes::editor_export_all))
        .route("/api/editor/import", post(routes::editor_import))
        .route("/api/puzzles/archive", get(routes::get_puzzle_archive))
        .route("/api/puzzles/import", post(routes::import_puzzle))
        .route("/api/admin/config", get(admin::admin_config))
        .route("/api/admin/config/all", get(admin::admin_full_config).put(admin::update_config))
        .route("/api/admin/login", post(admin::admin_login))
        .route("/api/admin/status", get(admin::admin_status))
        .route("/api/admin/setup", post(admin::admin_setup))
        .route("/api/admin/stats", get(admin::stats))
        .route("/api/admin/sessions", get(admin::list_sessions))
        .route("/api/admin/sessions/{token}", axum::routing::delete(admin::delete_session))
        .route("/api/admin/users/{token}/ban", post(admin::ban_user))
        .route("/api/admin/users/{token}/unban", post(admin::unban_user))
        .route("/api/admin/sessions/purge", post(admin::purge_old_sessions))
        .route("/api/admin/puzzles", get(admin::list_all_puzzles))
        .route("/api/admin/puzzles/{id}", axum::routing::delete(admin::delete_puzzle))
        .route("/api/admin/clues/pending", get(admin::list_pending_clues))
        .route("/api/admin/clues/{id}/moderate", post(admin::moderate_clue))
        .route("/api/admin/achievements", get(admin::achievement_stats))
        .route("/api/admin/webhooks/{id}/test", post(admin::test_webhook))
        .route("/api/admin/peers", get(admin::list_peers))
        .route("/api/admin/peers/connect", post(admin::connect_peer))
        .route("/api/admin/peers/{domain}", axum::routing::delete(admin::disconnect_peer))
        .route("/api/admin/block", post(admin::block_instance))
        .route("/api/admin/blocklist/export", get(admin::export_blocklist))
        .route("/api/admin/blocklist/import", post(admin::import_blocklist))
        .route("/api/admin/unblock/{domain}", post(admin::unblock_instance))
        .route("/api/admin/audit-log", get(admin::audit_log))
        .route("/api/admin/blocked-words", post(admin::add_blocked_word))
        .route("/api/admin/followers/pending", get(admin::list_pending_followers))
        .route("/api/admin/followers/approve", post(admin::approve_follower))
        .route("/api/admin/followers/reject", post(admin::reject_follower))
        .route("/api/admin/federation/freeze", post(admin::federation_freeze))
        .route("/api/admin/outbox", get(admin::list_outbox))
        .route("/api/admin/outbox/clear", post(admin::clear_outbox))
        .route("/api/admin/outbox/{idx}", axum::routing::delete(admin::delete_outbox_item))
        .route("/api/admin/outbox/delete-remote", post(admin::delete_remote_post))
        .route("/api/admin/federation/unfreeze", post(admin::federation_unfreeze))
        .route("/api/admin/federation/status", get(admin::federation_status))
        .route("/api/admin/avatar", post(admin::upload_avatar))
        .route("/api/admin/banner", post(admin::upload_banner))
        .route("/avatar", get(admin::serve_avatar))
        .route("/banner", get(admin::serve_banner))
        .route("/api/webhooks", get(routes::list_webhooks).post(routes::create_webhook))
        .route("/api/webhooks/{id}", axum::routing::delete(routes::delete_webhook))
        .route("/api/packs", get(routes::list_packs).post(routes::create_pack))
        .route("/api/packs/{id}", get(routes::get_pack_detail).delete(routes::delete_pack_handler))
        .route("/api/packs/{id}/rate", post(routes::rate_pack_handler))
        .route("/api/community-puzzles", get(routes::browse_community_puzzles))
        .route("/api/federated-puzzles", get(routes::list_federated_puzzles))
        .route("/api/community-puzzles/{id}", get(routes::play_community_puzzle))
        .route("/api/puzzles/{id}/rate", post(routes::rate_puzzle))
        .route("/api/puzzles/{id}/rating", get(routes::get_rating))
        .route("/api/puzzles/{id}/replies", get(routes::get_puzzle_replies))
        .route("/api/puzzles/daily/{date}/ratings", get(routes::get_daily_ratings))
        .route("/api/clues/community", post(routes::submit_community_clue))
        .route("/api/clues/community/{word}", get(routes::get_community_clues))
        .route("/api/clues/community/vote/{id}", post(routes::vote_community_clue))
        .route("/api/me/submissions", get(routes::my_submissions))
        .route("/api/me/submissions/{id}", axum::routing::delete(routes::delete_submission))
        .route("/api/passkey/register/start", post(routes::passkey_register_start))
        .route("/api/passkey/register/finish", post(routes::passkey_register_finish))
        .route("/api/passkey/authenticate/start", post(routes::passkey_auth_start))
        .route("/api/passkey/authenticate/finish", post(routes::passkey_auth_finish))
        .route("/api/me/passkeys", get(routes::list_passkeys))
        .route("/api/me/sessions", get(routes::get_session_info))
        .route("/api/me/account", axum::routing::delete(routes::delete_account))
        .route("/api/me/export", get(routes::export_data))
        .route("/api/me/import", post(routes::import_data))
        .route("/api/me/migrate", post(routes::migrate_account))
        .route("/api/me/blocklist", get(routes::export_user_blocklist))
        .route("/api/me/blocklist/import", post(routes::import_user_blocklist))
        .route("/api/rooms", post(rooms::create_room))
        .route("/api/rooms/{code}", get(rooms::get_room))
        .route("/api/rooms/{code}/ws", get(rooms::ws_upgrade))
        .route("/api/receipts/generate", post(receipts::generate_receipt_handler))
        .route("/api/receipts/claim", post(receipts::claim_receipt_handler))
        .route("/api/receipts/{id}", get(receipts::get_receipt_handler))
        .route("/api/word-of-the-day", get(routes::word_of_the_day))
        .route("/api/trainer/clue", get(routes::trainer_clue))
        .route("/api/trainer/check", post(routes::trainer_check))
        .route("/api/community-puzzles/today", get(routes::community_puzzles_today))
        .route("/api/internal/daily-image", post(routes::upload_daily_image))
        .route("/daily-image", get(routes::serve_daily_image))
        .route("/ap/daily/{*rest}", get(routes::ap_post_permalink))
        .route("/ap/community-digest/{*rest}", get(routes::ap_post_permalink))
        .route("/post/{date}", get(routes::daily_post_page))
        .route("/api/report", post(routes::submit_report))
        .route("/api/admin/reports", get(admin::list_reports))
        .route("/api/admin/reports/{id}/resolve", post(admin::resolve_report))
        .route("/api/admin/ap-humor-defaults", get(admin::ap_humor_defaults))
        .route("/api/admin/community-puzzles", get(admin::list_community_puzzles))
        .route("/api/admin/community-puzzles/{id}", axum::routing::delete(admin::delete_community_puzzle))
        .route("/api/admin/community-puzzles/batch-delete", post(admin::batch_delete_community_puzzles))
        .route("/api/admin/puzzles/filtered", get(admin::list_all_puzzles_filtered))
        .route("/api/admin/puzzles/batch-delete", post(admin::batch_delete_puzzles))
        .route("/api/puzzles/{id}/export/puz", get(routes::export_puz))
        .route("/api/puzzles/{id}/export/ipuz", get(routes::export_ipuz))
        .route("/api/puzzles/{id}/export/pdf", get(routes::export_pdf))
        .route("/feeds/daily.atom", get(feeds::atom_feed))
        .route("/feeds/daily.rss", get(feeds::rss_feed))
        .route("/feeds/daily.json", get(feeds::json_feed))
        .route("/about", get(routes::about_page))
        .route("/api/about", get(routes::about_page))
        .route("/api/moderation-log", get(routes::public_moderation_log))
        .route("/api/federation/health", get(routes::federation_health))
        .route("/api/federation/leaderboard", post(routes::receive_federated_leaderboard))
        .route("/health", get(health))
        .route("/api/health", get(health))
        // ActivityPub / discovery endpoints
        .route("/.well-known/webfinger", get(activitypub::actor::webfinger))
        .route("/.well-known/nodeinfo", get(activitypub::nodeinfo::well_known))
        .route("/nodeinfo/2.1", get(activitypub::nodeinfo::nodeinfo))
        .route("/@{name}", get(activitypub::actor::actor_document))
        .route("/@{name}/inbox", post(activitypub::inbox::inbox))
        .route("/@{name}/outbox", get(activitypub::outbox::outbox))
        .route("/@{name}/followers", get(activitypub::actor::followers))
        .route("/inbox", post(activitypub::inbox::inbox))
        .route("/e/{short_id}", get(routes::embed_page))
        .route("/api/v1/openapi.json", get(api_v1::v1_openapi))
        .route("/api/docs", get(api_v1::swagger_ui))
        .with_state(state.clone());

    // v1 public API
    let v1 = Router::new()
        .route("/puzzles/generate", post(api_v1::v1_generate))
        .route("/puzzles/{id}", get(api_v1::v1_get_puzzle))
        .route("/puzzles/{id}/solution", get(api_v1::v1_get_solution))
        .route("/puzzles/{id}/validate", post(api_v1::v1_validate))
        .route("/puzzles/{id}/hint", post(api_v1::v1_hint))
        .route("/puzzles/{id}/export/{fmt}", get(api_v1::v1_export))
        .route("/daily/{date}", get(api_v1::v1_get_daily))
        .route("/daily/{date}/{preset}", get(api_v1::v1_get_daily_preset))
        .route("/community/puzzles", get(api_v1::v1_community_puzzles))
        .route("/community/clues/{word}", get(api_v1::v1_community_clues))
        .route("/community/clues", post(api_v1::v1_submit_clue))
        .route("/community/clues/{id}/vote", post(api_v1::v1_vote_clue))
        .route("/packs", get(api_v1::v1_list_packs).post(api_v1::v1_create_pack))
        .route("/packs/{id}", get(api_v1::v1_get_pack))
        .route("/me", get(api_v1::v1_me))
        .route("/me/achievements", get(api_v1::v1_achievements))
        .route("/me/stats", get(api_v1::v1_stats))
        .route("/instance", get(api_v1::v1_instance))
        .route("/themes", get(api_v1::v1_themes))
        .route("/keys", post(api_v1::v1_create_key).get(api_v1::v1_list_keys))
        .route("/keys/{id}", axum::routing::delete(api_v1::v1_delete_key))
        .with_state(state.clone());

    let api = api.nest("/api/v1", v1);

    let static_path = PathBuf::from(&static_dir);
    let embed_path = static_path.join("embed");
    let app = if static_path.exists() {
        let index = static_path.join("index.html");
        let mut router = api.nest_service("/embed", ServeDir::new(&embed_path));
        router = router.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index)));
        router
    } else {
        tracing::warn!("static dir {} not found, serving API only", static_dir);
        api.into()
    };

    // security layers
    let cors = if std::env::var("CORS_ORIGIN").is_ok() {
        let origin = std::env::var("CORS_ORIGIN").unwrap();
        CorsLayer::new()
            .allow_origin(origin.parse::<axum::http::HeaderValue>().unwrap())
            .allow_methods([axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE])
            .allow_headers([axum::http::header::CONTENT_TYPE, axum::http::header::HeaderName::from_static("x-admin-session")])
    } else {
        tracing::warn!("CORS_ORIGIN not set - using permissive CORS (set CORS_ORIGIN for production)");
        CorsLayer::permissive()
    };

    let app = app
        .layer(TimeoutLayer::with_status_code(axum::http::StatusCode::REQUEST_TIMEOUT, std::time::Duration::from_secs(120)))
        .layer(cors)
        .layer(DefaultBodyLimit::max(2 * 1024 * 1024))
        .layer(middleware::from_fn(security::security_headers));

    // start ActivityPub workers
    activitypub::deliver::start_delivery_worker(state.clone());
    activitypub::discover::start_discovery_worker(state.clone());

    // #10: ephemeral session cleanup worker - runs every hour
    {
        let cleanup_state = state.clone();
        tokio::spawn(async move {
            loop {
                tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
                let dbi = cleanup_state.db.lock().await;
                db::cleanup_ephemeral_sessions(&dbi.db);
                db::cleanup_expired_receipts(&dbi.db);
            }
        });
    }

    let addr = SocketAddr::from(([0, 0, 0, 0], port));
    tracing::info!("listening on {}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.unwrap();
}

async fn health() -> axum::Json<serde_json::Value> {
    axum::Json(serde_json::json!({"status": "ok"}))
}

fn download_font(family: &str, weight: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    // get the font URL from Google Fonts CSS API
    let css_url = format!(
        "https://fonts.googleapis.com/css2?family={}:wght@{}",
        family, weight
    );

    let client = reqwest::blocking::Client::builder()
        .user_agent("Mozilla/5.0")
        .build()?;

    let css = client.get(&css_url).send()?.text()?;

    // extract the TTF url from the CSS
    let url = css.lines()
        .find(|l| l.contains("src: url(") && l.contains(".ttf"))
        .and_then(|l| {
            let start = l.find("url(")? + 4;
            let end = l[start..].find(')')? + start;
            Some(l[start..end].to_string())
        })
        .ok_or("font URL not found in CSS")?;

    let data = client.get(&url).send()?.bytes()?.to_vec();
    Ok(data)
}
