use std::sync::Arc;
use std::collections::HashMap;
use axum::Json;
use axum::extract::{ConnectInfo, Multipart, Path, Query, State};
use axum::http::{StatusCode, HeaderMap};
use axum::response::{IntoResponse, Response};
use sha2::{Sha256, Digest};
use crate::db;
use crate::state::AppState;

static STATS_CACHE: std::sync::LazyLock<std::sync::Mutex<Option<(serde_json::Value, i64)>>> =
    std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
const STATS_CACHE_TTL: i64 = 300;

pub fn invalidate_stats_cache() {
    if let Ok(mut c) = STATS_CACHE.lock() { *c = None; }
}

// admin session store: token -> expiry timestamp
static ADMIN_SESSIONS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, i64>>> =
    std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));

// failed login tracker
static LOGIN_ATTEMPTS: std::sync::LazyLock<std::sync::Mutex<HashMap<String, (u32, i64)>>> =
    std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));

const SESSION_TTL: i64 = 3600; // 1 hour
const MAX_ATTEMPTS: u32 = 5;
const LOCKOUT_SECS: i64 = 300; // 5 min lockout after max attempts

fn env_admin_configured() -> bool {
    !std::env::var("ADMIN_USER").unwrap_or_default().is_empty()
        && !std::env::var("ADMIN_PASS").unwrap_or_default().is_empty()
}

fn admin_configured_with_db(db_has_admin: bool) -> bool {
    db_has_admin || env_admin_configured()
}

pub async fn list_outbox(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare("SELECT id, published FROM ap_outbox ORDER BY published DESC").unwrap();
    let items: Vec<serde_json::Value> = stmt.query_map([], |row| {
        let id: String = row.get(0)?;
        let published: String = row.get(1)?;
        Ok(serde_json::json!({ "id": id, "published": published }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "items": items })))
}

pub async fn delete_outbox_item(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(idx): Path<usize>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let item_id = {
        let dbi = state.db.lock().await;
        let mut stmt = dbi.db.prepare("SELECT id FROM ap_outbox ORDER BY published DESC").unwrap();
        let ids: Vec<String> = stmt.query_map([], |row| row.get(0)).unwrap().filter_map(|r| r.ok()).collect();
        ids.get(idx).cloned()
    };
    if let Some(id) = item_id {
        let object_uri = id.strip_suffix("/activity").unwrap_or(&id).to_string();
        crate::activitypub::outbox::publish_delete(&state, &object_uri).await;
        // ensure the exact row is gone
        let dbi = state.db.lock().await;
        dbi.db.execute("DELETE FROM ap_outbox WHERE id = ?1", [&id]).ok();
        db::log_admin_action(&dbi.db, "delete_outbox_item", Some(&id), None);
        Ok(Json(serde_json::json!({ "ok": true, "deleted": id })))
    } else {
        Err((StatusCode::NOT_FOUND, "item not found").into_response())
    }
}

pub async fn delete_remote_post(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let uri = req.get("uri").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing uri").into_response())?;
    crate::activitypub::outbox::publish_delete(&state, uri).await;
    db::log_admin_action(&state.db.lock().await.db, "delete_remote_post", Some(uri), None);
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub fn verify_admin_session(token: &str) -> bool {
    if token.is_empty() { return false; }
    let now = chrono::Utc::now().timestamp();
    let sessions = ADMIN_SESSIONS.lock().unwrap();
    matches!(sessions.get(token), Some(exp) if *exp > now)
}

async fn check_admin(headers: &HeaderMap) -> Result<(), Response> {
    let token = headers.get("X-Admin-Session")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    if token.is_empty() {
        return Err((StatusCode::UNAUTHORIZED, "no admin session").into_response());
    }
    let now = chrono::Utc::now().timestamp();
    let mut sessions = ADMIN_SESSIONS.lock().unwrap();
    // clean expired
    sessions.retain(|_, exp| *exp > now);
    match sessions.get(token) {
        Some(exp) if *exp > now => {
            // refresh expiry on use
            sessions.insert(token.to_string(), now + SESSION_TTL);
            Ok(())
        }
        _ => Err((StatusCode::UNAUTHORIZED, "expired or invalid session").into_response()),
    }
}

#[axum::debug_handler]
pub async fn admin_login(
    State(state): State<Arc<AppState>>,
    ConnectInfo(addr): ConnectInfo<std::net::SocketAddr>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    let db_has_admin = {
        let dbi = state.db.lock().await;
        db::has_admin_account(&dbi.db)
    };

    if !admin_configured_with_db(db_has_admin) {
        return Err((StatusCode::FORBIDDEN, "admin not configured").into_response());
    }

    let now = chrono::Utc::now().timestamp();
    // the lockout key is the proxy-forwarded IP only when a trusted proxy is
    // configured; otherwise use the real socket address so a spoofable header
    // cannot reset or trigger lockouts
    let ip = if std::env::var("TRUST_PROXY").as_deref() == Ok("true") {
        crate::security::extract_client_ip(&headers)
    } else {
        addr.ip().to_string()
    };

    // check lockout per IP
    {
        let attempts = LOGIN_ATTEMPTS.lock().unwrap();
        if let Some(&(count, last)) = attempts.get(&ip) {
            if count >= MAX_ATTEMPTS && now - last < LOCKOUT_SECS {
                let remaining = LOCKOUT_SECS - (now - last);
                return Err((StatusCode::TOO_MANY_REQUESTS,
                    format!("too many failed attempts - try again in {}s", remaining)).into_response());
            }
        }
    }

    let user = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
    let pass = req.get("password").and_then(|v| v.as_str()).unwrap_or("");

    // check database admin first, then fall back to env vars
    let credentials_ok = if db_has_admin {
        let dbi = state.db.lock().await;
        db::verify_admin_account(&dbi.db, user, pass)
    } else {
        let expected_user = std::env::var("ADMIN_USER").unwrap_or_default();
        let expected_pass = std::env::var("ADMIN_PASS").unwrap_or_default();
        let user_ok = user.len() == expected_user.len()
            && user.bytes().zip(expected_user.bytes()).all(|(a, b)| a == b);
        let pass_ok = pass.len() == expected_pass.len()
            && pass.bytes().zip(expected_pass.bytes()).all(|(a, b)| a == b);
        user_ok && pass_ok
    };

    if !credentials_ok {
        // record failed attempt
        {
            let mut attempts = LOGIN_ATTEMPTS.lock().unwrap();
            let entry = attempts.entry(ip.clone()).or_insert((0, now));
            if now - entry.1 > LOCKOUT_SECS { *entry = (1, now); } else { entry.0 += 1; entry.1 = now; }
        }
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
        return Err((StatusCode::UNAUTHORIZED, "invalid credentials").into_response());
    }

    // upgrade legacy salted-sha256 rows to argon2id on first successful login
    if db_has_admin {
        let dbi = state.db.lock().await;
        let stored: Option<String> = dbi.db
            .query_row(
                "SELECT password_hash FROM admin_accounts WHERE username = ?1",
                [user],
                |r| r.get(0),
            )
            .ok();
        if let Some(s) = stored {
            if !s.starts_with("$argon2") {
                db::rehash_admin_password(&dbi.db, user, pass);
            }
        }
    }

    // reset failed attempts on success
    { LOGIN_ATTEMPTS.lock().unwrap().remove(&ip); }

    // generate random session token
    let session_token = {
        use rand::Rng;
        let mut rng = rand::rng();
        let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
        hex::encode(Sha256::digest(&bytes))
    };

    ADMIN_SESSIONS.lock().unwrap().insert(session_token.clone(), now + SESSION_TTL);

    {
        let dbi = state.db.lock().await;
        db::log_admin_action(&dbi.db, "admin_login", None, Some(&format!("ip: {}", ip)));
    }

    let path = std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into());
    Ok(Json(serde_json::json!({ "session": session_token, "path": path })))
}

