use std::path::Path;

use rusqlite::{params, Connection};

pub fn init_db(path: &Path) -> Connection {
    let db = Connection::open(path).expect("failed to open cruciverb.db");
    db.execute_batch("PRAGMA busy_timeout = 5000;").unwrap();
    db.execute_batch("PRAGMA journal_mode = DELETE;").unwrap();

    db.execute_batch("
        CREATE TABLE IF NOT EXISTS sessions (
            token       TEXT PRIMARY KEY,
            display_name TEXT,
            created_at  TEXT NOT NULL,
            last_seen   TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS puzzles (
            id          TEXT PRIMARY KEY,
            short_id    TEXT NOT NULL UNIQUE,
            daily_date  TEXT,
            preset      TEXT,
            width       INTEGER NOT NULL,
            height      INTEGER NOT NULL,
            difficulty  INTEGER NOT NULL,
            puzzle_json TEXT NOT NULL,
            created_at  TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_puzzles_daily ON puzzles(daily_date, preset);
        CREATE INDEX IF NOT EXISTS idx_puzzles_short ON puzzles(short_id);

        -- one canonical row per daily puzzle: drop any historical duplicates,
        -- then enforce it so concurrent generation can never double-store
        DELETE FROM puzzles WHERE daily_date IS NOT NULL AND rowid NOT IN (
            SELECT MIN(rowid) FROM puzzles WHERE daily_date IS NOT NULL GROUP BY daily_date, preset
        );
        CREATE UNIQUE INDEX IF NOT EXISTS idx_puzzles_daily_unique ON puzzles(daily_date, preset);

        CREATE TABLE IF NOT EXISTS solve_records (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            session_token   TEXT NOT NULL,
            puzzle_id       TEXT NOT NULL,
            started_at      TEXT NOT NULL,
            completed_at    TEXT,
            solve_time_secs INTEGER,
            hints_used      INTEGER NOT NULL DEFAULT 0,
            is_complete     INTEGER NOT NULL DEFAULT 0,
            progress_json   TEXT
        );
        CREATE INDEX IF NOT EXISTS idx_solve_session ON solve_records(session_token, completed_at DESC);
        CREATE UNIQUE INDEX IF NOT EXISTS idx_solve_unique ON solve_records(session_token, puzzle_id);
        CREATE INDEX IF NOT EXISTS idx_solve_records_puzzle ON solve_records(puzzle_id);

        CREATE TABLE IF NOT EXISTS streaks (
            session_token   TEXT PRIMARY KEY,
            current_streak  INTEGER NOT NULL DEFAULT 0,
            best_streak     INTEGER NOT NULL DEFAULT 0,
            last_solve_date TEXT
        );

        CREATE TABLE IF NOT EXISTS achievements (
            session_token   TEXT NOT NULL,
            achievement_id  TEXT NOT NULL,
            unlocked_at     TEXT NOT NULL,
            PRIMARY KEY (session_token, achievement_id)
        );

        CREATE TABLE IF NOT EXISTS settings (
            session_token   TEXT PRIMARY KEY,
            settings_json   TEXT NOT NULL DEFAULT '{}'
        );

        CREATE TABLE IF NOT EXISTS community_clues (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            word            TEXT NOT NULL,
            clue_text       TEXT NOT NULL,
            submitted_by    TEXT NOT NULL,
            upvotes         INTEGER NOT NULL DEFAULT 0,
            downvotes       INTEGER NOT NULL DEFAULT 0,
            status          TEXT NOT NULL DEFAULT 'pending',
            created_at      TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_cc_word ON community_clues(word, status);
        CREATE INDEX IF NOT EXISTS idx_cc_submitter ON community_clues(submitted_by);

        CREATE TABLE IF NOT EXISTS clue_votes (
            session_token   TEXT NOT NULL,
            clue_id         INTEGER NOT NULL,
            vote            INTEGER NOT NULL,
            PRIMARY KEY (session_token, clue_id)
        );

        CREATE TABLE IF NOT EXISTS clue_quality (
            word            TEXT NOT NULL,
            clue_text       TEXT NOT NULL,
            total_score     INTEGER NOT NULL DEFAULT 0,
            rating_count    INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (word, clue_text)
        );

        CREATE TABLE IF NOT EXISTS word_quality (
            word            TEXT PRIMARY KEY,
            total_score     INTEGER NOT NULL DEFAULT 0,
            rating_count    INTEGER NOT NULL DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS puzzle_ratings (
            session_token   TEXT NOT NULL,
            puzzle_id       TEXT NOT NULL,
            rating          INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
            created_at      TEXT NOT NULL,
            PRIMARY KEY (session_token, puzzle_id)
        );
        CREATE INDEX IF NOT EXISTS idx_pr_puzzle ON puzzle_ratings(puzzle_id);

        CREATE TABLE IF NOT EXISTS user_puzzles (
            id              TEXT PRIMARY KEY,
            session_token   TEXT NOT NULL,
            title           TEXT NOT NULL DEFAULT 'Untitled',
            width           INTEGER NOT NULL,
            height          INTEGER NOT NULL,
            grid_json       TEXT NOT NULL,
            clues_json      TEXT,
            puzzle_json     TEXT,
            status          TEXT NOT NULL DEFAULT 'draft',
            created_at      TEXT NOT NULL,
            updated_at      TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_up_session ON user_puzzles(session_token, status);

        CREATE TABLE IF NOT EXISTS webhooks (
            id              TEXT PRIMARY KEY,
            url             TEXT NOT NULL,
            events          TEXT NOT NULL,
            secret          TEXT,
            format          TEXT NOT NULL DEFAULT 'json',
            enabled         INTEGER NOT NULL DEFAULT 1,
            created_at      TEXT NOT NULL,
            failure_count   INTEGER NOT NULL DEFAULT 0
        );

        CREATE TABLE IF NOT EXISTS ap_keys (
            id              INTEGER PRIMARY KEY CHECK (id = 1),
            public_pem      TEXT NOT NULL,
            private_pem     TEXT NOT NULL,
            created_at      TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS ap_followers (
            actor_uri       TEXT PRIMARY KEY,
            inbox_uri       TEXT NOT NULL,
            shared_inbox    TEXT,
            accepted        INTEGER NOT NULL DEFAULT 0,
            created_at      TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS ap_peers (
            domain          TEXT PRIMARY KEY,
            actor_uri       TEXT NOT NULL,
            inbox_uri       TEXT NOT NULL,
            public_pem      TEXT,
            last_seen       TEXT,
            created_at      TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS ap_outbox (
            id              TEXT PRIMARY KEY,
            activity        TEXT NOT NULL,
            published       TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_ap_outbox_pub ON ap_outbox(published DESC);

        CREATE TABLE IF NOT EXISTS ap_delivery_queue (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            inbox_uri       TEXT NOT NULL,
            payload         TEXT NOT NULL,
            attempts        INTEGER NOT NULL DEFAULT 0,
            next_retry      TEXT NOT NULL,
            created_at      TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_ap_dq_retry ON ap_delivery_queue(next_retry);

        CREATE TABLE IF NOT EXISTS ap_stats (
            domain          TEXT NOT NULL,
            direction       TEXT NOT NULL,
            count           INTEGER NOT NULL DEFAULT 0,
            last_activity   TEXT NOT NULL,
            PRIMARY KEY (domain, direction)
        );

        CREATE TABLE IF NOT EXISTS ap_blocked (
            domain          TEXT PRIMARY KEY,
            reason          TEXT,
            blocked_at      TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS server_config (
            key     TEXT PRIMARY KEY,
            value   TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS passkeys (
            credential_id   TEXT PRIMARY KEY,
            session_token   TEXT NOT NULL,
            passkey_json    TEXT NOT NULL,
            name            TEXT NOT NULL DEFAULT 'My passkey',
            created_at      TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_passkeys_session ON passkeys(session_token);

        CREATE TABLE IF NOT EXISTS admin_audit_log (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            action      TEXT NOT NULL,
            target      TEXT,
            detail      TEXT,
            timestamp   TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS admin_accounts (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            username        TEXT NOT NULL UNIQUE,
            password_hash   TEXT NOT NULL,
            created_at      TEXT NOT NULL
        );
    ").unwrap();

    // federation column migrations
    db.execute(
        "ALTER TABLE community_clues ADD COLUMN origin_domain TEXT",
        [],
    )
    .ok();
    db.execute("ALTER TABLE community_clues ADD COLUMN origin_id TEXT", [])
        .ok();
    db.execute("ALTER TABLE clue_votes ADD COLUMN origin_domain TEXT", [])
        .ok();

    // content reports
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS content_reports (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            reporter_token TEXT NOT NULL,
            content_type TEXT NOT NULL,
            content_id TEXT NOT NULL,
            reason TEXT,
            status TEXT NOT NULL DEFAULT 'pending',
            created_at TEXT NOT NULL
        );
    ",
    )
    .unwrap();

    // instance reputation
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS ap_instance_reputation (
            domain TEXT PRIMARY KEY,
            score REAL NOT NULL DEFAULT 100.0,
            total_clues INTEGER NOT NULL DEFAULT 0,
            rejected_clues INTEGER NOT NULL DEFAULT 0,
            reports_against INTEGER NOT NULL DEFAULT 0,
            last_updated TEXT NOT NULL
        );
    ",
    )
    .unwrap();

    // puzzle content hash
    db.execute("ALTER TABLE puzzles ADD COLUMN content_hash TEXT", [])
        .ok();

    // federated leaderboard entries
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS federated_leaderboard (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            origin_domain TEXT NOT NULL,
            puzzle_date TEXT NOT NULL,
            preset TEXT NOT NULL,
            player_name TEXT NOT NULL,
            solve_time INTEGER NOT NULL,
            received_at TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_fl_date ON federated_leaderboard(puzzle_date, preset);
    ",
    )
    .unwrap();

    // cross-instance puzzle origin tracking
    db.execute("ALTER TABLE user_puzzles ADD COLUMN origin_domain TEXT", [])
        .ok();

    // boost and reply tracking
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS ap_boosts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            outbox_id TEXT NOT NULL,
            actor_uri TEXT NOT NULL,
            received_at TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS ap_replies (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            outbox_id TEXT NOT NULL,
            puzzle_id TEXT,
            actor_uri TEXT NOT NULL,
            actor_name TEXT,
            content TEXT NOT NULL,
            published TEXT,
            received_at TEXT NOT NULL
        );
    ",
    )
    .unwrap();

    // group actor followers for themed collections
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS ap_group_followers (
            group_name TEXT NOT NULL,
            actor_uri TEXT NOT NULL,
            inbox_uri TEXT NOT NULL,
            accepted INTEGER NOT NULL DEFAULT 1,
            created_at TEXT NOT NULL,
            PRIMARY KEY (group_name, actor_uri)
        );
    ",
    )
    .unwrap();

    // wordlist packs
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS wordlist_packs (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            description TEXT,
            author_session TEXT,
            author_name TEXT,
            tags TEXT,
            word_count INTEGER NOT NULL,
            download_count INTEGER DEFAULT 0,
            rating_avg REAL,
            rating_count INTEGER DEFAULT 0,
            pack_data TEXT NOT NULL,
            status TEXT DEFAULT 'published',
            created_at TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS pack_ratings (
            pack_id TEXT NOT NULL,
            session_token TEXT NOT NULL,
            rating INTEGER NOT NULL,
            created_at TEXT NOT NULL,
            PRIMARY KEY (pack_id, session_token)
        );
    ",
    )
    .unwrap();

    // api keys
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS api_keys (
            id TEXT PRIMARY KEY,
            session_token TEXT NOT NULL,
            key_hash TEXT NOT NULL,
            name TEXT NOT NULL,
            permissions TEXT NOT NULL DEFAULT 'read',
            rate_limit_per_min INTEGER DEFAULT 100,
            is_active INTEGER DEFAULT 1,
            created_at TEXT NOT NULL,
            last_used_at TEXT,
            expires_at TEXT
        );
    ",
    )
    .unwrap();

    // anti-cheat: flagged column on solve_records
    db.execute(
        "ALTER TABLE solve_records ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0",
        [],
    )
    .ok();

    // anti-cheat: server-side hint tracking
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS puzzle_hints (
            puzzle_id TEXT NOT NULL,
            session_token TEXT NOT NULL,
            hint_count INTEGER NOT NULL DEFAULT 0,
            PRIMARY KEY (puzzle_id, session_token)
        );
    ",
    )
    .unwrap();

    // user banning
    db.execute(
        "ALTER TABLE sessions ADD COLUMN banned INTEGER DEFAULT 0",
        [],
    )
    .ok();

    // session binding fingerprint
    db.execute("ALTER TABLE sessions ADD COLUMN user_agent_hash TEXT", []).ok();

    // solve receipts
    db.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS solve_receipts (
            receipt_id TEXT PRIMARY KEY,
            puzzle_hash TEXT NOT NULL,
            solve_time_secs INTEGER NOT NULL,
            hints_used INTEGER NOT NULL DEFAULT 0,
            solve_date TEXT NOT NULL,
            instance_url TEXT NOT NULL,
            player_name TEXT NOT NULL,
            room_code TEXT,
            mode TEXT,
            players TEXT,
            created_at TEXT NOT NULL,
            expires_at TEXT NOT NULL,
            signature TEXT NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_receipts_expire ON solve_receipts(expires_at);

        CREATE TABLE IF NOT EXISTS claimed_receipts (
            receipt_id TEXT PRIMARY KEY,
            session_token TEXT NOT NULL,
            origin_instance TEXT NOT NULL,
            claimed_at TEXT NOT NULL
        );

        CREATE TABLE IF NOT EXISTS daily_challenges (
            date TEXT NOT NULL,
            preset TEXT NOT NULL,
            challenge_type TEXT NOT NULL,
            PRIMARY KEY (date, preset)
        );

        CREATE TABLE IF NOT EXISTS challenge_completions (
            session_token TEXT NOT NULL,
            date TEXT NOT NULL,
            preset TEXT NOT NULL,
            challenge_type TEXT NOT NULL,
            completed_at TEXT NOT NULL,
            PRIMARY KEY (session_token, date, preset)
        );
    ",
    )
    .unwrap();

    // challenge completion provenance columns
    db.execute("ALTER TABLE challenge_completions ADD COLUMN verified INTEGER NOT NULL DEFAULT 0", [])
        .ok();
    db.execute("ALTER TABLE challenge_completions ADD COLUMN client_attested INTEGER NOT NULL DEFAULT 0", [])
        .ok();

    // analytics indexes
    db.execute_batch(
        "
        CREATE INDEX IF NOT EXISTS idx_solve_completed ON solve_records(completed_at);
        CREATE INDEX IF NOT EXISTS idx_solve_complete_flag ON solve_records(is_complete);
        CREATE INDEX IF NOT EXISTS idx_sessions_last_seen ON sessions(last_seen);
        CREATE INDEX IF NOT EXISTS idx_cc_status ON community_clues(status);
        CREATE INDEX IF NOT EXISTS idx_reports_status ON content_reports(status);
        CREATE INDEX IF NOT EXISTS idx_audit_action ON admin_audit_log(action);
    ",
    )
    .unwrap();

    // migrations
    let has_freezes: bool = db
        .query_row(
            "SELECT COUNT(*) FROM pragma_table_info('streaks') WHERE name = 'freezes'",
            [],
            |row| row.get::<_, i64>(0),
        )
        .unwrap_or(0)
        > 0;
    if !has_freezes {
        db.execute_batch("ALTER TABLE streaks ADD COLUMN freezes INTEGER NOT NULL DEFAULT 0")
            .unwrap();
    }

    // cell results column for per-cell solve data
    db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", [])
        .ok();

    db
}

pub fn ban_session(db: &Connection, token: &str) {
    db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token])
        .ok();
}

pub fn unban_session(db: &Connection, token: &str) {
    db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token])
        .ok();
}

pub fn is_banned(db: &Connection, token: &str) -> bool {
    db.query_row(
        "SELECT banned FROM sessions WHERE token = ?1",
        [token],
        |r| r.get::<_, i64>(0),
    )
    .unwrap_or(0)
        != 0
}

pub fn get_user_published_puzzles(db: &Connection, token: &str) -> Vec<String> {
    let mut stmt = db
        .prepare("SELECT id FROM user_puzzles WHERE session_token = ?1 AND status = 'published'")
        .unwrap();
    stmt.query_map([token], |row| row.get(0))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect()
}

pub fn cleanup_user_content(db: &Connection, token: &str) {
    db.execute("DELETE FROM user_puzzles WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM community_clues WHERE submitted_by = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM clue_votes WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM puzzle_ratings WHERE session_token = ?1",
        [token],
    )
    .ok();
    db.execute(
        "UPDATE solve_records SET flagged = 1 WHERE session_token = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM pack_ratings WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM wordlist_packs WHERE author_session = ?1",
        [token],
    )
    .ok();
    db.execute(
        "DELETE FROM content_reports WHERE reporter_token = ?1",
        [token],
    )
    .ok();
    // clear display name so deleted/banned users can't be looked up
    db.execute(
        "UPDATE sessions SET display_name = NULL WHERE token = ?1",
        [token],
    )
    .ok();
}

// api key management

pub fn create_api_key(
    db: &Connection,
    id: &str,
    session_token: &str,
    key_hash: &str,
    name: &str,
    permissions: &str,
) -> bool {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO api_keys (id, session_token, key_hash, name, permissions, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        params![id, session_token, key_hash, name, permissions, now],
    ).is_ok()
}

pub fn get_api_key_by_hash(
    db: &Connection,
    key_hash: &str,
) -> Option<(String, String, String, i64, i64, Option<String>)> {
    db.query_row(
        "SELECT id, session_token, permissions, rate_limit_per_min, is_active, expires_at FROM api_keys WHERE key_hash = ?1",
        [key_hash],
        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
    ).ok()
}

pub fn list_api_keys(
    db: &Connection,
    session_token: &str,
) -> Vec<(String, String, String, String, i64, Option<String>)> {
    let mut stmt = db.prepare(
        "SELECT id, name, permissions, created_at, is_active, last_used_at FROM api_keys WHERE session_token = ?1 ORDER BY created_at DESC"
    ).unwrap();
    stmt.query_map([session_token], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn delete_api_key(db: &Connection, id: &str, session_token: &str) -> bool {
    let rows = db
        .execute(
            "DELETE FROM api_keys WHERE id = ?1 AND session_token = ?2",
            params![id, session_token],
        )
        .unwrap_or(0);
    rows > 0
}

pub fn touch_api_key(db: &Connection, id: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2",
        params![now, id],
    )
    .ok();
}

pub fn has_admin_account(db: &Connection) -> bool {
    db.query_row("SELECT COUNT(*) FROM admin_accounts", [], |r| {
        r.get::<_, i64>(0)
    })
    .unwrap_or(0)
        > 0
}

pub fn create_admin_account(db: &Connection, username: &str, password_hash: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO admin_accounts (username, password_hash, created_at) VALUES (?1, ?2, ?3)",
        params![username, password_hash, now],
    )
    .ok();
}

pub fn verify_admin_account(db: &Connection, username: &str, password_hash: &str) -> bool {
    let stored: Option<String> = db
        .query_row(
            "SELECT password_hash FROM admin_accounts WHERE username = ?1",
            [username],
            |r| r.get(0),
        )
        .ok();
    match stored {
        Some(stored_hash) if stored_hash.starts_with("$argon2") => {
            use argon2::PasswordVerifier;
            let parsed = match argon2::PasswordHash::new(&stored_hash) {
                Ok(p) => p,
                Err(_) => return false,
            };
            argon2::Argon2::default()
                .verify_password(password_hash.as_bytes(), &parsed)
                .is_ok()
        }
        Some(stored_hash) => {
            // legacy salt:sha256 format - verify, and on success the caller
            // upgrades the row via rehash_admin_password
            let parts: Vec<&str> = stored_hash.splitn(2, ':').collect();
            if parts.len() != 2 {
                return false;
            }
            let salt = parts[0];
            let expected = parts[1];
            use sha2::{Digest, Sha256};
            let mut hasher = Sha256::new();
            hasher.update(salt.as_bytes());
            hasher.update(password_hash.as_bytes());
            let computed = hex::encode(hasher.finalize());
            computed.len() == expected.len()
                && computed.bytes().zip(expected.bytes()).all(|(a, b)| a == b)
        }
        None => false,
    }
}

/// Called after a legacy hash verifies - swaps the stored row to argon2id.
pub fn rehash_admin_password(db: &Connection, username: &str, password: &str) {
    use argon2::PasswordHasher;
    let salt =
        argon2::password_hash::SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
    let hash = match argon2::Argon2::default().hash_password(password.as_bytes(), &salt) {
        Ok(h) => h.to_string(),
        Err(_) => return,
    };
    db.execute(
        "UPDATE admin_accounts SET password_hash = ?1 WHERE username = ?2",
        params![hash, username],
    )
    .ok();
}

pub fn log_admin_action(db: &Connection, action: &str, target: Option<&str>, detail: Option<&str>) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO admin_audit_log (action, target, detail, timestamp) VALUES (?1, ?2, ?3, ?4)",
        params![action, target, detail, now],
    )
    .ok();
}

pub fn get_audit_log(
    db: &Connection,
    limit: usize,
) -> Vec<(i64, String, Option<String>, Option<String>, String)> {
    let mut stmt = db.prepare(
        "SELECT id, action, target, detail, timestamp FROM admin_audit_log ORDER BY id DESC LIMIT ?1"
    ).unwrap();
    stmt.query_map([limit as i64], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// short ID from UUID - base62 encode 60 bits so collisions stay negligible
pub fn make_short_id(uuid: &uuid::Uuid) -> String {
    let bytes = uuid.as_bytes();
    let n = u64::from_be_bytes([
        bytes[0], bytes[1], bytes[2], bytes[3],
        bytes[4], bytes[5], bytes[6], bytes[7],
    ]) & 0x0FFF_FFFF_FFFF_FFFF;
    base62_encode(n)
}

fn base62_encode(mut n: u64) -> String {
    const CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
    if n == 0 {
        return "00000000000".into();
    }
    let mut s = Vec::new();
    while n > 0 {
        s.push(CHARS[(n % 62) as usize]);
        n /= 62;
    }
    while s.len() < 11 {
        s.push(b'0');
    }
    s.reverse();
    String::from_utf8(s).unwrap()
}

// puzzle storage
/// Stores a puzzle, allocating the short id with retries so a collision can
/// never silently replace the puzzle that already owns it.
pub fn store_puzzle(
    db: &Connection,
    id: &str,
    puzzle_json: &str,
    width: usize,
    height: usize,
    difficulty: u8,
    daily_date: Option<&str>,
    preset: Option<&str>,
) -> String {
    let mut short_id = make_short_id(&uuid::Uuid::new_v4());
    for _ in 0..3 {
        let now = chrono::Utc::now().to_rfc3339();
        let res = db.execute(
            "INSERT INTO puzzles (id, short_id, daily_date, preset, width, height, difficulty, puzzle_json, created_at)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
             ON CONFLICT(id) DO UPDATE SET
               short_id = excluded.short_id, daily_date = excluded.daily_date,
               preset = excluded.preset, width = excluded.width, height = excluded.height,
               difficulty = excluded.difficulty, puzzle_json = excluded.puzzle_json,
               created_at = excluded.created_at",
            params![id, short_id, daily_date, preset, width as i64, height as i64, difficulty, puzzle_json, now],
        );
        if res.is_ok() {
            return short_id;
        }
        // unique-contraint hit on short_id - draw a new one and retry
        short_id = make_short_id(&uuid::Uuid::new_v4());
    }
    // three collisions in a row is effectively impossible at 60 bits;
    // return the last id and let the caller surface any persistence error
    short_id
}

pub fn get_puzzle_json(db: &Connection, id: &str) -> Option<(String, String)> {
    db.query_row(
        "SELECT puzzle_json, short_id FROM puzzles WHERE id = ?1",
        [id],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
    )
    .ok()
}

pub fn get_puzzle_by_short_id(db: &Connection, short_id: &str) -> Option<(String, String)> {
    db.query_row(
        "SELECT id, puzzle_json FROM puzzles WHERE short_id = ?1",
        [short_id],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
    )
    .ok()
}

pub fn get_daily_puzzle(
    db: &Connection,
    date: &str,
    preset: &str,
) -> Option<(String, String, String)> {
    db.query_row(
        "SELECT id, short_id, puzzle_json FROM puzzles WHERE daily_date = ?1 AND preset = ?2",
        params![date, preset],
        |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
            ))
        },
    )
    .ok()
}

pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String, String)> {
    db.query_row(
        "SELECT daily_date, preset FROM puzzles WHERE id = ?1 AND daily_date IS NOT NULL AND preset IS NOT NULL",
        [puzzle_id],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
    ).ok()
}

// sessions
pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) {
    let now = chrono::Utc::now().to_rfc3339();

    let existing = db.query_row(
        "SELECT token, display_name FROM sessions WHERE token = ?1",
        [token],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
    );

    if let Ok((t, name)) = existing {
        db.execute(
            "UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
            params![now, token],
        )
        .ok();
        return (t, name);
    }

    db.execute(
        "INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)",
        params![token, now],
    )
    .unwrap();
    (token.to_string(), None)
}

pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> {
    db.query_row(
        "SELECT user_agent_hash FROM sessions WHERE token = ?1",
        [token],
        |r| r.get(0),
    )
    .ok()
    .flatten()
}

pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) {
    db.execute(
        "UPDATE sessions SET user_agent_hash = ?1 WHERE token = ?2",
        params![hash, token],
    )
    .ok();
}

pub fn set_display_name(db: &Connection, token: &str, name: &str) {
    db.execute(
        "UPDATE sessions SET display_name = ?1 WHERE token = ?2",
        params![name, token],
    )
    .ok();
}

// solve records
pub fn record_solve(
    db: &Connection,
    token: &str,
    puzzle_id: &str,
    time_secs: i64,
    hints: i64,
    flagged: bool,
    cell_results: Option<&str>,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR REPLACE INTO solve_records (session_token, puzzle_id, started_at, completed_at, solve_time_secs, hints_used, is_complete, flagged, cell_results)
         VALUES (?1, ?2, ?3, ?3, ?4, ?5, 1, ?6, ?7)",
        params![token, puzzle_id, now, time_secs, hints, flagged as i64, cell_results],
    ).unwrap();
}

pub fn get_solve_info(
    db: &Connection,
    token: &str,
    puzzle_id: &str,
) -> Option<(i64, i64, Option<String>)> {
    db.query_row(
        "SELECT solve_time_secs, hints_used, cell_results FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2 AND is_complete = 1",
        params![token, puzzle_id],
        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, Option<String>>(2)?)),
    ).ok()
}

pub fn has_solve_record(db: &Connection, token: &str, puzzle_id: &str) -> bool {
    db.query_row(
        "SELECT 1 FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2 AND is_complete = 1",
        params![token, puzzle_id],
        |_| Ok(()),
    ).is_ok()
}