pub async fn admin_config(
    State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
    let db_has_admin = {
        let dbi = state.db.lock().await;
        db::has_admin_account(&dbi.db)
    };
    Json(serde_json::json!({
        "configured": admin_configured_with_db(db_has_admin),
        "path": std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into()),
    }))
}

pub async fn admin_status(
    State(state): State<Arc<AppState>>,
) -> Json<serde_json::Value> {
    let db_has_admin = {
        let dbi = state.db.lock().await;
        db::has_admin_account(&dbi.db)
    };
    Json(serde_json::json!({
        "configured": admin_configured_with_db(db_has_admin),
    }))
}

pub async fn admin_setup(
    State(state): State<Arc<AppState>>,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    let dbi = state.db.lock().await;

    if db::has_admin_account(&dbi.db) {
        return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
    }

    let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
    let password = req.get("password").and_then(|v| v.as_str()).unwrap_or("");

    if username.len() < 3 {
        return Err((StatusCode::BAD_REQUEST, "username must be at least 3 characters").into_response());
    }
    if password.len() < 8 {
        return Err((StatusCode::BAD_REQUEST, "password must be at least 8 characters").into_response());
    }

    // hash password with argon2id (memory-hard, replaces the old salted sha256)
    let stored = {
        use argon2::PasswordHasher;
        let salt =
            argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
        match argon2::Argon2::default().hash_password(password.as_bytes(), &salt) {
            Ok(h) => h.to_string(),
            Err(_) => {
                return Err((StatusCode::INTERNAL_SERVER_ERROR, "password hashing failed").into_response())
            }
        }
    };

    db::create_admin_account(&dbi.db, username, &stored);
    db::log_admin_action(&dbi.db, "admin_setup", Some(username), None);

    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn stats(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;

    let now = chrono::Utc::now().timestamp();
    if let Ok(cache) = STATS_CACHE.lock() {
        if let Some((ref val, ts)) = *cache {
            if now - ts < STATS_CACHE_TTL {
                return Ok(Json(val.clone()));
            }
        }
    }

    let dict_words = state.dict.word_count();
    let clue_db_words = state.clue_db.word_count();
    let cryptic_words = state.cryptic_db.as_ref().map(|c| c.word_count()).unwrap_or(0);
    let cached_puzzles = state.puzzles.read().await.len();
    let word_quality_count = state.word_quality.read().await.len();
    let clue_quality_count = state.clue_quality.read().await.len();

    let db_path = state.db_path.clone();
    let db_stats = tokio::task::spawn_blocking(move || {
        let conn = rusqlite::Connection::open_with_flags(
            &db_path,
            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
        ).map_err(|e| format!("db open failed: {}", e))?;

        // combined counts in a single query
        let (sessions, solves, solves_today, active_today): (i64, i64, i64, i64) = conn.query_row(
            "SELECT
                (SELECT COUNT(*) FROM sessions),
                (SELECT COUNT(*) FROM solve_records WHERE is_complete = 1),
                (SELECT COUNT(*) FROM solve_records WHERE is_complete = 1 AND completed_at >= date('now')),
                (SELECT COUNT(*) FROM sessions WHERE last_seen >= date('now'))",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
        ).unwrap_or((0, 0, 0, 0));

        let (puzzles, daily_puzzles, user_puzzles, published_puzzles): (i64, i64, i64, i64) = conn.query_row(
            "SELECT
                (SELECT COUNT(*) FROM puzzles),
                (SELECT COUNT(*) FROM puzzles WHERE daily_date IS NOT NULL),
                (SELECT COUNT(*) FROM user_puzzles),
                (SELECT COUNT(*) FROM user_puzzles WHERE status = 'published')",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
        ).unwrap_or((0, 0, 0, 0));

        let (community_clues, pending_clues, webhooks, passkeys, ratings, achievements): (i64, i64, i64, i64, i64, i64) = conn.query_row(
            "SELECT
                (SELECT COUNT(*) FROM community_clues),
                (SELECT COUNT(*) FROM community_clues WHERE status = 'pending'),
                (SELECT COUNT(*) FROM webhooks),
                (SELECT COUNT(*) FROM passkeys),
                (SELECT COUNT(*) FROM puzzle_ratings),
                (SELECT COUNT(*) FROM achievements)",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?))
        ).unwrap_or((0, 0, 0, 0, 0, 0));

        let (avg_solve_time, fastest_solve, avg_rating): (f64, i64, f64) = conn.query_row(
            "SELECT
                (SELECT COALESCE(AVG(solve_time_secs), 0) FROM solve_records WHERE is_complete = 1 AND solve_time_secs > 0),
                (SELECT COALESCE(MIN(solve_time_secs), 0) FROM solve_records WHERE is_complete = 1 AND solve_time_secs > 0),
                (SELECT COALESCE(AVG(CAST(rating AS REAL)), 0) FROM puzzle_ratings)",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
        ).unwrap_or((0.0, 0, 0.0));

        let mut preset_stats = Vec::new();
        if let Ok(mut stmt) = conn.prepare(
            "SELECT p.preset, COUNT(sr.id), COALESCE(AVG(sr.solve_time_secs), 0)
             FROM puzzles p JOIN solve_records sr ON p.id = sr.puzzle_id
             WHERE p.preset IS NOT NULL AND sr.is_complete = 1
             GROUP BY p.preset ORDER BY p.preset"
        ) {
            if let Ok(rows) = stmt.query_map([], |row| {
                Ok(serde_json::json!({
                    "preset": row.get::<_, String>(0)?, "solves": row.get::<_, i64>(1)?,
                    "avg_time": (row.get::<_, f64>(2)? * 10.0).round() / 10.0,
                }))
            }) {
                for r in rows.flatten() { preset_stats.push(r); }
            }
        }

        let mut daily_activity = Vec::new();
        if let Ok(mut stmt) = conn.prepare(
            "SELECT DATE(completed_at) as d, COUNT(*) FROM solve_records
             WHERE is_complete = 1 AND completed_at >= date('now', '-7 days')
             GROUP BY d ORDER BY d"
        ) {
            if let Ok(rows) = stmt.query_map([], |row| {
                Ok(serde_json::json!({ "date": row.get::<_, String>(0)?, "solves": row.get::<_, i64>(1)? }))
            }) {
                for r in rows.flatten() { daily_activity.push(r); }
            }
        }

        let mut top_contributors = Vec::new();
        if let Ok(mut stmt) = conn.prepare(
            "SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END)
             FROM community_clues cc
             LEFT JOIN sessions s ON cc.submitted_by = s.token
             LEFT JOIN settings st ON cc.submitted_by = st.session_token
             GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
        ) {
            if let Ok(rows) = stmt.query_map([], |row| {
                Ok(serde_json::json!({
                    "token": row.get::<_, String>(0)?,
                    "name": row.get::<_, Option<String>>(1)?.unwrap_or_else(|| "Anonymous".into()),
                    "submitted": row.get::<_, i64>(2)?,
                    "approved": row.get::<_, i64>(3)?,
                }))
            }) {
                for r in rows.flatten() { top_contributors.push(r); }
            }
        }

        let ap_enabled = std::env::var("AP_DOMAIN").is_ok();
        let (ap_followers, ap_peers, ap_queue, ap_blocked_count): (i64, i64, i64, i64) = conn.query_row(
            "SELECT
                (SELECT COUNT(*) FROM ap_followers WHERE accepted = 1),
                (SELECT COUNT(*) FROM ap_peers),
                (SELECT COUNT(*) FROM ap_delivery_queue),
                (SELECT COUNT(*) FROM ap_blocked)",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
        ).unwrap_or((0, 0, 0, 0));

        let (ap_inbound, ap_outbound, ap_key_changes, pending_reports): (i64, i64, i64, i64) = conn.query_row(
            "SELECT
                (SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'inbound'),
                (SELECT COALESCE(SUM(count), 0) FROM ap_stats WHERE direction = 'outbound'),
                (SELECT COUNT(*) FROM admin_audit_log WHERE action = 'key_change_detected'),
                (SELECT COUNT(*) FROM content_reports WHERE status = 'pending')",
            [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
        ).unwrap_or((0, 0, 0, 0));

        Ok::<_, String>(serde_json::json!({
            "sessions": { "total": sessions, "active_today": active_today },
            "solves": { "total": solves, "today": solves_today, "avg_time": (avg_solve_time * 10.0).round() / 10.0, "fastest": fastest_solve },
            "puzzles": { "generated": puzzles, "daily": daily_puzzles, "user_created": user_puzzles, "published": published_puzzles },
            "community_clues": { "total": community_clues, "pending": pending_clues },
            "ratings": { "total": ratings, "average": (avg_rating * 10.0).round() / 10.0 },
            "achievements_unlocked": achievements,
            "passkeys": passkeys,
            "webhooks": webhooks,
            "federation": { "enabled": ap_enabled, "followers": ap_followers, "peers": ap_peers, "queue": ap_queue,
                            "inbound": ap_inbound, "outbound": ap_outbound, "blocked": ap_blocked_count,
                            "key_changes": ap_key_changes, "domain": std::env::var("AP_DOMAIN").ok() },
            "preset_stats": preset_stats,
            "daily_activity": daily_activity,
            "top_contributors": top_contributors,
            "pending_reports": pending_reports,
        }))
    }).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("stats task failed: {}", e)).into_response())?
      .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e).into_response())?;

    // merge in-memory stats that don't need DB
    let mut result = db_stats;
    if let Some(obj) = result.as_object_mut() {
        obj.insert("dictionary".into(), serde_json::json!({
            "words": dict_words, "clue_entries": clue_db_words, "cryptic_entries": cryptic_words
        }));
        obj.insert("cache".into(), serde_json::json!({
            "puzzles": cached_puzzles, "word_quality": word_quality_count, "clue_quality": clue_quality_count
        }));
    }

    if let Ok(mut cache) = STATS_CACHE.lock() {
        *cache = Some((result.clone(), now));
    }

    Ok(Json(result))
}

pub async fn list_sessions(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT s.token, s.display_name, s.created_at, s.last_seen,
         (SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1),
         (SELECT current_streak FROM streaks st WHERE st.session_token = s.token),
         COALESCE(s.banned, 0)
         FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
    ).unwrap();
    let sessions: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "token": row.get::<_, String>(0)?,
            "display_name": row.get::<_, Option<String>>(1)?,
            "created_at": row.get::<_, String>(2)?,
            "last_seen": row.get::<_, String>(3)?,
            "solves": row.get::<_, i64>(4)?,
            "streak": row.get::<_, Option<i64>>(5)?,
            "banned": row.get::<_, i64>(6)? != 0,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "sessions": sessions })))
}

pub async fn delete_session(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(token): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok();
    dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok();
    db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn ban_user(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(token): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;

    let puzzle_ids: Vec<String>;
    {
        let dbi = state.db.lock().await;
        puzzle_ids = db::get_user_published_puzzles(&dbi.db, &token);
        db::ban_session(&dbi.db, &token);
        db::cleanup_user_content(&dbi.db, &token);
        db::log_admin_action(&dbi.db, "ban_user", Some(&token), None);
    }

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

    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true, "cleaned_puzzles": puzzle_ids.len() })))
}

pub async fn unban_user(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(token): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    db::unban_session(&dbi.db, &token);
    db::log_admin_action(&dbi.db, "unban_user", Some(&token), None);
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn list_pending_clues(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT id, word, clue_text, submitted_by, upvotes, downvotes, status, created_at
         FROM community_clues WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100"
    ).unwrap();
    let clues: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "id": row.get::<_, i64>(0)?,
            "word": row.get::<_, String>(1)?,
            "clue": row.get::<_, String>(2)?,
            "submitted_by": row.get::<_, String>(3)?,
            "upvotes": row.get::<_, i64>(4)?,
            "downvotes": row.get::<_, i64>(5)?,
            "status": row.get::<_, String>(6)?,
            "created_at": row.get::<_, String>(7)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "clues": clues })))
}