pub fn increment_hint_count(db: &Connection, puzzle_id: &str, token: &str) {
    db.execute(
        "INSERT INTO puzzle_hints (puzzle_id, session_token, hint_count)
         VALUES (?1, ?2, 1)
         ON CONFLICT(puzzle_id, session_token) DO UPDATE SET hint_count = hint_count + 1",
        params![puzzle_id, token],
    )
    .unwrap();
}

pub fn get_hint_count(db: &Connection, puzzle_id: &str, token: &str) -> i64 {
    db.query_row(
        "SELECT hint_count FROM puzzle_hints WHERE puzzle_id = ?1 AND session_token = ?2",
        params![puzzle_id, token],
        |row| row.get(0),
    )
    .unwrap_or(0)
}

pub fn save_progress(db: &Connection, token: &str, puzzle_id: &str, grid_json: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO solve_records (session_token, puzzle_id, started_at, progress_json)
         VALUES (?1, ?2, ?3, ?4)
         ON CONFLICT(session_token, puzzle_id) DO UPDATE SET progress_json = ?4",
        params![token, puzzle_id, now, grid_json],
    )
    .unwrap();
}

pub fn load_progress(db: &Connection, token: &str, puzzle_id: &str) -> Option<String> {
    db.query_row(
        "SELECT progress_json FROM solve_records WHERE session_token = ?1 AND puzzle_id = ?2",
        params![token, puzzle_id],
        |row| row.get::<_, Option<String>>(0),
    )
    .ok()
    .flatten()
}

// streaks
pub fn get_streak(db: &Connection, token: &str) -> (i64, i64, Option<String>) {
    db.query_row(
        "SELECT current_streak, best_streak, last_solve_date FROM streaks WHERE session_token = ?1",
        [token],
        |row| {
            Ok((
                row.get::<_, i64>(0)?,
                row.get::<_, i64>(1)?,
                row.get::<_, Option<String>>(2)?,
            ))
        },
    )
    .unwrap_or((0, 0, None))
}

pub fn get_streak_freezes(db: &Connection, token: &str) -> i64 {
    db.query_row(
        "SELECT freezes FROM streaks WHERE session_token = ?1",
        [token],
        |row| row.get::<_, i64>(0),
    )
    .unwrap_or(0)
}

pub fn add_streak_freeze(db: &Connection, token: &str) {
    db.execute(
        "UPDATE streaks SET freezes = MIN(freezes + 1, 3) WHERE session_token = ?1",
        [token],
    )
    .ok();
}

pub fn use_streak_freeze(db: &Connection, token: &str) -> bool {
    let freezes = get_streak_freezes(db, token);
    if freezes > 0 {
        db.execute(
            "UPDATE streaks SET freezes = freezes - 1 WHERE session_token = ?1",
            [token],
        )
        .ok();
        true
    } else {
        false
    }
}

pub fn update_streak(db: &Connection, token: &str, today: &str) {
    let (current, best, last_date) = get_streak(db, token);

    let new_current = if let Some(last) = &last_date {
        if last == today {
            return; // already solved today
        }
        let last_d = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").unwrap_or_default();
        let today_d = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d").unwrap_or_default();
        let diff = (today_d - last_d).num_days();
        if diff == 1 {
            current + 1
        } else if diff == 2 && use_streak_freeze(db, token) {
            // missed exactly one day, use a freeze to keep the streak
            current + 1
        } else {
            1
        }
    } else {
        1
    };

    let new_best = new_current.max(best);

    db.execute(
        "INSERT INTO streaks (session_token, current_streak, best_streak, last_solve_date)
         VALUES (?1, ?2, ?3, ?4)
         ON CONFLICT(session_token) DO UPDATE SET current_streak = ?2, best_streak = ?3, last_solve_date = ?4",
        params![token, new_current, new_best, today],
    ).unwrap();

    // award a freeze for every 7-day streak milestone
    if new_current > 0 && new_current % 7 == 0 {
        add_streak_freeze(db, token);
    }
}