pub async fn moderate_clue(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(id): Path<i64>,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let action = req.get("action").and_then(|v| v.as_str()).unwrap_or("");
    let dbi = state.db.lock().await;
    // get origin info before moderation
    let (origin_domain, origin_id): (Option<String>, Option<String>) = dbi.db.query_row(
        "SELECT origin_domain, origin_id FROM community_clues WHERE id = ?1",
        [id], |row| Ok((row.get(0)?, row.get(1)?)),
    ).unwrap_or((None, None));

    match action {
        "approve" => { dbi.db.execute("UPDATE community_clues SET status = 'approved' WHERE id = ?1", [id]).ok(); }
        "reject" => { dbi.db.execute("UPDATE community_clues SET status = 'rejected' WHERE id = ?1", [id]).ok(); }
        "delete" => { dbi.db.execute("DELETE FROM community_clues WHERE id = ?1", [id]).ok(); }
        _ => {}
    }

    // federate Delete for locally-originated clues
    if action == "delete" {
        if let Some(ref oid) = origin_id {
            if oid.contains(&state.ap.as_ref().map(|a| a.domain.as_str()).unwrap_or("")) {
                drop(dbi);
                crate::activitypub::outbox::publish_delete(&state, oid).await;
                let dbi = state.db.lock().await;
                // update reputation below uses dbi, re-acquire and proceed
                if let Some(ref domain) = origin_domain {
                    match action {
                        "approve" => { db::update_instance_reputation(&dbi.db, domain, 1.0); }
                        "reject" | "delete" => { db::update_instance_reputation(&dbi.db, domain, -5.0); }
                        _ => {}
                    }
                }
                db::log_admin_action(&dbi.db, "moderate_clue", Some(&id.to_string()), Some(action));
                return Ok(Json(serde_json::json!({ "ok": true })));
            }
        }
    }

    // update instance reputation for federated clues
    if let Some(ref domain) = origin_domain {
        match action {
            "approve" => { db::update_instance_reputation(&dbi.db, domain, 1.0); }
            "reject" | "delete" => { db::update_instance_reputation(&dbi.db, domain, -5.0); }
            _ => {}
        }
    }

    db::log_admin_action(&dbi.db, "moderate_clue", Some(&id.to_string()), Some(action));
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn list_all_puzzles(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at,
         (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1),
         (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id)
         FROM puzzles p ORDER BY p.created_at DESC LIMIT 100"
    ).unwrap();
    let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "id": row.get::<_, String>(0)?,
            "short_id": row.get::<_, String>(1)?,
            "daily_date": row.get::<_, Option<String>>(2)?,
            "preset": row.get::<_, Option<String>>(3)?,
            "width": row.get::<_, i64>(4)?,
            "height": row.get::<_, i64>(5)?,
            "difficulty": row.get::<_, i64>(6)?,
            "created_at": row.get::<_, String>(7)?,
            "solves": row.get::<_, i64>(8)?,
            "rating": (row.get::<_, f64>(9)? * 10.0).round() / 10.0,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "puzzles": puzzles })))
}

pub async fn delete_puzzle(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [&id]).ok();
    dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1", [&id]).ok();
    db::log_admin_action(&dbi.db, "delete_puzzle", Some(&id), None);
    drop(dbi);
    if let Ok(uuid) = uuid::Uuid::parse_str(&id) {
        state.puzzles.write().await.remove(&uuid);
    }
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn achievement_stats(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let all = crate::achievements::all_achievements();
    let mut stats: Vec<serde_json::Value> = Vec::new();
    for (id, name, desc, icon, c1, c2) in &all {
        let count: i64 = dbi.db.query_row(
            "SELECT COUNT(*) FROM achievements WHERE achievement_id = ?1", [id], |r| r.get(0)
        ).unwrap_or(0);
        stats.push(serde_json::json!({
            "id": id, "name": name, "description": desc, "icon": icon,
            "color1": c1, "color2": c2, "unlock_count": count,
        }));
    }
    stats.sort_by(|a, b| b["unlock_count"].as_i64().cmp(&a["unlock_count"].as_i64()));
    Ok(Json(serde_json::json!({ "achievements": stats })))
}

pub async fn test_webhook(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let hook = dbi.db.query_row(
        "SELECT url, format FROM webhooks WHERE id = ?1", [&id],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
    ).ok();
    drop(dbi);
    if let Some((_url, _format)) = hook {
        crate::webhooks::fire_event(&state, "test", serde_json::json!({
            "description": "Test webhook from cruciverb admin panel",
        })).await;
        let dbi = state.db.lock().await;
        db::log_admin_action(&dbi.db, "test_webhook", Some(&id), None);
        Ok(Json(serde_json::json!({ "ok": true })))
    } else {
        Ok(Json(serde_json::json!({ "ok": false, "error": "webhook not found" })))
    }
}

pub async fn purge_old_sessions(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let days = req.get("older_than_days").and_then(|v| v.as_i64()).unwrap_or(90);
    let dbi = state.db.lock().await;
    let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
    let cutoff_str = cutoff.to_rfc3339();
    let deleted: i64 = dbi.db.execute(
        "DELETE FROM sessions WHERE last_seen < ?1", [&cutoff_str]
    ).map(|n| n as i64).unwrap_or(0);
    db::log_admin_action(&dbi.db, "purge_sessions", None, Some(&format!("deleted {} older than {} days", deleted, days)));
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "deleted": deleted })))
}

pub async fn connect_peer(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let domain = req.get("domain").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing domain").into_response())?;

    // #7: reject http:// domains - must always use HTTPS for federation
    if domain.starts_with("http://") {
        return Err((StatusCode::BAD_REQUEST, "federation requires HTTPS - plain HTTP is not allowed").into_response());
    }

    // check not blocked
    {
        let dbi = state.db.lock().await;
        let blocked: bool = dbi.db.query_row(
            "SELECT COUNT(*) > 0 FROM ap_blocked WHERE domain = ?1", [domain], |r| r.get(0)
        ).unwrap_or(false);
        if blocked {
            return Err((StatusCode::BAD_REQUEST, "this instance is blocked").into_response());
        }
    }

    // fetch the remote actor document
    let actor_uri = format!("https://{}/@cruciverb", domain);
    let client = reqwest::Client::new();
    let resp = client.get(&actor_uri)
        .header("Accept", "application/activity+json")
        .send().await
        .map_err(|e| (StatusCode::BAD_GATEWAY, format!("failed to reach {}: {}", domain, e)).into_response())?;

    if !resp.status().is_success() {
        return Err((StatusCode::BAD_GATEWAY, format!("{} returned {}", domain, resp.status())).into_response());
    }

    let actor: serde_json::Value = resp.json().await
        .map_err(|_| (StatusCode::BAD_GATEWAY, "invalid actor document").into_response())?;

    let inbox = actor.get("inbox").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let public_key_pem = actor.get("publicKey")
        .and_then(|pk| pk.get("publicKeyPem"))
        .and_then(|v| v.as_str())
        .unwrap_or("").to_string();

    if inbox.is_empty() || public_key_pem.is_empty() {
        return Err((StatusCode::BAD_GATEWAY, "actor missing inbox or public key").into_response());
    }

    let dbi = state.db.lock().await;
    let now = chrono::Utc::now().to_rfc3339();
    dbi.db.execute(
        "INSERT OR REPLACE INTO ap_peers (domain, actor_uri, inbox_uri, public_pem, last_seen, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
        rusqlite::params![domain, actor_uri, inbox, public_key_pem, now],
    ).ok();

    // also send a Follow to initiate mutual federation
    if let Some(ref ap) = state.ap {
        let follow = serde_json::json!({
            "@context": "https://www.w3.org/ns/activitystreams",
            "id": format!("{}/follow/{}", ap.actor_uri, uuid::Uuid::new_v4()),
            "type": "Follow",
            "actor": ap.actor_uri,
            "object": actor_uri,
        });
        let payload = serde_json::to_string(&follow).unwrap();
        let retry = chrono::Utc::now().to_rfc3339();
        dbi.db.execute(
            "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)",
            rusqlite::params![inbox, payload, retry],
        ).ok();
    }

    db::log_admin_action(&dbi.db, "connect_peer", Some(domain), None);
    Ok(Json(serde_json::json!({ "ok": true, "domain": domain, "inbox": inbox })))
}