// stats
pub fn get_stats(db: &Connection, token: &str) -> Vec<(String, String, i64, i64)> {
    let mut stmt = db
        .prepare(
            "SELECT sr.puzzle_id, p.preset, sr.solve_time_secs, sr.hints_used
         FROM solve_records sr
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         WHERE sr.session_token = ?1 AND sr.is_complete = 1
         ORDER BY sr.completed_at DESC
         LIMIT 50",
        )
        .unwrap();

    stmt.query_map([token], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, Option<String>>(1)?.unwrap_or_default(),
            row.get::<_, i64>(2)?,
            row.get::<_, i64>(3)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// community clues
pub fn submit_clue(db: &Connection, word: &str, clue_text: &str, token: &str) -> i64 {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO community_clues (word, clue_text, submitted_by, created_at)
         VALUES (?1, ?2, ?3, ?4)",
        params![word.to_uppercase(), clue_text, token, now],
    )
    .unwrap();
    db.last_insert_rowid()
}

pub fn get_community_clues(
    db: &Connection,
    word: &str,
) -> Vec<(i64, String, i64, i64, String, String, Option<String>)> {
    let mut stmt = db.prepare(
        "SELECT cc.id, cc.clue_text, cc.upvotes, cc.downvotes, cc.status, cc.created_at, cc.origin_domain
         FROM community_clues cc WHERE cc.word = ?1
         ORDER BY (cc.upvotes - cc.downvotes) DESC, cc.created_at DESC"
    ).unwrap();
    stmt.query_map([word.to_uppercase()], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
            row.get(6)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool {
    // check if already voted
    let existing: Option<i32> = db
        .query_row(
            "SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
            params![token, clue_id],
            |row| row.get(0),
        )
        .ok();

    if let Some(old_vote) = existing {
        if old_vote == vote {
            return false;
        } // same vote, no change
          // undo old vote
        if old_vote > 0 {
            db.execute(
                "UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        } else {
            db.execute(
                "UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        }
        // apply new vote
        if vote > 0 {
            db.execute(
                "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        } else {
            db.execute(
                "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        }
        db.execute(
            "UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
            params![vote, token, clue_id],
        )
        .ok();
    } else {
        // new vote
        if vote > 0 {
            db.execute(
                "UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        } else {
            db.execute(
                "UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
                [clue_id],
            )
            .ok();
        }
        db.execute(
            "INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
            params![token, clue_id, vote],
        )
        .ok();
    }

    // auto-approve clues with net +3 votes, auto-reject at net -3
    let (up, down): (i64, i64) = db
        .query_row(
            "SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
            [clue_id],
            |row| Ok((row.get(0)?, row.get(1)?)),
        )
        .unwrap_or((0, 0));

    if up - down >= 3 {
        db.execute(
            "UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
            [clue_id],
        )
        .ok();
    } else if down - up >= 3 {
        db.execute(
            "UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
            [clue_id],
        )
        .ok();
    }

    true
}

pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> {
    if clue_ids.is_empty() {
        return vec![];
    }
    let placeholders: Vec<String> = clue_ids.iter().map(|_| "?".into()).collect();
    let sql = format!(
        "SELECT clue_id, vote FROM clue_votes WHERE session_token = ?1 AND clue_id IN ({})",
        placeholders.join(",")
    );
    let mut stmt = db.prepare(&sql).unwrap();
    let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(token.to_string())];
    for id in clue_ids {
        params_vec.push(Box::new(*id));
    }
    stmt.query_map(rusqlite::params_from_iter(params_vec), |row| {
        Ok((row.get::<_, i64>(0)?, row.get::<_, i32>(1)?))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn get_my_submissions(
    db: &Connection,
    token: &str,
) -> Vec<(i64, String, String, i64, i64, String)> {
    let mut stmt = db
        .prepare(
            "SELECT id, word, clue_text, upvotes, downvotes, status
         FROM community_clues WHERE submitted_by = ?1
         ORDER BY created_at DESC LIMIT 50",
        )
        .unwrap();
    stmt.query_map([token], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// puzzle ratings
pub fn rate_puzzle(db: &Connection, token: &str, puzzle_id: &str, rating: i32) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO puzzle_ratings (session_token, puzzle_id, rating, created_at)
         VALUES (?1, ?2, ?3, ?4)
         ON CONFLICT(session_token, puzzle_id) DO UPDATE SET rating = ?3",
        params![token, puzzle_id, rating, now],
    )
    .unwrap();
}

pub fn get_puzzle_rating(db: &Connection, puzzle_id: &str) -> (f64, i64) {
    db.query_row(
        "SELECT COALESCE(AVG(CAST(rating AS REAL)), 0), COUNT(*) FROM puzzle_ratings WHERE puzzle_id = ?1",
        [puzzle_id],
        |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?)),
    ).unwrap_or((0.0, 0))
}

/// Apply a rating change to word and clue quality aggregates.
/// delta is the change in the user's rating (new minus old) and first flags
/// whether this is a brand new rating, in which case the count increments.
pub fn update_quality_from_rating(db: &Connection, puzzle_id: &str, delta: i64, first: bool) {
    let json = match db.query_row(
        "SELECT puzzle_json FROM puzzles WHERE id = ?1",
        [puzzle_id],
        |row| row.get::<_, String>(0),
    ) {
        Ok(j) => j,
        Err(_) => return,
    };

    let puzzle: serde_json::Value = match serde_json::from_str(&json) {
        Ok(v) => v,
        Err(_) => return,
    };

    let count_change: i64 = if first { 1 } else { 0 };

    // extract word-clue pairs from both across and down
    for key in ["clues_across", "clues_down"] {
        if let Some(clues) = puzzle.get(key).and_then(|v| v.as_array()) {
            for clue in clues {
                let word = clue.get("answer").and_then(|v| v.as_str()).unwrap_or("");
                let text = clue.get("text").and_then(|v| v.as_str()).unwrap_or("");
                if word.is_empty() || text.is_empty() {
                    continue;
                }

                db.execute(
                    "INSERT INTO clue_quality (word, clue_text, total_score, rating_count)
                     VALUES (?1, ?2, ?3, ?4)
                     ON CONFLICT(word, clue_text) DO UPDATE SET
                       total_score = MAX(0, total_score + ?3), rating_count = rating_count + ?4",
                    params![word, text, delta, count_change],
                )
                .ok();

                db.execute(
                    "INSERT INTO word_quality (word, total_score, rating_count)
                     VALUES (?1, ?2, ?3)
                     ON CONFLICT(word) DO UPDATE SET
                       total_score = MAX(0, total_score + ?2), rating_count = rating_count + ?3",
                    params![word, delta, count_change],
                )
                .ok();
            }
        }
    }
}

pub fn get_daily_ratings(db: &Connection, date: &str) -> Vec<(String, f64, i64)> {
    let mut stmt = db
        .prepare(
            "SELECT p.preset, COALESCE(AVG(CAST(pr.rating AS REAL)), 0), COUNT(pr.rating)
         FROM puzzles p LEFT JOIN puzzle_ratings pr ON p.id = pr.puzzle_id
         WHERE p.daily_date = ?1 AND p.preset IS NOT NULL
         GROUP BY p.preset",
        )
        .unwrap();
    stmt.query_map([date], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, f64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn get_preferred_preset(db: &Connection, token: &str) -> Option<String> {
    db.query_row(
        "SELECT p.preset FROM puzzle_ratings pr
         JOIN puzzles p ON pr.puzzle_id = p.id
         WHERE pr.session_token = ?1 AND p.preset IS NOT NULL AND pr.rating >= 4
         GROUP BY p.preset ORDER BY AVG(CAST(pr.rating AS REAL)) DESC, COUNT(*) DESC
         LIMIT 1",
        [token],
        |row| row.get(0),
    )
    .ok()
}

pub fn get_clue_quality_scores(
    db: &Connection,
) -> std::collections::HashMap<(String, String), f64> {
    let mut result = std::collections::HashMap::new();
    if let Ok(mut stmt) = db.prepare(
        "SELECT word, clue_text, CAST(total_score AS REAL) / rating_count
         FROM clue_quality WHERE rating_count >= 3",
    ) {
        if let Ok(rows) = stmt.query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, f64>(2)?,
            ))
        }) {
            for r in rows.flatten() {
                result.insert((r.0, r.1), r.2);
            }
        }
    }
    result
}

pub fn get_word_quality_scores(db: &Connection) -> std::collections::HashMap<String, f64> {
    let mut result = std::collections::HashMap::new();
    if let Ok(mut stmt) = db.prepare(
        "SELECT word, CAST(total_score AS REAL) / rating_count
         FROM word_quality WHERE rating_count >= 3",
    ) {
        if let Ok(rows) = stmt.query_map([], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
        }) {
            for r in rows.flatten() {
                result.insert(r.0, r.1);
            }
        }
    }
    result
}

pub fn get_my_rating(db: &Connection, token: &str, puzzle_id: &str) -> Option<i32> {
    db.query_row(
        "SELECT rating FROM puzzle_ratings WHERE session_token = ?1 AND puzzle_id = ?2",
        params![token, puzzle_id],
        |row| row.get(0),
    )
    .ok()
}

pub fn delete_submission(db: &Connection, id: i64, token: &str) {
    db.execute(
        "DELETE FROM community_clues WHERE id = ?1 AND submitted_by = ?2",
        params![id, token],
    )
    .ok();
    db.execute("DELETE FROM clue_votes WHERE clue_id = ?1", [id])
        .ok();
}

// user puzzles (editor)
pub fn save_user_puzzle(
    db: &Connection,
    id: &str,
    token: &str,
    title: &str,
    width: usize,
    height: usize,
    grid_json: &str,
    clues_json: Option<&str>,
    puzzle_json: Option<&str>,
    status: &str,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO user_puzzles (id, session_token, title, width, height, grid_json, clues_json, puzzle_json, status, created_at, updated_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)
         ON CONFLICT(id) DO UPDATE SET title=?3, grid_json=?6, clues_json=?7, puzzle_json=?8, status=?9, updated_at=?10",
        params![id, token, title, width as i64, height as i64, grid_json, clues_json, puzzle_json, status, now],
    ).unwrap();
}

pub fn get_user_puzzle(
    db: &Connection,
    id: &str,
    token: &str,
) -> Option<(
    String,
    String,
    i64,
    i64,
    String,
    Option<String>,
    Option<String>,
    String,
)> {
    db.query_row(
        "SELECT id, title, width, height, grid_json, clues_json, puzzle_json, status
         FROM user_puzzles WHERE id = ?1 AND session_token = ?2",
        params![id, token],
        |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
                row.get(5)?,
                row.get(6)?,
                row.get(7)?,
            ))
        },
    )
    .ok()
}

pub fn list_user_puzzles(
    db: &Connection,
    token: &str,
) -> Vec<(String, String, i64, i64, String, String)> {
    let mut stmt = db
        .prepare(
            "SELECT id, title, width, height, status, updated_at FROM user_puzzles
         WHERE session_token = ?1 ORDER BY updated_at DESC LIMIT 50",
        )
        .unwrap();
    stmt.query_map([token], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn delete_user_puzzle(db: &Connection, id: &str, token: &str) {
    db.execute(
        "DELETE FROM user_puzzles WHERE id = ?1 AND session_token = ?2",
        params![id, token],
    )
    .ok();
}

pub fn publish_user_puzzle(db: &Connection, id: &str, token: &str) -> bool {
    let affected = db
        .execute(
            "UPDATE user_puzzles SET status = 'published', updated_at = ?3
         WHERE id = ?1 AND session_token = ?2 AND clues_json IS NOT NULL",
            params![id, token, chrono::Utc::now().to_rfc3339()],
        )
        .unwrap_or(0);
    affected > 0
}

pub fn list_published_puzzles(
    db: &Connection,
    limit: usize,
    offset: usize,
) -> Vec<(String, String, String, i64, i64, String, Option<String>)> {
    let mut stmt = db.prepare(
        "SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain
         FROM user_puzzles up
         LEFT JOIN sessions s ON up.session_token = s.token
         LEFT JOIN settings st ON up.session_token = st.session_token
         WHERE up.status = 'published' AND up.clues_json IS NOT NULL
         ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
    ).unwrap();
    stmt.query_map(params![limit as i64, offset as i64], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get::<_, Option<String>>(2)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
            row.get(6)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn list_federated_puzzles(
    db: &Connection,
    limit: usize,
    offset: usize,
) -> Vec<(String, String, i64, i64, String, String)> {
    let mut stmt = db
        .prepare(
            "SELECT id, title, width, height, origin_domain, updated_at
         FROM user_puzzles
         WHERE session_token = 'federation' AND status = 'published' AND clues_json IS NOT NULL
         ORDER BY updated_at DESC LIMIT ?1 OFFSET ?2",
        )
        .unwrap();
    stmt.query_map(params![limit as i64, offset as i64], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get::<_, String>(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn get_published_puzzle(
    db: &Connection,
    id: &str,
) -> Option<(String, i64, i64, String, String)> {
    db.query_row(
        "SELECT title, width, height, grid_json, clues_json
         FROM user_puzzles WHERE id = ?1 AND status = 'published' AND clues_json IS NOT NULL",
        [id],
        |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
            ))
        },
    )
    .ok()
}

// webhooks
pub fn create_webhook(
    db: &Connection,
    id: &str,
    url: &str,
    events: &str,
    secret: Option<&str>,
    format: &str,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO webhooks (id, url, events, secret, format, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        params![id, url, events, secret, format, now],
    ).unwrap();
}

pub fn list_webhooks(
    db: &Connection,
) -> Vec<(String, String, String, Option<String>, String, bool, i64)> {
    let mut stmt = db.prepare(
        "SELECT id, url, events, secret, format, enabled, failure_count FROM webhooks ORDER BY created_at DESC"
    ).unwrap();
    stmt.query_map([], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get::<_, i64>(5)? != 0,
            row.get(6)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn delete_webhook(db: &Connection, id: &str) {
    db.execute("DELETE FROM webhooks WHERE id = ?1", [id]).ok();
}

pub fn get_enabled_webhooks(
    db: &Connection,
    event: &str,
) -> Vec<(String, String, Option<String>, String)> {
    let mut stmt = db.prepare(
        "SELECT id, url, secret, format FROM webhooks WHERE enabled = 1 AND events LIKE '%' || ?1 || '%'"
    ).unwrap();
    stmt.query_map([event], |row| {
        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn record_webhook_failure(db: &Connection, id: &str) {
    db.execute(
        "UPDATE webhooks SET failure_count = failure_count + 1 WHERE id = ?1",
        [id],
    )
    .ok();
}

// federation stats
pub fn track_federation_activity(db: &Connection, domain: &str, direction: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO ap_stats (domain, direction, count, last_activity) VALUES (?1, ?2, 1, ?3)
         ON CONFLICT(domain, direction) DO UPDATE SET count = count + 1, last_activity = ?3",
        params![domain, direction, now],
    )
    .ok();
}

// server config (DB overrides env vars)
pub fn get_server_config(db: &Connection, key: &str) -> Option<String> {
    db.query_row(
        "SELECT value FROM server_config WHERE key = ?1",
        [key],
        |row| row.get(0),
    )
    .ok()
}

pub fn set_server_config(db: &Connection, key: &str, value: &str) {
    db.execute(
        "INSERT INTO server_config (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2",
        params![key, value],
    ).ok();
}

pub fn get_config_or_env(db: &Connection, key: &str, default: &str) -> String {
    get_server_config(db, key)
        .or_else(|| std::env::var(key).ok())
        .unwrap_or_else(|| default.into())
}

pub fn get_config_bool(db: &Connection, key: &str) -> bool {
    get_config_or_env(db, key, "false") == "true"
}

pub fn get_all_config(db: &Connection) -> serde_json::Value {
    serde_json::json!({
        "CORS_ORIGIN": get_config_or_env(db, "CORS_ORIGIN", ""),
        "SECURE_COOKIES": get_config_bool(db, "SECURE_COOKIES"),
        "HSTS": get_config_bool(db, "HSTS"),
        "PUBLIC_MOD_LOG": get_config_bool(db, "PUBLIC_MOD_LOG"),
        "INSTANCE_CONTACT": get_config_or_env(db, "INSTANCE_CONTACT", ""),
        "INSTANCE_RULES": get_config_or_env(db, "INSTANCE_RULES", "Be respectful. Submit quality clues. No spam."),
        "AP_DOMAIN": std::env::var("AP_DOMAIN").unwrap_or_default(),
        "AP_ACTOR_NAME": get_config_or_env(db, "AP_ACTOR_NAME", "cruciverb"),
        "ap_actor_name": get_config_or_env(db, "ap_actor_name", "crossword"),
        "ap_bio": get_config_or_env(db, "ap_bio", ""),
        "ap_avatar_url": get_config_or_env(db, "ap_avatar_url", ""),
        "ap_banner_url": get_config_or_env(db, "ap_banner_url", ""),
        "instance_rules": get_config_or_env(db, "instance_rules", "Be respectful. Submit quality clues. No spam."),
        "AP_ALLOWLIST_MODE": get_config_bool(db, "AP_ALLOWLIST_MODE"),
        "AP_APPROVE_FOLLOWERS": get_config_bool(db, "AP_APPROVE_FOLLOWERS"),
        "AP_NEW_PEER_REVIEW": get_config_bool(db, "AP_NEW_PEER_REVIEW"),
        "AP_OUTBOX_LIMIT": get_config_or_env(db, "AP_OUTBOX_LIMIT", "20").parse::<i64>().unwrap_or(20),
        "AP_SHARED_BLOCKLIST": get_config_bool(db, "AP_SHARED_BLOCKLIST"),
        "ADMIN_PATH": get_server_config(db, "admin_path")
            .unwrap_or_else(|| std::env::var("ADMIN_PATH").unwrap_or_else(|_| "/admin".into())),
        "ap_daily_taglines": get_server_config(db, "ap_daily_taglines")
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .unwrap_or(serde_json::json!([])),
        "ap_daily_signoffs": get_server_config(db, "ap_daily_signoffs")
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .unwrap_or(serde_json::json!([])),
        "ap_community_middles": get_server_config(db, "ap_community_middles")
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .unwrap_or(serde_json::json!([])),
    })
}

// settings
pub fn get_settings(db: &Connection, token: &str) -> String {
    db.query_row(
        "SELECT settings_json FROM settings WHERE session_token = ?1",
        [token],
        |row| row.get::<_, String>(0),
    )
    .unwrap_or_else(|_| "{}".into())
}

pub fn save_settings(db: &Connection, token: &str, settings_json: &str) {
    db.execute(
        "INSERT INTO settings (session_token, settings_json) VALUES (?1, ?2)
         ON CONFLICT(session_token) DO UPDATE SET settings_json = ?2",
        params![token, settings_json],
    )
    .unwrap();
}

// passkeys
pub fn has_passkey(db: &Connection, token: &str) -> bool {
    db.query_row(
        "SELECT COUNT(*) FROM passkeys WHERE session_token = ?1",
        [token],
        |row| row.get::<_, i64>(0),
    )
    .unwrap_or(0)
        > 0
}

pub fn get_passkeys(db: &Connection, token: &str) -> Vec<(String, String, String)> {
    let mut stmt = db.prepare(
        "SELECT credential_id, name, created_at FROM passkeys WHERE session_token = ?1 ORDER BY created_at DESC"
    ).unwrap();
    stmt.query_map([token], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, String>(1)?,
            row.get::<_, String>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn save_passkey(
    db: &Connection,
    credential_id: &str,
    token: &str,
    passkey_json: &str,
    name: &str,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO passkeys (credential_id, session_token, passkey_json, name, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![credential_id, token, passkey_json, name, now],
    )
    .unwrap();
}

pub fn update_passkey_json(db: &Connection, credential_id: &str, passkey_json: &str) {
    db.execute(
        "UPDATE passkeys SET passkey_json = ?1 WHERE credential_id = ?2",
        params![passkey_json, credential_id],
    )
    .ok();
}

pub fn get_passkey_json(db: &Connection, credential_id: &str) -> Option<(String, String)> {
    db.query_row(
        "SELECT passkey_json, session_token FROM passkeys WHERE credential_id = ?1",
        [credential_id],
        |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
    )
    .ok()
}

// achievements
pub fn get_achievements(db: &Connection, token: &str) -> Vec<(String, String)> {
    let mut stmt = db.prepare(
        "SELECT achievement_id, unlocked_at FROM achievements WHERE session_token = ?1 ORDER BY unlocked_at DESC"
    ).unwrap();
    stmt.query_map([token], |row| {
        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn unlock_achievement(db: &Connection, token: &str, achievement_id: &str) -> bool {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR IGNORE INTO achievements (session_token, achievement_id, unlocked_at) VALUES (?1, ?2, ?3)",
        params![token, achievement_id, now],
    ).unwrap() > 0 // returns true if newly inserted
}

pub fn get_solve_count(db: &Connection, token: &str) -> i64 {
    db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1",
        [token],
        |row| row.get(0),
    )
    .unwrap_or(0)
}

pub fn get_solve_count_by_preset(db: &Connection, token: &str, preset: &str) -> i64 {
    db.query_row(
        "SELECT COUNT(*) FROM solve_records sr JOIN puzzles p ON sr.puzzle_id = p.id WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND p.preset = ?2",
        params![token, preset], |row| row.get(0),
    ).unwrap_or(0)
}

pub fn get_no_hint_count(db: &Connection, token: &str) -> i64 {
    db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND hints_used = 0",
        [token], |row| row.get(0),
    ).unwrap_or(0)
}

pub fn get_fastest_solve(db: &Connection, token: &str, preset: &str) -> Option<i64> {
    db.query_row(
        "SELECT MIN(sr.solve_time_secs) FROM solve_records sr JOIN puzzles p ON sr.puzzle_id = p.id WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND p.preset = ?2",
        params![token, preset], |row| row.get::<_, Option<i64>>(0),
    ).ok().flatten()
}

// content reports
pub fn submit_report(
    db: &Connection,
    token: &str,
    content_type: &str,
    content_id: &str,
    reason: Option<&str>,
) -> i64 {
    let existing: bool = db.query_row(
        "SELECT COUNT(*) > 0 FROM content_reports WHERE reporter_token = ?1 AND content_type = ?2 AND content_id = ?3",
        params![token, content_type, content_id], |r| r.get(0),
    ).unwrap_or(false);
    if existing {
        return 0;
    }
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO content_reports (reporter_token, content_type, content_id, reason, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        params![token, content_type, content_id, reason, now],
    )
    .unwrap();
    db.last_insert_rowid()
}

pub fn get_pending_reports(
    db: &Connection,
) -> Vec<(i64, String, String, String, Option<String>, String)> {
    let mut stmt = db
        .prepare(
            "SELECT id, reporter_token, content_type, content_id, reason, created_at
         FROM content_reports WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100",
        )
        .unwrap();
    stmt.query_map([], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn resolve_report(db: &Connection, id: i64, status: &str) {
    db.execute(
        "UPDATE content_reports SET status = ?1 WHERE id = ?2",
        params![status, id],
    )
    .ok();
}

// instance reputation
pub fn update_instance_reputation(db: &Connection, domain: &str, delta: f64) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO ap_instance_reputation (domain, score, last_updated) VALUES (?1, ?2, ?3)
         ON CONFLICT(domain) DO UPDATE SET score = MAX(0, MIN(100, score + ?4)), last_updated = ?3",
        params![domain, 100.0 + delta, now, delta],
    )
    .ok();
    // track totals
    if delta < 0.0 {
        db.execute(
            "UPDATE ap_instance_reputation SET rejected_clues = rejected_clues + 1 WHERE domain = ?1",
            [domain],
        ).ok();
    } else {
        db.execute(
            "UPDATE ap_instance_reputation SET total_clues = total_clues + 1 WHERE domain = ?1",
            [domain],
        )
        .ok();
    }
}

pub fn increment_instance_reports(db: &Connection, domain: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO ap_instance_reputation (domain, score, reports_against, last_updated)
         VALUES (?1, 90.0, 1, ?2)
         ON CONFLICT(domain) DO UPDATE SET
           score = MAX(0, score - 10), reports_against = reports_against + 1, last_updated = ?2",
        params![domain, now],
    )
    .ok();
}

pub fn get_instance_reputation(db: &Connection, domain: &str) -> f64 {
    db.query_row(
        "SELECT score FROM ap_instance_reputation WHERE domain = ?1",
        [domain],
        |row| row.get(0),
    )
    .unwrap_or(100.0)
}

pub fn get_all_instance_reputations(db: &Connection) -> Vec<(String, f64, i64, i64, i64, String)> {
    let mut stmt = db
        .prepare(
            "SELECT domain, score, total_clues, rejected_clues, reports_against, last_updated
         FROM ap_instance_reputation ORDER BY score ASC",
        )
        .unwrap();
    stmt.query_map([], |row| {
        Ok((
            row.get(0)?,
            row.get(1)?,
            row.get(2)?,
            row.get(3)?,
            row.get(4)?,
            row.get(5)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// import user data from export
pub fn import_user_data(
    db: &Connection,
    token: &str,
    data: &serde_json::Value,
) -> (usize, Vec<String>) {
    let mut imported = 0;
    let mut skipped = Vec::new();

    // display name
    if let Some(name) = data
        .get("session")
        .and_then(|s| s.get("display_name"))
        .and_then(|v| v.as_str())
    {
        set_display_name(db, token, name);
        imported += 1;
    }

    // settings
    if let Some(settings) = data.get("settings") {
        save_settings(db, token, &settings.to_string());
        imported += 1;
    }

    // streak (only if better than current)
    if let Some(streak) = data.get("streak") {
        let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0);
        let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0);
        let imp_last = streak
            .get("last_date")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        let (cur_current, cur_best, _) = get_streak(db, token);
        if imp_best > cur_best as i64 || imp_current > cur_current as i64 {
            db.execute(
                "INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)",
                params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last],
            ).ok();
            imported += 1;
        }
    }

    // achievements (merge - only add ones not already unlocked)
    if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) {
        for ach in achievements {
            let id = ach.get("id").and_then(|v| v.as_str()).unwrap_or("");
            let unlocked_at = ach
                .get("unlocked_at")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            if id.is_empty() {
                continue;
            }
            db.execute(
                "INSERT OR IGNORE INTO achievements (session_token, achievement_id, unlocked_at) VALUES (?1, ?2, ?3)",
                params![token, id, unlocked_at],
            ).ok();
            imported += 1;
        }
    }

    // community clue submissions (re-associate with new token)
    if let Some(subs) = data.get("submissions").and_then(|v| v.as_array()) {
        for sub in subs {
            let word = sub.get("word").and_then(|v| v.as_str()).unwrap_or("");
            let clue = sub.get("clue").and_then(|v| v.as_str()).unwrap_or("");
            if word.is_empty() || clue.is_empty() {
                continue;
            }
            // check for URL in clue text
            if text_contains_url(clue) {
                skipped.push(format!("clue for {}: contains URL", word));
                continue;
            }
            // check duplicate
            let exists: bool = db
                .query_row(
                    "SELECT COUNT(*) > 0 FROM community_clues WHERE word = ?1 AND clue_text = ?2",
                    params![word, clue],
                    |r| r.get(0),
                )
                .unwrap_or(false);
            if exists {
                skipped.push(format!("clue for {}: already exists", word));
                continue;
            }
            let now = chrono::Utc::now().to_rfc3339();
            db.execute(
                "INSERT INTO community_clues (word, clue_text, submitted_by, status, created_at) VALUES (?1, ?2, ?3, 'pending', ?4)",
                params![word, clue, token, now],
            ).ok();
            imported += 1;
        }
    }

    // solve records are skipped - puzzle IDs may not exist on this instance
    if data
        .get("solves")
        .and_then(|v| v.as_array())
        .map(|a| a.len())
        .unwrap_or(0)
        > 0
    {
        skipped.push("solve records: puzzle IDs are instance-specific".into());
    }
    // ratings skipped for same reason
    if data
        .get("ratings")
        .and_then(|v| v.as_array())
        .map(|a| a.len())
        .unwrap_or(0)
        > 0
    {
        skipped.push("puzzle ratings: puzzle IDs are instance-specific".into());
    }
    // user puzzles - import if they have full grid data
    if let Some(puzzles) = data.get("puzzles").and_then(|v| v.as_array()) {
        for puzzle in puzzles {
            let title = puzzle
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("Imported");
            let w = puzzle.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
            let h = puzzle.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
            let grid = puzzle.get("grid");
            if w == 0 || h == 0 || grid.is_none() {
                skipped.push(format!("puzzle '{}': missing grid data", title));
                continue;
            }
            let grid_json = serde_json::to_string(grid.unwrap()).unwrap_or_default();
            let clues_json = puzzle
                .get("clues")
                .map(|c| serde_json::to_string(c).unwrap_or_default());
            let id = uuid::Uuid::new_v4().to_string();
            save_user_puzzle(
                db,
                &id,
                token,
                title,
                w,
                h,
                &grid_json,
                clues_json.as_deref(),
                None,
                "draft",
            );
            imported += 1;
        }
    }

    (imported, skipped)
}

// auto-purge old records
pub fn auto_purge(db: &Connection, token: &str, days: i64) {
    let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
    let cutoff_str = cutoff.to_rfc3339();
    db.execute(
        "DELETE FROM solve_records WHERE session_token = ?1 AND completed_at < ?2",
        params![token, cutoff_str],
    )
    .ok();
}

// delete all user data
pub fn delete_account(db: &Connection, token: &str) {
    db.execute(
        "DELETE FROM solve_records WHERE session_token = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM streaks WHERE session_token = ?1", [token])
        .ok();
    db.execute("DELETE FROM achievements WHERE session_token = ?1", [token])
        .ok();
    db.execute("DELETE FROM settings WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM community_clues WHERE submitted_by = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM clue_votes WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM puzzle_ratings WHERE session_token = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM user_puzzles WHERE session_token = ?1", [token])
        .ok();
    db.execute("DELETE FROM passkeys WHERE session_token = ?1", [token])
        .ok();
    db.execute(
        "DELETE FROM content_reports WHERE reporter_token = ?1",
        [token],
    )
    .ok();
    db.execute("DELETE FROM sessions WHERE token = ?1", [token])
        .ok();
}

// export all user data
pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value {
    let session: serde_json::Value = db
        .query_row(
            "SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1",
            [token],
            |row| {
                Ok(serde_json::json!({
                    "display_name": row.get::<_, Option<String>>(0)?,
                    "created_at": row.get::<_, String>(1)?,
                    "last_seen": row.get::<_, String>(2)?,
                }))
            },
        )
        .unwrap_or(serde_json::json!(null));

    let settings = get_settings(db, token);
    let solves = get_stats(db, token);
    let (current, best, last) = get_streak(db, token);
    let achievements = get_achievements(db, token);
    let submissions = get_my_submissions(db, token);

    let mut ratings = Vec::new();
    if let Ok(mut stmt) = db.prepare(
        "SELECT puzzle_id, rating, created_at FROM puzzle_ratings WHERE session_token = ?1",
    ) {
        if let Ok(rows) = stmt.query_map([token], |row| {
            Ok(serde_json::json!({
                "puzzle_id": row.get::<_, String>(0)?,
                "rating": row.get::<_, i32>(1)?,
                "created_at": row.get::<_, String>(2)?,
            }))
        }) {
            for r in rows.flatten() {
                ratings.push(r);
            }
        }
    }

    let full_puzzles = export_user_puzzles(db, token);

    serde_json::json!({
        "session": session,
        "settings": serde_json::from_str::<serde_json::Value>(&settings).unwrap_or_default(),
        "solves": solves.iter().map(|(pid, preset, time, hints)| {
            serde_json::json!({"puzzle_id": pid, "preset": preset, "time": time, "hints": hints})
        }).collect::<Vec<_>>(),
        "streak": {"current": current, "best": best, "last_date": last},
        "achievements": achievements.iter().map(|(id, t)| {
            serde_json::json!({"id": id, "unlocked_at": t})
        }).collect::<Vec<_>>(),
        "submissions": submissions.iter().map(|(id, word, clue, up, down, status)| {
            serde_json::json!({"id": id, "word": word, "clue": clue, "upvotes": up, "downvotes": down, "status": status})
        }).collect::<Vec<_>>(),
        "ratings": ratings,
        "puzzles": full_puzzles,
    })
}

fn export_user_puzzles(db: &Connection, token: &str) -> Vec<serde_json::Value> {
    let mut result = Vec::new();
    if let Ok(mut stmt) = db.prepare(
        "SELECT id, title, width, height, grid_json, clues_json, status, updated_at
         FROM user_puzzles WHERE session_token = ?1 ORDER BY updated_at DESC",
    ) {
        if let Ok(rows) = stmt.query_map([token], |row| {
            Ok(serde_json::json!({
                "id": row.get::<_, String>(0)?,
                "title": row.get::<_, String>(1)?,
                "width": row.get::<_, i64>(2)?,
                "height": row.get::<_, i64>(3)?,
                "grid": serde_json::from_str::<serde_json::Value>(&row.get::<_, String>(4)?).unwrap_or_default(),
                "clues": row.get::<_, Option<String>>(5)?.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()),
                "status": row.get::<_, String>(6)?,
                "updated_at": row.get::<_, String>(7)?,
            }))
        }) {
            for r in rows.flatten() { result.push(r); }
        }
    }
    result
}

// privacy-aware leaderboards - exclude users with hideFromLeaderboards
pub fn get_leaderboard_filtered(
    db: &Connection,
    puzzle_id: &str,
    limit: usize,
) -> Vec<(String, i64, i64)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY sr.solve_time_secs ASC
         LIMIT ?2"
    ).unwrap();

    stmt.query_map(params![puzzle_id, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

pub fn get_daily_leaderboard_filtered(
    db: &Connection,
    date: &str,
    preset: &str,
    limit: usize,
) -> Vec<(String, i64, i64)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY sr.solve_time_secs ASC
         LIMIT ?3"
    ).unwrap();

    stmt.query_map(params![date, preset, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// count recent federated clues from a domain (last hour)
pub fn count_recent_clues_from_domain(db: &Connection, domain: &str) -> i64 {
    let cutoff = (chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
    db.query_row(
        "SELECT COUNT(*) FROM community_clues WHERE origin_domain = ?1 AND created_at > ?2",
        params![domain, cutoff],
        |r| r.get(0),
    )
    .unwrap_or(0)
}

// count consecutive rejected clues from a domain
pub fn count_consecutive_rejections(db: &Connection, domain: &str) -> i64 {
    let mut stmt = db
        .prepare(
            "SELECT status FROM community_clues WHERE origin_domain = ?1 ORDER BY id DESC LIMIT 10",
        )
        .unwrap();
    let statuses: Vec<String> = stmt
        .query_map([domain], |row| row.get(0))
        .unwrap()
        .filter_map(|r| r.ok())
        .collect();
    let mut count = 0i64;
    for s in &statuses {
        if s == "rejected" {
            count += 1;
        } else {
            break;
        }
    }
    count
}

// puzzle content hash
pub fn store_puzzle_hash(db: &Connection, id: &str, hash: &str) {
    db.execute(
        "UPDATE puzzles SET content_hash = ?1 WHERE id = ?2",
        params![hash, id],
    )
    .ok();
}

// check for URLs in text
pub fn text_contains_url(text: &str) -> bool {
    text.contains("http://") || text.contains("https://") || text.contains("www.")
}

// #10: ephemeral session cleanup - delete data for sessions with ephemeral=true that expired
pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 {
    // find sessions that have ephemeral=true and haven't been seen in longer than the cookie lifetime
    let mut cleaned = 0i64;
    let now = chrono::Utc::now();

    // get all sessions that have ephemeralSession in settings
    let sessions: Vec<(String, String, String)> = {
        let mut stmt = db
            .prepare(
                "SELECT s.token, st.settings_json, s.last_seen FROM sessions s
             JOIN settings st ON st.session_token = s.token",
            )
            .unwrap();
        stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
            .unwrap()
            .filter_map(|r| r.ok())
            .collect()
    };

    for (token, settings_json, last_seen) in sessions {
        let settings: serde_json::Value = match serde_json::from_str(&settings_json) {
            Ok(v) => v,
            Err(_) => continue,
        };
        let ephemeral = settings
            .get("ephemeralSession")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        if !ephemeral {
            continue;
        }

        let cookie_lifetime = settings
            .get("cookieLifetime")
            .and_then(|v| v.as_i64())
            .unwrap_or(2_592_000); // default 30 days

        if let Ok(seen) = chrono::DateTime::parse_from_rfc3339(&last_seen) {
            let elapsed = now.signed_duration_since(seen).num_seconds();
            if elapsed > cookie_lifetime {
                delete_account(db, &token);
                cleaned += 1;
            }
        }
    }

    if cleaned > 0 {
        tracing::info!("cleaned up {} ephemeral sessions", cleaned);
    }
    cleaned
}

// federated leaderboard
pub fn insert_federated_leaderboard(
    db: &Connection,
    origin: &str,
    date: &str,
    preset: &str,
    name: &str,
    time: i64,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO federated_leaderboard (origin_domain, puzzle_date, preset, player_name, solve_time, received_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
        params![origin, date, preset, name, time, now],
    ).ok();
}

pub fn get_federated_leaderboard(
    db: &Connection,
    date: &str,
    preset: &str,
    limit: usize,
) -> Vec<(String, String, i64)> {
    let mut stmt = db
        .prepare(
            "SELECT player_name, origin_domain, solve_time FROM federated_leaderboard
         WHERE puzzle_date = ?1 AND preset = ?2 ORDER BY solve_time ASC LIMIT ?3",
        )
        .unwrap();
    stmt.query_map(params![date, preset, limit as i64], |row| {
        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// insert federated puzzle
pub fn insert_federated_puzzle(
    db: &Connection,
    id: &str,
    title: &str,
    w: usize,
    h: usize,
    grid_json: &str,
    clues_json: &str,
    puzzle_json: &str,
    origin: &str,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR IGNORE INTO user_puzzles (id, session_token, title, width, height, grid_json, clues_json, puzzle_json, status, created_at, updated_at, origin_domain)
         VALUES (?1, 'federation', ?2, ?3, ?4, ?5, ?6, ?7, 'published', ?8, ?8, ?9)",
        params![id, title, w as i64, h as i64, grid_json, clues_json, puzzle_json, now, origin],
    ).ok();
}

// boosts
pub fn add_ap_boost(db: &Connection, outbox_id: &str, actor_uri: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO ap_boosts (outbox_id, actor_uri, received_at) VALUES (?1, ?2, ?3)",
        params![outbox_id, actor_uri, now],
    )
    .ok();
}

// replies
pub fn add_ap_reply(
    db: &Connection,
    outbox_id: &str,
    puzzle_id: Option<&str>,
    actor_uri: &str,
    actor_name: Option<&str>,
    content: &str,
    published: Option<&str>,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO ap_replies (outbox_id, puzzle_id, actor_uri, actor_name, content, published, received_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        params![outbox_id, puzzle_id, actor_uri, actor_name, content, published, now],
    ).ok();
}

pub fn get_ap_replies(
    db: &Connection,
    puzzle_id: &str,
    limit: usize,
) -> Vec<(String, Option<String>, String, String)> {
    let mut stmt = db.prepare(
        "SELECT actor_uri, actor_name, content, COALESCE(published, received_at) FROM ap_replies WHERE puzzle_id = ?1 ORDER BY published DESC LIMIT ?2"
    ).unwrap();
    stmt.query_map(params![puzzle_id, limit as i64], |row| {
        Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// update peer domain (for Move activities)
pub fn update_peer_domain(
    db: &Connection,
    old_domain: &str,
    new_domain: &str,
    new_actor: &str,
    new_inbox: &str,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute("DELETE FROM ap_peers WHERE domain = ?1", [old_domain])
        .ok();
    db.execute(
        "INSERT OR REPLACE INTO ap_peers (domain, actor_uri, inbox_uri, last_seen, created_at)
         VALUES (?1, ?2, ?3, ?4, ?4)",
        params![new_domain, new_actor, new_inbox, now],
    )
    .ok();
}

// wordlist packs

pub fn create_pack(
    db: &Connection,
    id: &str,
    name: &str,
    desc: Option<&str>,
    author_session: &str,
    author_name: Option<&str>,
    tags: Option<&str>,
    word_count: i64,
    pack_data: &str,
) -> bool {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO wordlist_packs (id, name, description, author_session, author_name, tags, word_count, pack_data, created_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
        params![id, name, desc, author_session, author_name, tags, word_count, pack_data, now],
    ).is_ok()
}

pub struct PackInfo {
    pub id: String,
    pub name: String,
    pub description: Option<String>,
    pub author_name: Option<String>,
    pub tags: Option<String>,
    pub word_count: i64,
    pub download_count: i64,
    pub rating_avg: Option<f64>,
    pub rating_count: i64,
    pub pack_data: String,
    pub created_at: String,
}

pub fn get_pack(db: &Connection, id: &str) -> Option<PackInfo> {
    db.query_row(
        "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, pack_data, created_at
         FROM wordlist_packs WHERE id = ?1 AND status = 'published'",
        [id],
        |row| Ok(PackInfo {
            id: row.get(0)?,
            name: row.get(1)?,
            description: row.get(2)?,
            author_name: row.get(3)?,
            tags: row.get(4)?,
            word_count: row.get(5)?,
            download_count: row.get(6)?,
            rating_avg: row.get(7)?,
            rating_count: row.get(8)?,
            pack_data: row.get(9)?,
            created_at: row.get(10)?,
        }),
    ).ok()
}

pub fn list_packs(
    db: &Connection,
    search: Option<&str>,
    limit: usize,
) -> Vec<(
    String,
    String,
    Option<String>,
    Option<String>,
    Option<String>,
    i64,
    i64,
    Option<f64>,
    i64,
    String,
)> {
    if let Some(q) = search {
        let pattern = format!("%{}%", q);
        let mut stmt = db.prepare(
            "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, created_at
             FROM wordlist_packs WHERE status = 'published' AND (name LIKE ?1 OR description LIKE ?1 OR tags LIKE ?1)
             ORDER BY download_count DESC LIMIT ?2"
        ).unwrap();
        stmt.query_map(params![pattern, limit as i64], |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
                row.get(5)?,
                row.get(6)?,
                row.get(7)?,
                row.get(8)?,
                row.get(9)?,
            ))
        })
        .unwrap()
        .filter_map(|r| r.ok())
        .collect()
    } else {
        let mut stmt = db.prepare(
            "SELECT id, name, description, author_name, tags, word_count, download_count, rating_avg, rating_count, created_at
             FROM wordlist_packs WHERE status = 'published'
             ORDER BY download_count DESC LIMIT ?1"
        ).unwrap();
        stmt.query_map([limit as i64], |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
                row.get(5)?,
                row.get(6)?,
                row.get(7)?,
                row.get(8)?,
                row.get(9)?,
            ))
        })
        .unwrap()
        .filter_map(|r| r.ok())
        .collect()
    }
}

pub fn delete_pack(db: &Connection, id: &str, author_session: &str) -> bool {
    let rows = db
        .execute(
            "DELETE FROM wordlist_packs WHERE id = ?1 AND author_session = ?2",
            params![id, author_session],
        )
        .unwrap_or(0);
    rows > 0
}

pub fn rate_pack(db: &Connection, pack_id: &str, session_token: &str, rating: i64) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT INTO pack_ratings (pack_id, session_token, rating, created_at)
         VALUES (?1, ?2, ?3, ?4)
         ON CONFLICT(pack_id, session_token) DO UPDATE SET rating = ?3, created_at = ?4",
        params![pack_id, session_token, rating, now],
    )
    .ok();
    // update aggregate
    db.execute(
        "UPDATE wordlist_packs SET
            rating_avg = (SELECT AVG(CAST(rating AS REAL)) FROM pack_ratings WHERE pack_id = ?1),
            rating_count = (SELECT COUNT(*) FROM pack_ratings WHERE pack_id = ?1)
         WHERE id = ?1",
        [pack_id],
    )
    .ok();
}

pub fn increment_pack_downloads(db: &Connection, id: &str) {
    db.execute(
        "UPDATE wordlist_packs SET download_count = download_count + 1 WHERE id = ?1",
        [id],
    )
    .ok();
}

// all-time leaderboard - fastest solve ever per player for a preset
pub fn get_alltime_leaderboard(
    db: &Connection,
    preset: &str,
    limit: usize,
) -> Vec<(String, i64, i64, String)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used,
                p.daily_date
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         GROUP BY sr.session_token
         ORDER BY best_time ASC
         LIMIT ?2"
    ).unwrap();

    stmt.query_map(params![preset, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
            row.get::<_, Option<String>>(3)?.unwrap_or_default(),
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// weekly leaderboard - best time per player in the last 7 days
pub fn get_weekly_leaderboard(
    db: &Connection,
    preset: &str,
    limit: usize,
) -> Vec<(String, i64, i64)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
           AND p.daily_date >= date('now', '-7 days')
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         GROUP BY sr.session_token
         ORDER BY best_time ASC
         LIMIT ?2"
    ).unwrap();

    stmt.query_map(params![preset, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// streak leaderboard - top current streaks
pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64, i64)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak
         FROM streaks st2
         LEFT JOIN sessions s ON st2.session_token = s.token
         LEFT JOIN settings st ON st2.session_token = st.session_token
         WHERE st2.current_streak > 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY st2.current_streak DESC, st2.best_streak DESC
         LIMIT ?1"
    ).unwrap();

    stmt.query_map(params![limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// completionist leaderboard - most total solves
pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64)> {
    let mut stmt = db.prepare(
        "SELECT 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(*) as total
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE sr.is_complete = 1 AND sr.flagged = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         GROUP BY sr.session_token
         ORDER BY total DESC
         LIMIT ?1"
    ).unwrap();

    stmt.query_map(params![limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// clean solve leaderboard - zero hints only
pub fn get_clean_leaderboard(
    db: &Connection,
    date: &str,
    preset: &str,
    limit: usize,
) -> Vec<(String, i64)> {
    let mut stmt = db.prepare(
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.daily_date = ?1 AND p.preset = ?2
           AND sr.is_complete = 1 AND sr.flagged = 0 AND sr.hints_used = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY sr.solve_time_secs ASC
         LIMIT ?3"
    ).unwrap();

    stmt.query_map(params![date, preset, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// personal stats - detailed breakdown
pub fn get_personal_stats(db: &Connection, token: &str) -> serde_json::Value {
    let total: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1",
            [token],
            |r| r.get(0),
        )
        .unwrap_or(0);

    let clean: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND hints_used = 0",
        [token], |r| r.get(0),
    ).unwrap_or(0);

    let avg_hints: f64 = db.query_row(
        "SELECT COALESCE(AVG(hints_used), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1",
        [token], |r| r.get(0),
    ).unwrap_or(0.0);

    // by preset counts
    let mut by_preset = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT COALESCE(p.preset, 'custom'), COUNT(*)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1
             GROUP BY p.preset",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            by_preset.insert(r.0, serde_json::json!(r.1));
        }
    }

    // best times per preset
    let mut best_times = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT COALESCE(p.preset, 'custom'), MIN(sr.solve_time_secs)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1
             GROUP BY p.preset",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            best_times.insert(r.0, serde_json::json!(r.1));
        }
    }

    // avg time per preset
    let mut avg_times = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT COALESCE(p.preset, 'custom'), AVG(sr.solve_time_secs)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1
             GROUP BY p.preset",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            avg_times.insert(r.0, serde_json::json!(r.1.round() as i64));
        }
    }

    let (current_streak, best_streak, _) = get_streak(db, token);

    serde_json::json!({
        "total_solves": total,
        "clean_solves": clean,
        "avg_hints": (avg_hints * 100.0).round() / 100.0,
        "by_preset": by_preset,
        "best_times": best_times,
        "avg_time_by_preset": avg_times,
        "current_streak": current_streak,
        "best_streak": best_streak,
    })
}

// analytics - rich personal stats endpoint
pub fn get_analytics_data(db: &Connection, token: &str) -> serde_json::Value {
    // summary stats
    let total_solves: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
        [token], |r| r.get(0),
    ).unwrap_or(0);

    let clean_solves: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0 AND hints_used = 0",
        [token], |r| r.get(0),
    ).unwrap_or(0);

    let total_time: i64 = db.query_row(
        "SELECT COALESCE(SUM(solve_time_secs), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
        [token], |r| r.get(0),
    ).unwrap_or(0);

    let avg_time: i64 = if total_solves > 0 {
        total_time / total_solves
    } else {
        0
    };

    let avg_hints: f64 = db.query_row(
        "SELECT COALESCE(AVG(hints_used), 0) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
        [token], |r| r.get(0),
    ).unwrap_or(0.0);

    let (current_streak, best_streak, _) = get_streak(db, token);

    let first_solve: Option<String> = db.query_row(
        "SELECT MIN(completed_at) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
        [token], |r| r.get(0),
    ).ok().flatten().map(|s: String| s.chars().take(10).collect());

    let puzzles_rated: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM puzzle_ratings WHERE session_token = ?1",
            [token],
            |r| r.get(0),
        )
        .unwrap_or(0);

    let clues_submitted: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM community_clues WHERE submitted_by = ?1",
            [token],
            |r| r.get(0),
        )
        .unwrap_or(0);

    // by preset - count, best, avg, clean per preset
    let mut by_preset = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT COALESCE(p.preset, 'custom'),
                    COUNT(*), MIN(sr.solve_time_secs),
                    CAST(AVG(sr.solve_time_secs) AS INTEGER),
                    SUM(CASE WHEN sr.hints_used = 0 THEN 1 ELSE 0 END)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
             GROUP BY COALESCE(p.preset, 'custom')",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, i64>(1)?,
                    row.get::<_, i64>(2)?,
                    row.get::<_, i64>(3)?,
                    row.get::<_, i64>(4)?,
                ))
            })
            .unwrap();
        for r in rows.flatten() {
            by_preset.insert(
                r.0,
                serde_json::json!({
                    "count": r.1, "best": r.2, "avg": r.3, "clean": r.4
                }),
            );
        }
    }

    // solve history - last 100
    let mut solve_history = Vec::new();
    {
        let mut stmt = db.prepare(
            "SELECT sr.completed_at, COALESCE(p.preset, 'custom'), sr.solve_time_secs, sr.hints_used
             FROM solve_records sr LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
             ORDER BY sr.completed_at DESC LIMIT 100"
        ).unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, i64>(2)?,
                    row.get::<_, i64>(3)?,
                ))
            })
            .unwrap();
        for r in rows.flatten() {
            let date: String = r.0.chars().take(10).collect();
            solve_history.push(serde_json::json!({
                "date": date, "preset": r.1, "time": r.2, "hints": r.3
            }));
        }
    }

    // activity calendar - solves per day for last 365 days
    let mut activity_calendar = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT DATE(completed_at) as d, COUNT(*)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-365 days')
             GROUP BY d",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            activity_calendar.insert(r.0, serde_json::json!(r.1));
        }
    }

    // by hour - 24 buckets
    let mut by_hour = vec![0i64; 24];
    {
        let mut stmt = db
            .prepare(
                "SELECT CAST(strftime('%H', completed_at) AS INTEGER) as h, COUNT(*)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             GROUP BY h",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            let idx = r.0 as usize;
            if idx < 24 {
                by_hour[idx] = r.1;
            }
        }
    }

    // by day of week - Mon(0) through Sun(6)
    // SQLite %w: 0=Sunday, 1=Monday, ..., 6=Saturday
    // remap: Sun(0)->6, Mon(1)->0, Tue(2)->1, ..., Sat(6)->5
    let mut by_dow = vec![0i64; 7];
    {
        let mut stmt = db
            .prepare(
                "SELECT CAST(strftime('%w', completed_at) AS INTEGER) as dow, COUNT(*)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             GROUP BY dow",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            let mapped = if r.0 == 0 { 6 } else { (r.0 - 1) as usize };
            if mapped < 7 {
                by_dow[mapped] = r.1;
            }
        }
    }

    // time trend - weekly avg for last 12 weeks
    let mut time_trend = Vec::new();
    {
        let mut stmt = db.prepare(
            "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start, AVG(solve_time_secs)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-84 days')
             GROUP BY week_start ORDER BY week_start"
        ).unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            time_trend.push(serde_json::json!({
                "date": r.0, "avg_time": r.1.round() as i64
            }));
        }
    }

    // hints trend - weekly avg for last 12 weeks
    let mut hints_trend = Vec::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start, AVG(hints_used)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-84 days')
             GROUP BY week_start ORDER BY week_start",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            hints_trend.push(serde_json::json!({
                "date": r.0, "avg_hints": (r.1 * 100.0).round() / 100.0
            }));
        }
    }

    // improvement rate: compare avg time last 4 weeks vs previous 4 weeks
    let improvement_rate: Option<f64> = {
        let recent: Option<f64> = db
            .query_row(
                "SELECT AVG(solve_time_secs) FROM solve_records
             WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-28 days')",
                [token],
                |r| r.get(0),
            )
            .ok()
            .flatten();
        let previous: Option<f64> = db.query_row(
            "SELECT AVG(solve_time_secs) FROM solve_records
             WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-56 days') AND completed_at < DATE('now', '-28 days')",
            [token], |r| r.get(0),
        ).ok().flatten();
        match (recent, previous) {
            (Some(r), Some(p)) if p > 0.0 => Some(((r - p) / p * 100.0 * 10.0).round() / 10.0),
            _ => None,
        }
    };

    // clean rate trend: weekly clean solve %, 12 weeks
    let mut clean_rate_trend = Vec::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT DATE(completed_at, 'weekday 0', '-6 days') as week_start,
                    SUM(CASE WHEN hints_used = 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-84 days')
             GROUP BY week_start ORDER BY week_start",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            clean_rate_trend.push(serde_json::json!({
                "date": r.0, "rate": (r.1 * 10.0).round() / 10.0
            }));
        }
    }

    // fastest by preset: personal records with dates
    let mut fastest_by_preset = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT COALESCE(p.preset, 'custom'), sr.solve_time_secs, DATE(sr.completed_at)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
             AND sr.solve_time_secs = (
                 SELECT MIN(sr2.solve_time_secs) FROM solve_records sr2
                 LEFT JOIN puzzles p2 ON sr2.puzzle_id = p2.id
                 WHERE sr2.session_token = ?1 AND sr2.is_complete = 1 AND sr2.flagged = 0
                 AND COALESCE(p2.preset, 'custom') = COALESCE(p.preset, 'custom')
             )
             GROUP BY COALESCE(p.preset, 'custom')",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, i64>(1)?,
                    row.get::<_, String>(2)?,
                ))
            })
            .unwrap();
        for r in rows.flatten() {
            fastest_by_preset.insert(
                r.0,
                serde_json::json!({
                    "time": r.1, "date": r.2
                }),
            );
        }
    }

    // solve time distribution: 10 buckets from min to max
    let solve_time_distribution: Vec<i64> = {
        let min_max: Option<(i64, i64)> = db
            .query_row(
                "SELECT MIN(solve_time_secs), MAX(solve_time_secs)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
                [token],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .ok();
        match min_max {
            Some((min_t, max_t)) if max_t > min_t => {
                let bucket_size = ((max_t - min_t) as f64 / 10.0).ceil() as i64;
                let mut buckets = vec![0i64; 10];
                let mut stmt = db
                    .prepare(
                        "SELECT solve_time_secs FROM solve_records
                     WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
                    )
                    .unwrap();
                let rows = stmt.query_map([token], |row| row.get::<_, i64>(0)).unwrap();
                for t in rows.flatten() {
                    let idx = ((t - min_t) / bucket_size).min(9) as usize;
                    buckets[idx] += 1;
                }
                buckets
            }
            _ => vec![0; 10],
        }
    };

    // monthly totals: last 12 months
    let mut monthly_totals = Vec::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT strftime('%Y-%m', completed_at) as m, COUNT(*)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             AND completed_at >= DATE('now', '-365 days')
             GROUP BY m ORDER BY m",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            monthly_totals.push(serde_json::json!({
                "month": r.0, "count": r.1
            }));
        }
    }

    // completion rate: completed / total started
    let completion_rate: f64 = {
        let completed: i64 = db.query_row(
            "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0",
            [token], |r| r.get(0),
        ).unwrap_or(0);
        let started: i64 = db
            .query_row(
                "SELECT COUNT(*) FROM solve_records WHERE session_token = ?1 AND flagged = 0",
                [token],
                |r| r.get(0),
            )
            .unwrap_or(0);
        if started > 0 {
            (completed as f64 / started as f64 * 1000.0).round() / 10.0
        } else {
            0.0
        }
    };

    // avg time by hour: avg solve time for each hour of day
    let mut avg_time_by_hour = serde_json::Map::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT CAST(strftime('%H', completed_at) AS INTEGER) as h,
                    CAST(AVG(solve_time_secs) AS INTEGER)
             FROM solve_records WHERE session_token = ?1 AND is_complete = 1 AND flagged = 0
             GROUP BY h",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            avg_time_by_hour.insert(r.0.to_string(), serde_json::json!(r.1));
        }
    }

    // difficulty progression: weekly avg difficulty level
    let mut difficulty_progression = Vec::new();
    {
        let mut stmt = db
            .prepare(
                "SELECT DATE(sr.completed_at, 'weekday 0', '-6 days') as week_start,
                    AVG(CASE COALESCE(p.preset, 'custom')
                        WHEN 'glyph' THEN 0 WHEN 'rune' THEN 1 WHEN 'scroll' THEN 2
                        WHEN 'codex' THEN 3 WHEN 'grimoire' THEN 4 ELSE 2 END)
             FROM solve_records sr
             LEFT JOIN puzzles p ON sr.puzzle_id = p.id
             WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
             AND sr.completed_at >= DATE('now', '-84 days')
             GROUP BY week_start ORDER BY week_start",
            )
            .unwrap();
        let rows = stmt
            .query_map([token], |row| {
                Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
            })
            .unwrap();
        for r in rows.flatten() {
            difficulty_progression.push(serde_json::json!({
                "date": r.0, "avg_difficulty": (r.1 * 100.0).round() / 100.0
            }));
        }
    }

    // approximate words encountered from puzzle dimensions
    let words_encountered: i64 = db
        .query_row(
            "SELECT COALESCE(SUM(p.width + p.height), 0)
         FROM solve_records sr
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0",
            [token],
            |r| r.get(0),
        )
        .unwrap_or(0);

    serde_json::json!({
        "summary": {
            "total_solves": total_solves,
            "clean_solves": clean_solves,
            "total_time_secs": total_time,
            "avg_time_secs": avg_time,
            "avg_hints": (avg_hints * 100.0).round() / 100.0,
            "current_streak": current_streak,
            "best_streak": best_streak,
            "first_solve": first_solve,
            "puzzles_rated": puzzles_rated,
            "clues_submitted": clues_submitted,
        },
        "by_preset": by_preset,
        "solve_history": solve_history,
        "activity_calendar": activity_calendar,
        "by_hour": by_hour,
        "by_day_of_week": by_dow,
        "time_trend": time_trend,
        "hints_trend": hints_trend,
        "improvement_rate": improvement_rate,
        "clean_rate_trend": clean_rate_trend,
        "fastest_by_preset": fastest_by_preset,
        "solve_time_distribution": solve_time_distribution,
        "monthly_totals": monthly_totals,
        "completion_rate": completion_rate,
        "words_encountered": words_encountered,
        "avg_time_by_hour": avg_time_by_hour,
        "difficulty_progression": difficulty_progression,
    })
}