pub async fn disconnect_peer(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(domain): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [&domain]).ok();
    dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [&domain]).ok();
    dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [&domain]).ok();
    db::log_admin_action(&dbi.db, "disconnect_peer", Some(&domain), None);
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn block_instance(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let domain = req.get("domain").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing domain").into_response())?;
    let reason = req.get("reason").and_then(|v| v.as_str()).unwrap_or("");

    let dbi = state.db.lock().await;
    let now = chrono::Utc::now().to_rfc3339();
    dbi.db.execute(
        "INSERT OR REPLACE INTO ap_blocked (domain, reason, blocked_at) VALUES (?1, ?2, ?3)",
        rusqlite::params![domain, reason, now],
    ).ok();
    // also remove as peer and follower
    dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [domain]).ok();
    dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [domain]).ok();
    dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [domain]).ok();
    // remove any federated clues from this domain
    dbi.db.execute("DELETE FROM community_clues WHERE origin_domain = ?1", [domain]).ok();

    db::log_admin_action(&dbi.db, "block_instance", Some(domain), Some(reason));
    let domain_owned = domain.to_string();
    drop(dbi);
    crate::activitypub::outbox::publish_blocklist_update(&state, &domain_owned, "block").await;
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn unblock_instance(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(domain): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM ap_blocked WHERE domain = ?1", [&domain]).ok();
    db::log_admin_action(&dbi.db, "unblock_instance", Some(&domain), None);
    drop(dbi);
    crate::activitypub::outbox::publish_blocklist_update(&state, &domain, "unblock").await;
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn list_peers(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;

    let mut stmt = dbi.db.prepare(
        "SELECT domain, actor_uri, last_seen FROM ap_peers ORDER BY last_seen DESC"
    ).unwrap();
    let peers: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "domain": row.get::<_, String>(0)?,
            "actor_uri": row.get::<_, String>(1)?,
            "last_seen": row.get::<_, Option<String>>(2)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();

    let mut stmt2 = dbi.db.prepare(
        "SELECT domain, reason, blocked_at FROM ap_blocked ORDER BY blocked_at DESC"
    ).unwrap();
    let blocked: Vec<serde_json::Value> = stmt2.query_map([], |row| {
        Ok(serde_json::json!({
            "domain": row.get::<_, String>(0)?,
            "reason": row.get::<_, Option<String>>(1)?,
            "blocked_at": row.get::<_, String>(2)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();

    let reputations = db::get_all_instance_reputations(&dbi.db);
    let rep_result: Vec<serde_json::Value> = reputations.iter().map(|(domain, score, total, rejected, reports, updated)| {
        serde_json::json!({
            "domain": domain, "score": (*score * 10.0).round() / 10.0,
            "total_clues": total, "rejected_clues": rejected,
            "reports_against": reports, "last_updated": updated,
        })
    }).collect();

    Ok(Json(serde_json::json!({ "peers": peers, "blocked": blocked, "reputations": rep_result })))
}

pub async fn export_blocklist(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Response, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare("SELECT domain, reason, blocked_at FROM ap_blocked ORDER BY domain").unwrap();
    let entries: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "domain": row.get::<_, String>(0)?,
            "reason": row.get::<_, Option<String>>(1)?,
            "blocked_at": row.get::<_, String>(2)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();

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

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

pub async fn import_blocklist(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;

    let entries = req.get("blocked").and_then(|v| v.as_array())
        .or_else(|| req.get("domains").and_then(|v| v.as_array()))
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing blocked or domains array").into_response())?;

    let dbi = state.db.lock().await;
    let now = chrono::Utc::now().to_rfc3339();
    let mut imported = 0;

    for entry in entries {
        let domain = if let Some(s) = entry.as_str() {
            s.to_string()
        } else if let Some(d) = entry.get("domain").and_then(|v| v.as_str()) {
            d.to_string()
        } else {
            continue;
        };

        let domain = domain.trim().to_lowercase();
        if domain.is_empty() { continue; }

        let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("imported from blocklist");

        dbi.db.execute(
            "INSERT OR IGNORE INTO ap_blocked (domain, reason, blocked_at) VALUES (?1, ?2, ?3)",
            rusqlite::params![domain, reason, now],
        ).ok();
        // also remove from peers/followers
        dbi.db.execute("DELETE FROM ap_peers WHERE domain = ?1", [&domain]).ok();
        dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri LIKE '%' || ?1 || '%'", [&domain]).ok();
        dbi.db.execute("DELETE FROM ap_delivery_queue WHERE inbox_uri LIKE '%' || ?1 || '%'", [&domain]).ok();
        dbi.db.execute("DELETE FROM community_clues WHERE origin_domain = ?1", [&domain]).ok();
        imported += 1;
    }

    db::log_admin_action(&dbi.db, "import_blocklist", None, Some(&format!("imported {} domains", imported)));
    Ok(Json(serde_json::json!({ "imported": imported })))
}

pub async fn audit_log(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let entries = db::get_audit_log(&dbi.db, 100);
    let result: Vec<serde_json::Value> = entries.iter().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": result })))
}

pub async fn list_reports(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let reports = db::get_pending_reports(&dbi.db);
    let result: Vec<serde_json::Value> = reports.iter().map(|(id, reporter, ctype, cid, reason, created)| {
        serde_json::json!({
            "id": id, "reporter": reporter, "content_type": ctype,
            "content_id": cid, "reason": reason, "created_at": created,
        })
    }).collect();
    Ok(Json(serde_json::json!({ "reports": result })))
}

pub async fn resolve_report(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(id): Path<i64>,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let action = req.get("action").and_then(|v| v.as_str()).unwrap_or("dismissed");
    let status = if action == "remove" { "removed" } else { "dismissed" };
    let dbi = state.db.lock().await;
    db::resolve_report(&dbi.db, id, status);
    db::log_admin_action(&dbi.db, "resolve_report", Some(&id.to_string()), Some(status));
    Ok(Json(serde_json::json!({ "ok": true })))
}

// #5: follower approval endpoints
pub async fn list_pending_followers(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT actor_uri, inbox_uri, created_at FROM ap_followers WHERE accepted = 0 ORDER BY created_at DESC"
    ).unwrap();
    let followers: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "actor_uri": row.get::<_, String>(0)?,
            "inbox_uri": row.get::<_, String>(1)?,
            "created_at": row.get::<_, String>(2)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "pending_followers": followers })))
}

pub async fn approve_follower(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let actor_uri = req.get("actor_uri").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing actor_uri").into_response())?;

    let dbi = state.db.lock().await;
    dbi.db.execute("UPDATE ap_followers SET accepted = 1 WHERE actor_uri = ?1", [actor_uri]).ok();

    // send Accept activity
    if let Some(ref ap) = state.ap {
        let inbox: String = dbi.db.query_row(
            "SELECT inbox_uri FROM ap_followers WHERE actor_uri = ?1",
            [actor_uri], |r| r.get(0),
        ).unwrap_or_default();
        if !inbox.is_empty() {
            let accept = serde_json::json!({
                "@context": "https://www.w3.org/ns/activitystreams",
                "id": format!("{}/accept/{}", ap.actor_uri, uuid::Uuid::new_v4()),
                "type": "Accept",
                "actor": &ap.actor_uri,
                "object": { "type": "Follow", "actor": actor_uri },
            });
            let payload = serde_json::to_string(&accept).unwrap();
            let retry = chrono::Utc::now().to_rfc3339();
            dbi.db.execute(
                "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)",
                rusqlite::params![inbox, payload, retry],
            ).ok();
        }
    }

    db::log_admin_action(&dbi.db, "approve_follower", Some(actor_uri), None);
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn reject_follower(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let actor_uri = req.get("actor_uri").and_then(|v| v.as_str())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing actor_uri").into_response())?;

    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM ap_followers WHERE actor_uri = ?1", [actor_uri]).ok();
    db::log_admin_action(&dbi.db, "reject_follower", Some(actor_uri), None);
    Ok(Json(serde_json::json!({ "ok": true })))
}

// #16: federation freeze
pub async fn clear_outbox(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let items: Vec<String> = {
        let dbi = state.db.lock().await;
        let mut stmt = dbi.db.prepare("SELECT id FROM ap_outbox").unwrap();
        stmt.query_map([], |row| row.get::<_, String>(0)).unwrap()
            .filter_map(|r| r.ok()).collect()
    };
    for item_id in &items {
        let object_uri = item_id.strip_suffix("/activity").unwrap_or(item_id);
        crate::activitypub::outbox::publish_delete(&state, object_uri).await;
    }
    // ensure outbox is fully empty
    let dbi = state.db.lock().await;
    let remaining = dbi.db.execute("DELETE FROM ap_outbox", []).unwrap_or(0);
    db::log_admin_action(&dbi.db, "clear_outbox", None, Some(&format!("{} deleted, {} remaining cleared", items.len(), remaining)));
    Ok(Json(serde_json::json!({ "ok": true, "deleted": items.len() })))
}

pub async fn federation_freeze(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    crate::activitypub::set_frozen(true);
    let dbi = state.db.lock().await;
    db::log_admin_action(&dbi.db, "federation_freeze", None, Some("federation frozen"));
    Ok(Json(serde_json::json!({ "ok": true, "frozen": true })))
}

pub async fn federation_unfreeze(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    crate::activitypub::set_frozen(false);
    let dbi = state.db.lock().await;
    db::log_admin_action(&dbi.db, "federation_unfreeze", None, Some("federation unfrozen"));
    Ok(Json(serde_json::json!({ "ok": true, "frozen": false })))
}

pub async fn federation_status(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    Ok(Json(serde_json::json!({
        "frozen": crate::activitypub::is_frozen(),
        "allowlist_mode": db::get_config_bool(&dbi.db, "AP_ALLOWLIST_MODE"),
        "approve_followers": db::get_config_bool(&dbi.db, "AP_APPROVE_FOLLOWERS"),
        "new_peer_review": db::get_config_bool(&dbi.db, "AP_NEW_PEER_REVIEW"),
        "outbox_limit": db::get_config_or_env(&dbi.db, "AP_OUTBOX_LIMIT", "20").parse::<i64>().unwrap_or(20),
    })))
}

pub async fn admin_full_config(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    Ok(Json(db::get_all_config(&dbi.db)))
}

pub async fn update_config(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;

    // only these keys can be changed from the admin panel
    let editable = [
        "CORS_ORIGIN", "SECURE_COOKIES", "HSTS", "PUBLIC_MOD_LOG",
        "INSTANCE_CONTACT", "INSTANCE_RULES",
        "AP_ALLOWLIST_MODE", "AP_APPROVE_FOLLOWERS", "AP_NEW_PEER_REVIEW",
        "AP_OUTBOX_LIMIT", "AP_SHARED_BLOCKLIST",
        "ap_actor_name", "ap_bio", "instance_rules",
        "ap_daily_taglines", "ap_daily_signoffs", "ap_community_middles",
        "admin_path",
    ];
    let profile_keys = ["ap_actor_name", "ap_bio"];

    let dbi = state.db.lock().await;
    let mut updated = Vec::new();

    if let Some(obj) = req.as_object() {
        for (key, value) in obj {
            if !editable.contains(&key.as_str()) { continue; }
            let val = match value {
                serde_json::Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
                serde_json::Value::Number(n) => n.to_string(),
                serde_json::Value::String(s) => s.clone(),
                serde_json::Value::Array(_) => serde_json::to_string(value).unwrap_or_default(),
                _ => continue,
            };
            db::set_server_config(&dbi.db, key, &val);
            updated.push(key.clone());
        }
    }

    if !updated.is_empty() {
        db::log_admin_action(&dbi.db, "update_config", None, Some(&format!("changed: {}", updated.join(", "))));
    }

    let needs_actor_update = updated.iter().any(|k| profile_keys.contains(&k.as_str()));
    let result = db::get_all_config(&dbi.db);
    drop(dbi);

    if needs_actor_update {
        crate::activitypub::outbox::publish_actor_update(&state).await;
    }

    Ok(Json(result))
}

pub async fn add_blocked_word(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let word = req.get("word").and_then(|v| v.as_str()).unwrap_or("");
    if word.trim().is_empty() {
        return Err((StatusCode::BAD_REQUEST, "word is required").into_response());
    }
    {
        let mut bl = state.blocklist.write().unwrap();
        bl.add_word(word);
    }
    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
    let path = std::path::PathBuf::from(data_dir).join("banned_words.txt");
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
        use std::io::Write;
        let _ = writeln!(f, "{}", word.trim().to_uppercase());
    }
    let dbi = state.db.lock().await;
    db::log_admin_action(&dbi.db, "add_blocked_word", None, Some(word));
    Ok(Json(serde_json::json!({ "ok": true })))
}

fn allowed_image_ext(content_type: &str) -> Option<&'static str> {
    match content_type {
        "image/jpeg" | "image/jpg" => Some("jpg"),
        "image/png" => Some("png"),
        "image/gif" => Some("gif"),
        "image/webp" => Some("webp"),
        _ => None,
    }
}

async fn handle_image_upload(
    state: &Arc<AppState>,
    headers: &HeaderMap,
    mut multipart: Multipart,
    kind: &str,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(headers).await?;

    while let Some(field) = multipart.next_field().await.map_err(|e| {
        (StatusCode::BAD_REQUEST, format!("multipart error: {}", e)).into_response()
    })? {
        let ct = field.content_type().unwrap_or("application/octet-stream").to_string();
        let ext = allowed_image_ext(&ct)
            .ok_or_else(|| (StatusCode::BAD_REQUEST, "unsupported image type - use jpeg, png, gif, or webp").into_response())?;

        let data = field.bytes().await.map_err(|e| {
            (StatusCode::BAD_REQUEST, format!("read error: {}", e)).into_response()
        })?;

        if data.len() > 2 * 1024 * 1024 {
            return Err((StatusCode::BAD_REQUEST, "file too large - max 2MB").into_response());
        }

        let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
        let dir = std::path::PathBuf::from(&data_dir);
        std::fs::create_dir_all(&dir).ok();

        // remove old files
        for old_ext in ["jpg", "png", "gif", "webp"] {
            let old = dir.join(format!("{}.{}", kind, old_ext));
            std::fs::remove_file(&old).ok();
        }

        let filename = format!("{}.{}", kind, ext);
        let path = dir.join(&filename);
        std::fs::write(&path, &data).map_err(|e| {
            (StatusCode::INTERNAL_SERVER_ERROR, format!("write error: {}", e)).into_response()
        })?;

        let config_key = format!("ap_{}_path", kind);
        {
            let dbi = state.db.lock().await;
            db::set_server_config(&dbi.db, &config_key, &filename);
            db::log_admin_action(&dbi.db, &format!("upload_{}", kind), None, Some(&filename));
        }

        // publish actor update to followers
        crate::activitypub::outbox::publish_actor_update(state).await;

        return Ok(Json(serde_json::json!({ "ok": true, "filename": filename })));
    }

    Err((StatusCode::BAD_REQUEST, "no file in request").into_response())
}

pub async fn upload_avatar(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    multipart: Multipart,
) -> Result<Json<serde_json::Value>, Response> {
    handle_image_upload(&state, &headers, multipart, "avatar").await
}

pub async fn upload_banner(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    multipart: Multipart,
) -> Result<Json<serde_json::Value>, Response> {
    handle_image_upload(&state, &headers, multipart, "banner").await
}

pub async fn serve_image(
    State(state): State<Arc<AppState>>,
    kind: &str,
) -> Result<Response, Response> {
    let config_key = format!("ap_{}_path", kind);
    let filename = {
        let dbi = state.db.lock().await;
        db::get_server_config(&dbi.db, &config_key)
    };

    let filename = filename.ok_or_else(|| (StatusCode::NOT_FOUND, "not found").into_response())?;

    let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "./data".into());
    let path = std::path::PathBuf::from(&data_dir).join(&filename);

    let data = std::fs::read(&path)
        .map_err(|_| (StatusCode::NOT_FOUND, "not found").into_response())?;

    let ct = if filename.ends_with(".png") { "image/png" }
        else if filename.ends_with(".gif") { "image/gif" }
        else if filename.ends_with(".webp") { "image/webp" }
        else { "image/jpeg" };

    Ok((
        [
            (axum::http::header::CONTENT_TYPE, ct),
            (axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
        ],
        data,
    ).into_response())
}

pub async fn serve_avatar(
    State(state): State<Arc<AppState>>,
) -> Result<Response, Response> {
    serve_image(State(state), "avatar").await
}

pub async fn serve_banner(
    State(state): State<Arc<AppState>>,
) -> Result<Response, Response> {
    serve_image(State(state), "banner").await
}

pub async fn ap_humor_defaults() -> Json<serde_json::Value> {
    let taglines: Vec<&str> = crate::activitypub::outbox::TAGLINES.to_vec();
    let signoffs: Vec<&str> = crate::activitypub::outbox::SIGNOFFS.to_vec();
    let middles: Vec<&str> = crate::activitypub::outbox::COMMUNITY_MIDDLES.to_vec();
    Json(serde_json::json!({
        "taglines": taglines,
        "signoffs": signoffs,
        "middles": middles,
    }))
}

pub async fn list_community_puzzles(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at
         FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token
         WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200"
    ).unwrap();
    let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "id": row.get::<_, String>(0)?,
            "title": row.get::<_, String>(1)?,
            "author": row.get::<_, Option<String>>(2)?.unwrap_or_else(|| "Anonymous".into()),
            "width": row.get::<_, i64>(3)?,
            "height": row.get::<_, i64>(4)?,
            "created_at": row.get::<_, String>(5)?,
            "updated_at": row.get::<_, String>(6)?,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "puzzles": puzzles })))
}