// percentile - what fraction of solvers were slower
pub fn get_solve_percentile(db: &Connection, puzzle_id: &str, time: i64) -> f64 {
    let total: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0",
        [puzzle_id], |r| r.get(0),
    ).unwrap_or(0);
    if total == 0 {
        return 0.0;
    }

    let slower: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0 AND solve_time_secs > ?2",
        params![puzzle_id, time], |r| r.get(0),
    ).unwrap_or(0);

    (slower as f64 / total as f64 * 100.0 * 10.0).round() / 10.0
}

// total solvers for a puzzle
pub fn get_total_solvers(db: &Connection, puzzle_id: &str) -> i64 {
    db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0",
        [puzzle_id], |r| r.get(0),
    ).unwrap_or(0)
}

// solver rank for a given time
pub fn get_solve_rank(db: &Connection, puzzle_id: &str, time: i64) -> i64 {
    let faster: i64 = db.query_row(
        "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1 AND flagged = 0 AND solve_time_secs < ?2",
        params![puzzle_id, time], |r| r.get(0),
    ).unwrap_or(0);
    faster + 1
}

// daily leaderboard by specific date with optional clean-only filter
pub fn get_daily_leaderboard_by_date(
    db: &Connection,
    date: &str,
    preset: &str,
    clean_only: bool,
    limit: usize,
) -> Vec<(String, i64, i64)> {
    let sql = if clean_only {
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
           AND sr.hints_used = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY sr.solve_time_secs ASC
         LIMIT ?3"
    } else {
        "SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
         FROM solve_records sr
         LEFT JOIN sessions s ON sr.session_token = s.token
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         LEFT JOIN settings st ON sr.session_token = st.session_token
         WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
           AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
         ORDER BY sr.solve_time_secs ASC
         LIMIT ?3"
    };

    let mut stmt = db.prepare(sql).unwrap();
    stmt.query_map(params![date, preset, limit as i64], |row| {
        Ok((
            row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "Anonymous".into()),
            row.get::<_, i64>(1)?,
            row.get::<_, i64>(2)?,
        ))
    })
    .unwrap()
    .filter_map(|r| r.ok())
    .collect()
}