pub async fn delete_community_puzzle(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1 AND status = 'published'", [&id]).ok();
    dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [&id]).ok();
    db::log_admin_action(&dbi.db, "delete_community_puzzle", Some(&id), None);
    drop(dbi);
    if let Ok(uuid) = uuid::Uuid::parse_str(&id) {
        state.puzzles.write().await.remove(&uuid);
    }
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true })))
}

pub async fn batch_delete_community_puzzles(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let ids = req.get("ids").and_then(|v| v.as_array())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing ids array").into_response())?;
    let dbi = state.db.lock().await;
    let mut deleted = 0;
    let mut deleted_ids = Vec::new();
    for id_val in ids {
        if let Some(id) = id_val.as_str() {
            let n = dbi.db.execute("DELETE FROM user_puzzles WHERE id = ?1 AND status = 'published'", [id]).unwrap_or(0);
            dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [id]).ok();
            if n > 0 { deleted_ids.push(id.to_string()); }
            deleted += n;
        }
    }
    db::log_admin_action(&dbi.db, "batch_delete_community_puzzles", None, Some(&format!("{} deleted", deleted)));
    drop(dbi);
    let mut puzzles = state.puzzles.write().await;
    for id in &deleted_ids {
        if let Ok(uuid) = uuid::Uuid::parse_str(id) {
            puzzles.remove(&uuid);
        }
    }
    drop(puzzles);
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}