// solve receipts

pub fn store_receipt(
    db: &Connection,
    receipt_id: &str,
    signature: &str,
    expires_at: &str,
    puzzle_hash: &str,
    solve_time: i64,
    hints: i64,
    solve_date: &str,
    instance_url: &str,
    player_name: &str,
    room_code: Option<&str>,
    mode: Option<&str>,
    players: Option<&str>,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR REPLACE INTO solve_receipts (receipt_id, puzzle_hash, solve_time_secs, hints_used, solve_date,
         instance_url, player_name, room_code, mode, players, created_at, expires_at, signature)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
        params![receipt_id, puzzle_hash, solve_time, hints, solve_date,
                instance_url, player_name, room_code, mode, players, now, expires_at, signature],
    ).unwrap();
}

pub fn get_receipt(db: &Connection, receipt_id: &str) -> Option<String> {
    let now = chrono::Utc::now().to_rfc3339();
    db.query_row(
        "SELECT receipt_id, puzzle_hash, solve_time_secs, hints_used, solve_date,
                instance_url, player_name, room_code, mode, players, created_at, expires_at, signature
         FROM solve_receipts WHERE receipt_id = ?1 AND expires_at > ?2",
        params![receipt_id, now],
        |row| {
            let players_str: Option<String> = row.get(9)?;
            let players: Vec<String> = players_str
                .map(|s| serde_json::from_str(&s).unwrap_or_default())
                .unwrap_or_default();
            let receipt = serde_json::json!({
                "receipt": {
                    "receipt_id": row.get::<_, String>(0)?,
                    "puzzle_hash": row.get::<_, String>(1)?,
                    "solve_time_secs": row.get::<_, i64>(2)?,
                    "hints_used": row.get::<_, i64>(3)?,
                    "solve_date": row.get::<_, String>(4)?,
                    "instance_url": row.get::<_, String>(5)?,
                    "player_name": row.get::<_, String>(6)?,
                    "room_code": row.get::<_, Option<String>>(7)?,
                    "mode": row.get::<_, Option<String>>(8)?,
                    "players": players,
                    "created_at": row.get::<_, String>(10)?,
                    "expires_at": row.get::<_, String>(11)?,
                },
                "signature": row.get::<_, String>(12)?,
            });
            Ok(serde_json::to_string(&receipt).unwrap())
        },
    ).ok()
}

pub fn is_receipt_claimed(db: &Connection, receipt_id: &str) -> bool {
    db.query_row(
        "SELECT 1 FROM claimed_receipts WHERE receipt_id = ?1",
        [receipt_id],
        |_| Ok(()),
    )
    .is_ok()
}

pub fn claim_receipt(db: &Connection, receipt_id: &str, session_token: &str, origin: &str) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR IGNORE INTO claimed_receipts (receipt_id, session_token, origin_instance, claimed_at)
         VALUES (?1, ?2, ?3, ?4)",
        params![receipt_id, session_token, origin, now],
    ).unwrap();
}

pub fn cleanup_expired_receipts(db: &Connection) -> i64 {
    let now = chrono::Utc::now().to_rfc3339();
    let count = db
        .execute("DELETE FROM solve_receipts WHERE expires_at < ?1", [&now])
        .unwrap_or(0) as i64;
    if count > 0 {
        tracing::info!("cleaned up {} expired receipts", count);
    }
    count
}

pub fn get_solve_history(db: &Connection, token: &str, limit: usize) -> Vec<serde_json::Value> {
    let mut items = Vec::new();
    let mut stmt = db.prepare(
        "SELECT sr.completed_at, COALESCE(p.preset, 'custom'), sr.solve_time_secs, sr.hints_used, p.short_id
         FROM solve_records sr
         LEFT JOIN puzzles p ON sr.puzzle_id = p.id
         WHERE sr.session_token = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
         ORDER BY sr.completed_at DESC
         LIMIT ?2"
    ).unwrap();
    let rows = stmt
        .query_map(params![token, limit as i64], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, i64>(2)?,
                row.get::<_, i64>(3)?,
                row.get::<_, Option<String>>(4)?,
            ))
        })
        .unwrap();
    for r in rows.flatten() {
        let date: String = r.0.chars().take(10).collect();
        items.push(serde_json::json!({
            "date": date, "preset": r.1, "time": r.2, "hints": r.3, "short_id": r.4
        }));
    }
    items
}