pub async fn list_all_puzzles_filtered(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let ptype = params.get("type").map(|s| s.as_str()).unwrap_or("all");
    let dbi = state.db.lock().await;

    let query = match ptype {
        "daily" => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at,
             (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1),
             (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id)
             FROM puzzles p WHERE p.daily_date IS NOT NULL ORDER BY p.created_at DESC LIMIT 200",
        "custom" => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at,
             (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1),
             (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id)
             FROM puzzles p WHERE p.daily_date IS NULL ORDER BY p.created_at DESC LIMIT 200",
        _ => "SELECT p.id, p.short_id, p.daily_date, p.preset, p.width, p.height, p.difficulty, p.created_at,
             (SELECT COUNT(*) FROM solve_records sr WHERE sr.puzzle_id = p.id AND sr.is_complete = 1),
             (SELECT COALESCE(AVG(CAST(pr.rating AS REAL)), 0) FROM puzzle_ratings pr WHERE pr.puzzle_id = p.id)
             FROM puzzles p ORDER BY p.created_at DESC LIMIT 200",
    };

    let mut stmt = dbi.db.prepare(query).unwrap();
    let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
        Ok(serde_json::json!({
            "id": row.get::<_, String>(0)?,
            "short_id": row.get::<_, String>(1)?,
            "daily_date": row.get::<_, Option<String>>(2)?,
            "preset": row.get::<_, Option<String>>(3)?,
            "width": row.get::<_, i64>(4)?,
            "height": row.get::<_, i64>(5)?,
            "difficulty": row.get::<_, i64>(6)?,
            "created_at": row.get::<_, String>(7)?,
            "solves": row.get::<_, i64>(8)?,
            "rating": (row.get::<_, f64>(9)? * 10.0).round() / 10.0,
        }))
    }).unwrap().filter_map(|r| r.ok()).collect();
    Ok(Json(serde_json::json!({ "puzzles": puzzles })))
}

pub async fn batch_delete_puzzles(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
    check_admin(&headers).await?;
    let ids = req.get("ids").and_then(|v| v.as_array())
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing ids array").into_response())?;
    let dbi = state.db.lock().await;
    let mut deleted = 0;
    let mut deleted_ids = Vec::new();
    for id_val in ids {
        if let Some(id) = id_val.as_str() {
            let n = dbi.db.execute("DELETE FROM puzzles WHERE id = ?1", [id]).unwrap_or(0);
            if n > 0 { deleted_ids.push(id.to_string()); }
            deleted += n;
        }
    }
    db::log_admin_action(&dbi.db, "batch_delete_puzzles", None, Some(&format!("{} deleted", deleted)));
    drop(dbi);
    let mut puzzles = state.puzzles.write().await;
    for id in &deleted_ids {
        if let Ok(uuid) = uuid::Uuid::parse_str(id) {
            puzzles.remove(&uuid);
        }
    }
    drop(puzzles);
    invalidate_stats_cache();
    Ok(Json(serde_json::json!({ "ok": true, "deleted": deleted })))
}