// constructor analytics for a published puzzle
pub fn get_puzzle_analytics(
    db: &Connection,
    puzzle_id: &str,
    token: &str,
) -> Option<serde_json::Value> {
    // verify ownership
    let owns: bool = db.query_row(
        "SELECT COUNT(*) > 0 FROM user_puzzles WHERE id = ?1 AND session_token = ?2 AND status = 'published'",
        params![puzzle_id, token], |r| r.get(0),
    ).unwrap_or(false);
    if !owns {
        return None;
    }

    // get the puzzle_json to find the actual puzzle_id in the puzzles table
    // user_puzzles.puzzle_json contains the generated puzzle data
    let actual_puzzle_id: Option<String> = db
        .query_row(
            "SELECT puzzle_json FROM user_puzzles WHERE id = ?1",
            [puzzle_id],
            |r| r.get::<_, Option<String>>(0),
        )
        .ok()
        .flatten()
        .and_then(|json| serde_json::from_str::<serde_json::Value>(&json).ok())
        .and_then(|v| v.get("id")?.as_str().map(|s| s.to_string()));

    let qid = actual_puzzle_id.as_deref().unwrap_or(puzzle_id);

    let total_solvers: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1",
            [qid],
            |r| r.get(0),
        )
        .unwrap_or(0);

    let total_attempts: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM solve_records WHERE puzzle_id = ?1",
            [qid],
            |r| r.get(0),
        )
        .unwrap_or(0);

    let avg_time: f64 = db.query_row(
        "SELECT COALESCE(AVG(solve_time_secs), 0) FROM solve_records WHERE puzzle_id = ?1 AND is_complete = 1",
        [qid], |r| r.get(0),
    ).unwrap_or(0.0);

    let completion_rate = if total_attempts > 0 {
        total_solvers as f64 / total_attempts as f64
    } else {
        0.0
    };

    // hint heatmap from puzzle_hints - not per-cell but per-user hint count
    // we don't have per-cell hint tracking, so return total hints per user as a summary
    let total_hints: i64 = db
        .query_row(
            "SELECT COALESCE(SUM(hint_count), 0) FROM puzzle_hints WHERE puzzle_id = ?1",
            [qid],
            |r| r.get(0),
        )
        .unwrap_or(0);

    // get puzzle dimensions
    let (width, height): (i64, i64) = db
        .query_row(
            "SELECT width, height FROM user_puzzles WHERE id = ?1",
            [puzzle_id],
            |r| Ok((r.get(0)?, r.get(1)?)),
        )
        .unwrap_or((0, 0));

    Some(serde_json::json!({
        "total_solvers": total_solvers,
        "total_attempts": total_attempts,
        "avg_time": avg_time.round() as i64,
        "completion_rate": completion_rate,
        "total_hints": total_hints,
        "width": width,
        "height": height,
    }))
}

// daily puzzle archive
pub fn get_daily_archive(
    db: &Connection,
    preset: Option<&str>,
    token: &str,
    page: usize,
    per_page: usize,
) -> Vec<serde_json::Value> {
    let offset = page * per_page;
    let mut items = Vec::new();

    let (sql, use_preset) = if let Some(p) = preset {
        (
            "SELECT p.daily_date, p.preset, p.short_id, p.width, p.height,
                    CASE WHEN sr.is_complete = 1 THEN 1 ELSE 0 END as solved
             FROM puzzles p
             LEFT JOIN solve_records sr ON sr.puzzle_id = p.id AND sr.session_token = ?1
             WHERE p.daily_date IS NOT NULL AND p.daily_date <= date('now') AND p.preset = ?2
             ORDER BY p.daily_date DESC, p.preset ASC
             LIMIT ?3 OFFSET ?4"
                .to_string(),
            Some(p.to_string()),
        )
    } else {
        (
            "SELECT p.daily_date, p.preset, p.short_id, p.width, p.height,
                    CASE WHEN sr.is_complete = 1 THEN 1 ELSE 0 END as solved
             FROM puzzles p
             LEFT JOIN solve_records sr ON sr.puzzle_id = p.id AND sr.session_token = ?1
             WHERE p.daily_date IS NOT NULL AND p.daily_date <= date('now')
             ORDER BY p.daily_date DESC, p.preset ASC
             LIMIT ?2 OFFSET ?3"
                .to_string(),
            None,
        )
    };

    if let Some(ref p) = use_preset {
        let mut stmt = db.prepare(&sql).unwrap();
        let rows = stmt
            .query_map(params![token, p, per_page as i64, offset as i64], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                    row.get::<_, i64>(3)?,
                    row.get::<_, i64>(4)?,
                    row.get::<_, i64>(5)?,
                ))
            })
            .unwrap();
        for r in rows.flatten() {
            items.push(serde_json::json!({
                "date": r.0, "preset": r.1, "short_id": r.2,
                "width": r.3, "height": r.4, "solved": r.5 != 0,
            }));
        }
    } else {
        let mut stmt = db.prepare(&sql).unwrap();
        let rows = stmt
            .query_map(params![token, per_page as i64, offset as i64], |row| {
                Ok((
                    row.get::<_, String>(0)?,
                    row.get::<_, String>(1)?,
                    row.get::<_, String>(2)?,
                    row.get::<_, i64>(3)?,
                    row.get::<_, i64>(4)?,
                    row.get::<_, i64>(5)?,
                ))
            })
            .unwrap();
        for r in rows.flatten() {
            items.push(serde_json::json!({
                "date": r.0, "preset": r.1, "short_id": r.2,
                "width": r.3, "height": r.4, "solved": r.5 != 0,
            }));
        }
    }
    items
}

// daily challenges

const CHALLENGE_TYPES: &[&str] = &[
    "purist",
    "speed_demon",
    "surgeon",
    "no_regrets",
    "iron_will",
    "trust_fall",
    "inkwell",
    "night_owl",
    "early_bird",
    "rush_hour",
    "night_shift",
    "afternoon_tea",
    "contrarian",
    "sunday_stroll",
    "blitz",
    "opera_singer",
    "tongue_twister",
    "mic_drop",
    "jackrabbit",
    "metronome",
    "quickdraw",
    "bookends",
    "long_haul",
    "fast_fingers",
    "three_in_a_row",
    "alphabetical",
    "lucky_seven",
    "even_steven",
    "minimalist",
    "overtime",
];

pub fn get_daily_challenge(db: &Connection, date: &str, preset: &str) -> String {
    // check if already assigned
    if let Ok(ct) = db.query_row(
        "SELECT challenge_type FROM daily_challenges WHERE date = ?1 AND preset = ?2",
        params![date, preset],
        |row| row.get::<_, String>(0),
    ) {
        return ct;
    }

    // generate all 5 for this date at once
    generate_day_challenges(db, date);

    // now fetch the one we need
    db.query_row(
        "SELECT challenge_type FROM daily_challenges WHERE date = ?1 AND preset = ?2",
        params![date, preset],
        |row| row.get::<_, String>(0),
    )
    .unwrap_or_else(|_| CHALLENGE_TYPES[0].to_string())
}

fn generate_day_challenges(db: &Connection, date: &str) {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let presets = ["glyph", "rune", "scroll", "codex", "grimoire"];
    let cooldown_days = 14;

    // check if any already exist for this date (another thread might have beaten us)
    let existing: i64 = db
        .query_row(
            "SELECT COUNT(*) FROM daily_challenges WHERE date = ?1",
            [date],
            |r| r.get(0),
        )
        .unwrap_or(0);
    if existing >= 5 {
        return;
    }

    // get challenge history for the last 14 days
    let mut penalties: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
    if let Ok(mut stmt) = db.prepare(
        "SELECT challenge_type, date FROM daily_challenges WHERE date > date(?1, '-14 days') AND date < ?1"
    ) {
        if let Ok(rows) = stmt.query_map([date], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        }) {
            for r in rows.flatten() {
                let (ct, d) = r;
                // calculate days ago
                let days_ago = if let (Ok(target), Ok(entry)) = (
                    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d"),
                    chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d"),
                ) {
                    (target - entry).num_days()
                } else {
                    cooldown_days as i64
                };
                let penalty = (cooldown_days as i64).saturating_sub(days_ago).max(0);
                let entry = penalties.entry(ct).or_insert(0);
                *entry = (*entry).max(penalty); // use the highest penalty (most recent usage)
            }
        }
    }

    // score all challenges and sort by penalty (lowest first), break ties with hash
    let mut scored: Vec<(i64, u64, &str)> = CHALLENGE_TYPES
        .iter()
        .map(|ct| {
            let penalty = penalties.get(*ct).copied().unwrap_or(0);
            let mut hasher = DefaultHasher::new();
            date.hash(&mut hasher);
            ct.hash(&mut hasher);
            let tiebreak = hasher.finish();
            (penalty, tiebreak, *ct)
        })
        .collect();

    scored.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));

    // pick 5 unique challenges (lowest penalty first)
    for (i, preset) in presets.iter().enumerate() {
        if i < scored.len() {
            db.execute(
                "INSERT OR IGNORE INTO daily_challenges (date, preset, challenge_type) VALUES (?1, ?2, ?3)",
                params![date, preset, scored[i].2],
            ).ok();
        }
    }
}

pub fn complete_challenge(
    db: &Connection,
    token: &str,
    date: &str,
    preset: &str,
    challenge_type: &str,
    verified: bool,
    client_attested: bool,
) {
    let now = chrono::Utc::now().to_rfc3339();
    db.execute(
        "INSERT OR IGNORE INTO challenge_completions (session_token, date, preset, challenge_type, verified, client_attested, completed_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        params![token, date, preset, challenge_type, verified as i64, client_attested as i64, now],
    ).ok();
}

/// (solve_time_secs, hints_used, completed_at) for the newest completed solve
/// of the daily puzzle matching date+preset.
pub fn get_challenge_solve_info(
    db: &Connection,
    token: &str,
    date: &str,
    preset: &str,
) -> Option<(i64, i64, String)> {
    db.query_row(
        "SELECT sr.solve_time_secs, sr.hints_used, sr.completed_at
         FROM solve_records sr
         JOIN puzzles p ON sr.puzzle_id = p.id
         WHERE sr.session_token = ?1 AND p.daily_date = ?2 AND p.preset = ?3
           AND sr.is_complete = 1
         ORDER BY sr.completed_at DESC LIMIT 1",
        params![token, date, preset],
        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
    )
    .ok()
}

pub fn has_completed_challenge(db: &Connection, token: &str, date: &str, preset: &str) -> bool {
    db.query_row(
        "SELECT COUNT(*) FROM challenge_completions WHERE session_token = ?1 AND date = ?2 AND preset = ?3",
        params![token, date, preset],
        |row| row.get::<_, i64>(0),
    ).unwrap_or(0) > 0
}

pub fn get_today_challenge_completions(
    db: &Connection,
    token: &str,
    date: &str,
) -> std::collections::HashMap<String, bool> {
    let mut map = std::collections::HashMap::new();
    let mut stmt = db
        .prepare("SELECT preset FROM challenge_completions WHERE session_token = ?1 AND date = ?2")
        .unwrap();
    let rows = stmt
        .query_map(params![token, date], |row| row.get::<_, String>(0))
        .unwrap();
    for r in rows {
        if let Ok(preset) = r {
            map.insert(preset, true);
        }
    }
    map
}
