use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;

use axum::extract::{Path, State, WebSocketUpgrade};
use axum::extract::ws::{Message, WebSocket};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, RwLock};

use crate::db;
use crate::routes::AppError;
use crate::session::Session;
use crate::state::AppState;

// -- room state --

#[derive(Clone, Serialize, Deserialize)]
pub enum RoomMode {
    Coop,
    Competitive,
    Party,
}

// -- party mode --

#[derive(Clone, Serialize, Deserialize, PartialEq)]
pub enum PartyPhase {
    Lobby,
    Submit,
    Vote,
    Choose,
    Race,
    Results,
    Finished,
}

#[derive(Clone, Serialize, Deserialize, PartialEq)]
pub enum PartySubMode {
    WrongAnswers,
    Bluff,
    ReverseClue,
    SpeedRound,
    DefinitionRoulette,
}

impl PartySubMode {
    fn from_str(s: &str) -> Self {
        match s {
            "bluff" => Self::Bluff,
            "reverse" => Self::ReverseClue,
            "speed" => Self::SpeedRound,
            "roulette" => Self::DefinitionRoulette,
            _ => Self::WrongAnswers,
        }
    }
    fn as_str(&self) -> &str {
        match self {
            Self::WrongAnswers => "wrong",
            Self::Bluff => "bluff",
            Self::ReverseClue => "reverse",
            Self::SpeedRound => "speed",
            Self::DefinitionRoulette => "roulette",
        }
    }
}

#[derive(Serialize, Deserialize, Clone)]
pub struct PartyChoiceInfo {
    pub id: String,
    pub label: String,
    pub text: String,
}

pub struct PartyState {
    pub sub_mode: PartySubMode,
    pub clues: Vec<(String, String)>, // (clue_text, real_answer)
    pub roulette_fakes: Vec<Vec<String>>, // per-round fake clues for roulette
    pub current_round: usize,
    pub max_rounds: usize,
    pub phase: PartyPhase,
    pub submissions: HashMap<String, String>,   // player_id -> their answer
    pub answer_ids: HashMap<String, String>,     // answer_id -> player_id (or "__real__")
    pub votes: HashMap<String, String>,          // voter_id -> answer_id
    pub scores: HashMap<String, i32>,            // player_id -> total score
    pub speed_correct: Vec<(String, String, String)>, // (player_id, name, color) in order
    pub roulette_choices: Vec<PartyChoiceInfo>,  // current round options
    pub roulette_correct_id: String,             // correct choice id
    pub roulette_picks: HashMap<String, String>, // player_id -> choice_id
}

#[derive(Serialize, Deserialize, Clone)]
pub struct PartyAnswerInfo {
    pub id: String,
    pub text: String,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct PartyResultInfo {
    pub id: String,
    pub text: String,
    pub author: String,
    pub author_color: String,
    pub votes: u32,
    pub is_real: bool,
}

#[derive(Serialize, Deserialize, Clone)]
pub struct PartyScoreInfo {
    pub id: String,
    pub name: String,
    pub color: String,
    pub score: i32,
}

fn random_id() -> String {
    use rand::Rng;
    let mut rng = rand::rng();
    let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
    (0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect()
}

fn normalize_case(s: &str) -> String {
    let lower = s.to_lowercase();
    let mut chars = lower.chars();
    match chars.next() {
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
        None => String::new(),
    }
}

fn extract_clues(puzzle_json: &str) -> Vec<(String, String)> {
    let v: serde_json::Value = serde_json::from_str(puzzle_json).unwrap_or_default();
    let mut clues = Vec::new();
    for key in ["clues_across", "clues_down"] {
        if let Some(arr) = v.get(key).and_then(|v| v.as_array()) {
            for c in arr {
                let text = c.get("text").and_then(|v| v.as_str()).unwrap_or("");
                let answer = c.get("answer").and_then(|v| v.as_str()).unwrap_or("");
                if !text.is_empty() && !answer.is_empty() {
                    clues.push((text.to_string(), answer.to_uppercase()));
                }
            }
        }
    }
    clues
}

fn start_party_game(rooms: Rooms, code: String) {
    tokio::spawn(async move {
        let (max_rounds, sub_mode) = {
            let rooms = rooms.read().await;
            let room = match rooms.get(&code) {
                Some(r) => r,
                None => return,
            };
            let party = match &room.party {
                Some(p) => p,
                None => return,
            };
            (party.max_rounds, party.sub_mode.clone())
        };

        match sub_mode {
            PartySubMode::WrongAnswers => run_wrong_answers(rooms.clone(), code.clone(), max_rounds).await,
            PartySubMode::Bluff => run_bluff(rooms.clone(), code.clone(), max_rounds).await,
            PartySubMode::ReverseClue => run_reverse_clue(rooms.clone(), code.clone(), max_rounds).await,
            PartySubMode::SpeedRound => run_speed_round(rooms.clone(), code.clone(), max_rounds).await,
            PartySubMode::DefinitionRoulette => run_roulette(rooms.clone(), code.clone(), max_rounds).await,
        }

        // final scores
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) {
                Some(r) => r,
                None => return,
            };
            let party = match room.party.as_mut() {
                Some(p) => p,
                None => return,
            };
            party.phase = PartyPhase::Finished;

            let mut score_list: Vec<PartyScoreInfo> = room.players.values().map(|p| {
                PartyScoreInfo {
                    id: p.id.clone(),
                    name: p.name.clone(),
                    color: p.color.clone(),
                    score: party.scores.get(&p.id).copied().unwrap_or(0),
                }
            }).collect();
            score_list.sort_by(|a, b| b.score.cmp(&a.score));

            let msg = serde_json::to_string(&WsMessage::PartyFinal {
                scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
    });
}

fn build_score_list(players: &HashMap<String, Player>, scores: &HashMap<String, i32>) -> Vec<PartyScoreInfo> {
    let mut list: Vec<PartyScoreInfo> = players.values().map(|p| {
        PartyScoreInfo {
            id: p.id.clone(),
            name: p.name.clone(),
            color: p.color.clone(),
            score: scores.get(&p.id).copied().unwrap_or(0),
        }
    }).collect();
    list.sort_by(|a, b| b.score.cmp(&a.score));
    list
}

async fn wait_or_all_done(
    rooms: &Rooms,
    code: &str,
    max_secs: u64,
    check: impl Fn(&Room, &PartyState) -> bool,
) {
    let deadline = std::time::Instant::now() + Duration::from_secs(max_secs);
    loop {
        if std::time::Instant::now() >= deadline { break; }
        {
            let rooms = rooms.read().await;
            if let Some(room) = rooms.get(code) {
                if room.players.len() < 2 { break; }
                if let Some(party) = &room.party {
                    if matches!(party.phase, PartyPhase::Finished) { break; }
                    if check(room, party) { break; }
                }
            } else {
                break;
            }
        }
        tokio::time::sleep(Duration::from_millis(500)).await;
    }
}

async fn should_stop(rooms: &Rooms, code: &str) -> bool {
    let rooms = rooms.read().await;
    match rooms.get(code) {
        Some(room) => {
            if room.players.len() < 2 { return true; }
            if let Some(party) = &room.party {
                matches!(party.phase, PartyPhase::Finished)
            } else { true }
        }
        None => true,
    }
}

async fn run_wrong_answers(rooms: Rooms, code: String, max_rounds: usize) {
    for round in 0..max_rounds {
        if should_stop(&rooms, &code).await { return; }
        // submit phase
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.current_round = round;
            party.phase = PartyPhase::Submit;
            party.submissions.clear();
            party.answer_ids.clear();
            party.votes.clear();

            let (clue_text, _) = &party.clues[round];
            let msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: clue_text.clone(), phase: "submit".into(), deadline_secs: 30,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        wait_or_all_done(&rooms, &code, 30, |room, party| {
            room.players.len() > 0 && party.submissions.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // vote phase
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Vote;

            let (_, real_answer) = &party.clues[round];
            let mut answers: Vec<PartyAnswerInfo> = Vec::new();
            for (pid, text) in &party.submissions {
                let aid = random_id();
                party.answer_ids.insert(aid.clone(), pid.clone());
                answers.push(PartyAnswerInfo { id: aid, text: text.to_uppercase() });
            }
            let real_id = random_id();
            party.answer_ids.insert(real_id.clone(), "__real__".into());
            answers.push(PartyAnswerInfo { id: real_id, text: real_answer.to_uppercase() });

            use rand::seq::SliceRandom;
            let mut rng = rand::rng();
            answers.shuffle(&mut rng);

            let round_msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: party.clues[round].0.clone(), phase: "vote".into(), deadline_secs: 20,
            }).unwrap();
            let _ = room.tx.send(round_msg);

            let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap();
            let _ = room.tx.send(ans_msg);
        }
        wait_or_all_done(&rooms, &code, 20, |room, party| {
            room.players.len() > 0 && party.votes.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // results
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Results;

            let (_, real_answer) = party.clues[round].clone();
            let mut vote_counts: HashMap<String, u32> = HashMap::new();
            for (_, aid) in &party.votes {
                *vote_counts.entry(aid.clone()).or_insert(0) += 1;
            }
            for (aid, author_id) in &party.answer_ids {
                if author_id == "__real__" { continue; }
                let v = vote_counts.get(aid).copied().unwrap_or(0);
                if v > 0 {
                    *party.scores.entry(author_id.clone()).or_insert(0) += v as i32;
                }
            }

            let mut results: Vec<PartyResultInfo> = Vec::new();
            for (aid, author_id) in &party.answer_ids {
                let is_real = author_id == "__real__";
                let text = if is_real { real_answer.to_uppercase() } else {
                    party.submissions.get(author_id).map(|s| s.to_uppercase()).unwrap_or_default()
                };
                let (aname, acolor) = if is_real {
                    ("the real answer".to_string(), "#888888".to_string())
                } else {
                    let p = room.players.get(author_id);
                    (p.map(|p| p.name.clone()).unwrap_or("unknown".into()),
                     p.map(|p| p.color.clone()).unwrap_or("#888888".into()))
                };
                results.push(PartyResultInfo {
                    id: aid.clone(), text, author: aname, author_color: acolor,
                    votes: vote_counts.get(aid).copied().unwrap_or(0), is_real,
                });
            }
            results.sort_by(|a, b| b.votes.cmp(&a.votes));
            let score_list = build_score_list(&room.players, &party.scores);

            let msg = serde_json::to_string(&WsMessage::PartyResults {
                answers: results, real_answer, scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        tokio::time::sleep(Duration::from_secs(8)).await;
    }
}

async fn run_bluff(rooms: Rooms, code: String, max_rounds: usize) {
    for round in 0..max_rounds {
        if should_stop(&rooms, &code).await { return; }
        // submit phase - show the WORD, players write fake definitions
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.current_round = round;
            party.phase = PartyPhase::Submit;
            party.submissions.clear();
            party.answer_ids.clear();
            party.votes.clear();

            let (_, word) = &party.clues[round];
            let msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: word.clone(), phase: "submit".into(), deadline_secs: 40,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        wait_or_all_done(&rooms, &code, 40, |room, party| {
            room.players.len() > 0 && party.submissions.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // vote phase - fake defs + real definition shuffled
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Vote;

            let (real_def, word) = &party.clues[round];
            let word_clone = word.clone();
            let mut answers: Vec<PartyAnswerInfo> = Vec::new();
            for (pid, text) in &party.submissions {
                let aid = random_id();
                party.answer_ids.insert(aid.clone(), pid.clone());
                answers.push(PartyAnswerInfo { id: aid, text: normalize_case(text) });
            }
            let real_id = random_id();
            party.answer_ids.insert(real_id.clone(), "__real__".into());
            answers.push(PartyAnswerInfo { id: real_id, text: normalize_case(&real_def) });

            use rand::seq::SliceRandom;
            let mut rng = rand::rng();
            answers.shuffle(&mut rng);

            let round_msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: word_clone, phase: "vote".into(), deadline_secs: 25,
            }).unwrap();
            let _ = room.tx.send(round_msg);
            let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap();
            let _ = room.tx.send(ans_msg);
        }
        wait_or_all_done(&rooms, &code, 25, |room, party| {
            room.players.len() > 0 && party.votes.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // results - 2 pts per fool, 3 pts for spotting real
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Results;

            let (real_def, _) = party.clues[round].clone();
            let mut vote_counts: HashMap<String, u32> = HashMap::new();
            for (_, aid) in &party.votes {
                *vote_counts.entry(aid.clone()).or_insert(0) += 1;
            }

            // 2 pts per vote on your fake def (fooling people)
            for (aid, author_id) in &party.answer_ids {
                if author_id == "__real__" { continue; }
                let v = vote_counts.get(aid).copied().unwrap_or(0);
                if v > 0 {
                    *party.scores.entry(author_id.clone()).or_insert(0) += v as i32 * 2;
                }
            }
            // 3 pts for voting on the real definition
            let real_aid = party.answer_ids.iter()
                .find(|(_, v)| v.as_str() == "__real__")
                .map(|(k, _)| k.clone());
            if let Some(ref real_aid) = real_aid {
                for (voter, voted_aid) in &party.votes {
                    if voted_aid == real_aid {
                        *party.scores.entry(voter.clone()).or_insert(0) += 3;
                    }
                }
            }

            let mut results: Vec<PartyResultInfo> = Vec::new();
            for (aid, author_id) in &party.answer_ids {
                let is_real = author_id == "__real__";
                let text = if is_real { normalize_case(&real_def) } else {
                    party.submissions.get(author_id).map(|s| normalize_case(s)).unwrap_or_default()
                };
                let (aname, acolor) = if is_real {
                    ("real definition".to_string(), "#22c55e".to_string())
                } else {
                    let p = room.players.get(author_id);
                    (p.map(|p| p.name.clone()).unwrap_or("unknown".into()),
                     p.map(|p| p.color.clone()).unwrap_or("#888888".into()))
                };
                results.push(PartyResultInfo {
                    id: aid.clone(), text, author: aname, author_color: acolor,
                    votes: vote_counts.get(aid).copied().unwrap_or(0), is_real,
                });
            }
            results.sort_by(|a, b| b.votes.cmp(&a.votes));
            let score_list = build_score_list(&room.players, &party.scores);

            let msg = serde_json::to_string(&WsMessage::PartyResults {
                answers: results, real_answer: real_def, scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        tokio::time::sleep(Duration::from_secs(8)).await;
    }
}

async fn run_reverse_clue(rooms: Rooms, code: String, max_rounds: usize) {
    for round in 0..max_rounds {
        if should_stop(&rooms, &code).await { return; }
        // submit phase - show the WORD, players write clues
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.current_round = round;
            party.phase = PartyPhase::Submit;
            party.submissions.clear();
            party.answer_ids.clear();
            party.votes.clear();

            let (_, word) = &party.clues[round];
            let msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: word.clone(), phase: "submit".into(), deadline_secs: 30,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        wait_or_all_done(&rooms, &code, 30, |room, party| {
            room.players.len() > 0 && party.submissions.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // vote phase - all submitted clues, no real answer mixed in
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Vote;

            let (_, word) = &party.clues[round];
            let word_clone = word.clone();
            let mut answers: Vec<PartyAnswerInfo> = Vec::new();
            for (pid, text) in &party.submissions {
                let aid = random_id();
                party.answer_ids.insert(aid.clone(), pid.clone());
                answers.push(PartyAnswerInfo { id: aid, text: normalize_case(text) });
            }

            use rand::seq::SliceRandom;
            let mut rng = rand::rng();
            answers.shuffle(&mut rng);

            let round_msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: word_clone, phase: "vote".into(), deadline_secs: 20,
            }).unwrap();
            let _ = room.tx.send(round_msg);
            let ans_msg = serde_json::to_string(&WsMessage::PartyAnswers { answers }).unwrap();
            let _ = room.tx.send(ans_msg);
        }
        wait_or_all_done(&rooms, &code, 20, |room, party| {
            room.players.len() > 0 && party.votes.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // results - 1 pt per vote received
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Results;

            let (real_clue, _) = party.clues[round].clone();
            let mut vote_counts: HashMap<String, u32> = HashMap::new();
            for (_, aid) in &party.votes {
                *vote_counts.entry(aid.clone()).or_insert(0) += 1;
            }
            for (aid, author_id) in &party.answer_ids {
                let v = vote_counts.get(aid).copied().unwrap_or(0);
                if v > 0 {
                    *party.scores.entry(author_id.clone()).or_insert(0) += v as i32;
                }
            }

            let mut results: Vec<PartyResultInfo> = Vec::new();
            for (aid, author_id) in &party.answer_ids {
                let text = party.submissions.get(author_id).map(|s| normalize_case(s)).unwrap_or_default();
                let p = room.players.get(author_id);
                results.push(PartyResultInfo {
                    id: aid.clone(), text,
                    author: p.map(|p| p.name.clone()).unwrap_or("unknown".into()),
                    author_color: p.map(|p| p.color.clone()).unwrap_or("#888888".into()),
                    votes: vote_counts.get(aid).copied().unwrap_or(0),
                    is_real: false,
                });
            }
            results.sort_by(|a, b| b.votes.cmp(&a.votes));
            let score_list = build_score_list(&room.players, &party.scores);

            let msg = serde_json::to_string(&WsMessage::PartyResults {
                answers: results, real_answer: real_clue, scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        tokio::time::sleep(Duration::from_secs(8)).await;
    }
}

async fn run_speed_round(rooms: Rooms, code: String, max_rounds: usize) {
    for round in 0..max_rounds {
        if should_stop(&rooms, &code).await { return; }
        // race phase - show clue, players race to answer
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.current_round = round;
            party.phase = PartyPhase::Race;
            party.speed_correct.clear();

            let (clue_text, _) = &party.clues[round];
            let msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: clue_text.clone(), phase: "race".into(), deadline_secs: 10,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        wait_or_all_done(&rooms, &code, 10, |room, party| {
            room.players.len() > 0 && party.speed_correct.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // results
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Results;

            let (_, real_answer) = party.clues[round].clone();
            let correct = party.speed_correct.clone();

            let mut results: Vec<PartyResultInfo> = Vec::new();
            for (i, (pid, name, color)) in correct.iter().enumerate() {
                let pts = match i { 0 => 3, 1 => 2, 2 => 1, _ => 0 };
                results.push(PartyResultInfo {
                    id: pid.clone(), text: format!("#{} - {} pts", i + 1, pts),
                    author: name.clone(), author_color: color.clone(),
                    votes: pts as u32, is_real: false,
                });
            }
            let score_list = build_score_list(&room.players, &party.scores);

            let msg = serde_json::to_string(&WsMessage::PartyResults {
                answers: results, real_answer, scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        tokio::time::sleep(Duration::from_secs(5)).await;
    }
}

async fn run_roulette(rooms: Rooms, code: String, max_rounds: usize) {
    for round in 0..max_rounds {
        if should_stop(&rooms, &code).await { return; }
        // choose phase - show word + 4 options
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.current_round = round;
            party.phase = PartyPhase::Choose;
            party.roulette_picks.clear();

            let (real_clue, word) = &party.clues[round];
            let labels = ["A", "B", "C", "D"];

            let mut options: Vec<PartyChoiceInfo> = Vec::new();
            // real option
            let real_id = random_id();
            options.push(PartyChoiceInfo { id: real_id.clone(), label: String::new(), text: normalize_case(real_clue) });
            party.roulette_correct_id = real_id;

            // fake options
            let fakes = if round < party.roulette_fakes.len() {
                party.roulette_fakes[round].clone()
            } else {
                Vec::new()
            };
            for fake in fakes.into_iter().take(3) {
                options.push(PartyChoiceInfo { id: random_id(), label: String::new(), text: normalize_case(&fake) });
            }

            // shuffle and assign labels
            use rand::seq::SliceRandom;
            let mut rng = rand::rng();
            options.shuffle(&mut rng);
            for (i, opt) in options.iter_mut().enumerate() {
                opt.label = labels.get(i).unwrap_or(&"?").to_string();
            }
            party.roulette_choices = options.clone();

            let round_msg = serde_json::to_string(&WsMessage::PartyRound {
                round: round + 1, total: party.max_rounds,
                clue: word.clone(), phase: "choose".into(), deadline_secs: 15,
            }).unwrap();
            let _ = room.tx.send(round_msg);
            let choices_msg = serde_json::to_string(&WsMessage::PartyChoices {
                word: word.clone(), options,
            }).unwrap();
            let _ = room.tx.send(choices_msg);
        }
        wait_or_all_done(&rooms, &code, 15, |room, party| {
            room.players.len() > 0 && party.roulette_picks.len() >= room.players.len()
        }).await;
        if should_stop(&rooms, &code).await { return; }

        // results
        {
            let mut rooms = rooms.write().await;
            let room = match rooms.get_mut(&code) { Some(r) => r, None => return };
            let party = match room.party.as_mut() { Some(p) => p, None => return };
            party.phase = PartyPhase::Results;

            let (real_clue, _) = party.clues[round].clone();
            let correct_id = party.roulette_correct_id.clone();

            // award 1 pt per correct pick
            for (pid, cid) in &party.roulette_picks {
                if cid == &correct_id {
                    *party.scores.entry(pid.clone()).or_insert(0) += 1;
                }
            }

            // build results showing each choice and who picked it
            let mut results: Vec<PartyResultInfo> = Vec::new();
            for opt in &party.roulette_choices {
                let is_correct = opt.id == correct_id;
                let picks: u32 = party.roulette_picks.values().filter(|v| **v == opt.id).count() as u32;
                results.push(PartyResultInfo {
                    id: opt.id.clone(),
                    text: format!("{}: {}", opt.label, opt.text),
                    author: if is_correct { "correct".into() } else { String::new() },
                    author_color: if is_correct { "#22c55e".into() } else { "#888888".into() },
                    votes: picks,
                    is_real: is_correct,
                });
            }
            let score_list = build_score_list(&room.players, &party.scores);

            let msg = serde_json::to_string(&WsMessage::PartyResults {
                answers: results, real_answer: real_clue, scores: score_list,
            }).unwrap();
            let _ = room.tx.send(msg);
        }
        tokio::time::sleep(Duration::from_secs(6)).await;
    }
}

#[derive(Clone)]
pub struct Player {
    pub id: String,
    pub name: String,
    pub color: String,
    pub cursor: Option<(usize, usize)>,
    pub direction: String,
    pub order: usize,
}

pub struct Room {
    pub code: String,
    pub mode: RoomMode,
    pub puzzle_id: String,
    pub width: usize,
    pub height: usize,
    pub grid: Vec<Vec<String>>,
    pub players: HashMap<String, Player>,
    pub turn_order: Vec<String>,
    pub current_turn: usize,
    pub started: bool,
    pub last_activity: std::time::Instant,
    pub tx: broadcast::Sender<String>,
    pub max_players: usize,
    pub creator: String,
    pub party: Option<PartyState>,
}

impl Room {
    fn current_player_id(&self) -> Option<&str> {
        if !self.started || self.turn_order.is_empty() {
            return None;
        }
        let idx = self.current_turn % self.turn_order.len();
        Some(&self.turn_order[idx])
    }

    fn advance_turn(&mut self) {
        if self.turn_order.is_empty() { return; }
        self.current_turn += 1;
        let idx = self.current_turn % self.turn_order.len();
        let id = self.turn_order[idx].clone();
        let name = self.players.get(&id).map(|p| p.name.clone()).unwrap_or_default();
        let color = self.players.get(&id).map(|p| p.color.clone()).unwrap_or_default();
        let msg = serde_json::to_string(&WsMessage::TurnChange {
            id: id.clone(),
            name,
            color,
            turn_number: self.current_turn,
        }).unwrap();
        let _ = self.tx.send(msg);
    }

    fn remove_from_turn_order(&mut self, token: &str) {
        if let Some(pos) = self.turn_order.iter().position(|id| id == token) {
            self.turn_order.remove(pos);
            if !self.turn_order.is_empty() && self.current_turn >= self.turn_order.len() {
                self.current_turn = self.current_turn % self.turn_order.len();
            }
        }
    }
}

pub type Rooms = Arc<RwLock<HashMap<String, Room>>>;

pub fn new_rooms() -> Rooms {
    Arc::new(RwLock::new(HashMap::new()))
}

// -- code gen --

const PLAYER_COLORS: &[&str] = &[
    "#e8594a", "#3b82f6", "#22c55e", "#f59e0b",
    "#8b5cf6", "#ec4899", "#14b8a6", "#f97316",
];

pub fn generate_code() -> String {
    use rand::Rng;
    let mut rng = rand::rng();
    let chars: Vec<char> = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".chars().collect();
    (0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect()
}

// -- ws protocol --

#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsMessage {
    // client -> server
    #[serde(rename = "cursor")]
    Cursor { row: usize, col: usize, direction: String },
    #[serde(rename = "cell")]
    CellUpdate { row: usize, col: usize, letter: String },
    #[serde(rename = "clear")]
    CellClear { row: usize, col: usize },
    #[serde(rename = "pass")]
    Pass,
    #[serde(rename = "start")]
    Start,
    #[serde(rename = "party_submit")]
    PartySubmit { answer: String },
    #[serde(rename = "party_vote")]
    PartyVote { answer_id: String },
    #[serde(rename = "party_answer")]
    PartyAnswer { answer: String },
    #[serde(rename = "party_pick")]
    PartyPick { choice_id: String },

    // server -> client
    #[serde(rename = "player_join")]
    PlayerJoin { id: String, name: String, color: String },
    #[serde(rename = "player_leave")]
    PlayerLeave { id: String },
    #[serde(rename = "player_cursor")]
    PlayerCursor { id: String, row: usize, col: usize, direction: String },
    #[serde(rename = "cell_update")]
    ServerCellUpdate { id: String, row: usize, col: usize, letter: String },
    #[serde(rename = "cell_clear")]
    ServerCellClear { id: String, row: usize, col: usize },
    #[serde(rename = "state")]
    FullState {
        grid: Vec<Vec<String>>,
        players: Vec<PlayerInfo>,
        current_turn: Option<String>,
        started: bool,
        your_id: String,
    },
    #[serde(rename = "turn_change")]
    TurnChange { id: String, name: String, color: String, turn_number: usize },
    #[serde(rename = "game_started")]
    GameStarted { first_player: String },
    #[serde(rename = "complete")]
    Complete { id: String, time: i64 },
    #[serde(rename = "error")]
    Error { message: String },

    // party mode server -> client
    #[serde(rename = "party_round")]
    PartyRound { round: usize, total: usize, clue: String, phase: String, deadline_secs: u32 },
    #[serde(rename = "party_answers")]
    PartyAnswers { answers: Vec<PartyAnswerInfo> },
    #[serde(rename = "party_results")]
    PartyResults { answers: Vec<PartyResultInfo>, real_answer: String, scores: Vec<PartyScoreInfo> },
    #[serde(rename = "party_final")]
    PartyFinal { scores: Vec<PartyScoreInfo> },
    #[serde(rename = "party_correct")]
    PartyCorrect { id: String, name: String, color: String, rank: usize, points: i32 },
    #[serde(rename = "party_choices")]
    PartyChoices { word: String, options: Vec<PartyChoiceInfo> },
}

#[derive(Serialize, Deserialize)]
pub struct PlayerInfo {
    pub id: String,
    pub name: String,
    pub color: String,
    pub cursor: Option<(usize, usize)>,
    pub direction: String,
    pub order: usize,
}

// -- REST endpoints --

pub async fn create_room(
    State(state): State<Arc<AppState>>,
    session: Session,
    Json(req): Json<serde_json::Value>,
) -> Result<Response, AppError> {
    {
        let dbi = state.db.lock().await;
        if db::is_banned(&dbi.db, &session.token) {
            return Err(AppError::with_status(
                axum::http::StatusCode::FORBIDDEN, "account suspended",
            ));
        }
    }

    crate::security::check_rate_limit(&session.token, "create_room", 5, 300)
        .map_err(|s| AppError::with_status(s, "rate limit exceeded"))?;

    let puzzle_id = req.get("puzzle_id").and_then(|v| v.as_str()).unwrap_or("");
    let mode = match req.get("mode").and_then(|v| v.as_str()) {
        Some("competitive") => RoomMode::Competitive,
        Some("party") => RoomMode::Party,
        _ => RoomMode::Coop,
    };

    // party mode doesn't need a puzzle - pull random clues from the clue DB
    let (puzzle_json, width, height) = if matches!(mode, RoomMode::Party) {
        (String::new(), 0usize, 0usize)
    } else {
        let pj = {
            let dbi = state.db.lock().await;
            db::get_puzzle_json(&dbi.db, puzzle_id).map(|(json, _)| json)
        };
        let pj = match pj {
            Some(j) => j,
            None => return Err(AppError::with_status(
                axum::http::StatusCode::NOT_FOUND, "puzzle not found",
            )),
        };
        let pdata: serde_json::Value = serde_json::from_str(&pj).unwrap_or_default();
        let w = pdata.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        let h = pdata.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
        if w == 0 || h == 0 {
            return Err(AppError::with_status(
                axum::http::StatusCode::BAD_REQUEST, "invalid puzzle dimensions",
            ));
        }
        (pj, w, h)
    };

    let code = generate_code();
    let max_players = match mode {
        RoomMode::Coop => 8,
        RoomMode::Competitive => 20,
        RoomMode::Party => 20,
    };

    let (tx, _) = broadcast::channel(256);
    let grid = vec![vec![String::new(); width]; height];

    let party_sub_str = req.get("party_sub").and_then(|v| v.as_str()).unwrap_or("wrong");
    let party_sub = PartySubMode::from_str(party_sub_str);

    let party = if matches!(mode, RoomMode::Party) {
        let max_rounds = 10;
        let (clues, roulette_fakes) = match &party_sub {
            PartySubMode::Bluff => {
                let mut c = state.clue_db.random_obscure_clues(max_rounds);
                use rand::seq::SliceRandom;
                let mut rng = rand::rng();
                c.shuffle(&mut rng);
                c.truncate(max_rounds);
                (c, Vec::new())
            }
            PartySubMode::DefinitionRoulette => {
                let raw = state.clue_db.random_clues_with_fakes(max_rounds, 3);
                let mut clues = Vec::new();
                let mut fakes = Vec::new();
                for (clue, word, f) in raw {
                    clues.push((clue, word));
                    fakes.push(f);
                }
                (clues, fakes)
            }
            _ => {
                let mut c = if puzzle_json.is_empty() {
                    state.clue_db.random_clues(max_rounds)
                } else {
                    extract_clues(&puzzle_json)
                };
                use rand::seq::SliceRandom;
                let mut rng = rand::rng();
                c.shuffle(&mut rng);
                c.truncate(max_rounds);
                (c, Vec::new())
            }
        };
        let max_rounds = clues.len().min(10);
        Some(PartyState {
            sub_mode: party_sub,
            clues,
            roulette_fakes,
            current_round: 0,
            max_rounds,
            phase: PartyPhase::Lobby,
            submissions: HashMap::new(),
            answer_ids: HashMap::new(),
            votes: HashMap::new(),
            scores: HashMap::new(),
            speed_correct: Vec::new(),
            roulette_choices: Vec::new(),
            roulette_correct_id: String::new(),
            roulette_picks: HashMap::new(),
        })
    } else {
        None
    };

    let room = Room {
        code: code.clone(),
        mode,
        puzzle_id: puzzle_id.to_string(),
        width,
        height,
        grid,
        players: HashMap::new(),
        turn_order: Vec::new(),
        current_turn: 0,
        started: false,
        last_activity: std::time::Instant::now(),
        tx,
        max_players,
        creator: session.token.clone(),
        party,
    };

    let mut rooms = state.rooms.write().await;
    rooms.insert(code.clone(), room);

    Ok(Json(serde_json::json!({ "code": code })).into_response())
}

pub async fn get_room(
    State(state): State<Arc<AppState>>,
    Path(code): Path<String>,
) -> Result<Response, AppError> {
    let rooms = state.rooms.read().await;
    let room = rooms.get(&code.to_uppercase())
        .ok_or(AppError::with_status(
            axum::http::StatusCode::NOT_FOUND, "room not found",
        ))?;

    let players: Vec<PlayerInfo> = room.players.values().map(|p| PlayerInfo {
        id: p.id.clone(),
        name: p.name.clone(),
        color: p.color.clone(),
        cursor: p.cursor,
        direction: p.direction.clone(),
        order: p.order,
    }).collect();

    let mode_str = match room.mode {
        RoomMode::Coop => "coop",
        RoomMode::Competitive => "competitive",
        RoomMode::Party => "party",
    };

    let mut resp = serde_json::json!({
        "code": room.code,
        "mode": mode_str,
        "puzzle_id": room.puzzle_id,
        "players": players,
        "player_count": room.players.len(),
        "max_players": room.max_players,
        "started": room.started,
        "current_turn": room.current_player_id(),
    });

    if let Some(party) = &room.party {
        let phase_str = match &party.phase {
            PartyPhase::Lobby => "lobby",
            PartyPhase::Submit => "submit",
            PartyPhase::Vote => "vote",
            PartyPhase::Choose => "choose",
            PartyPhase::Race => "race",
            PartyPhase::Results => "results",
            PartyPhase::Finished => "finished",
        };
        let mut score_list: Vec<PartyScoreInfo> = room.players.values().map(|p| {
            PartyScoreInfo {
                id: p.id.clone(),
                name: p.name.clone(),
                color: p.color.clone(),
                score: party.scores.get(&p.id).copied().unwrap_or(0),
            }
        }).collect();
        score_list.sort_by(|a, b| b.score.cmp(&a.score));

        resp["party"] = serde_json::json!({
            "phase": phase_str,
            "round": party.current_round + 1,
            "total_rounds": party.max_rounds,
            "scores": score_list,
            "sub_mode": party.sub_mode.as_str(),
        });
    }

    Ok(Json(resp).into_response())
}

// -- WebSocket --

pub async fn ws_upgrade(
    State(state): State<Arc<AppState>>,
    Path(code): Path<String>,
    session: Session,
    ws: WebSocketUpgrade,
) -> Response {
    let code = code.to_uppercase();
    let token = session.token.clone();
    let name = session.display_name.unwrap_or_else(|| "Anonymous".into());
    ws.on_upgrade(move |socket| handle_ws(socket, state, code, token, name))
}

async fn send_text(socket: &mut WebSocket, text: String) -> Result<(), axum::Error> {
    socket.send(Message::Text(text.into())).await
}

async fn handle_ws(
    mut socket: WebSocket,
    state: Arc<AppState>,
    code: String,
    token: String,
    name: String,
) {
    // ban check
    {
        let dbi = state.db.lock().await;
        if db::is_banned(&dbi.db, &token) {
            let msg = serde_json::to_string(&WsMessage::Error {
                message: "account suspended".into(),
            }).unwrap();
            let _ = send_text(&mut socket, msg).await;
            return;
        }
    }

    // join room
    let mut rx = {
        let mut rooms = state.rooms.write().await;
        let room = match rooms.get_mut(&code) {
            Some(r) => r,
            None => {
                let msg = serde_json::to_string(&WsMessage::Error {
                    message: "room not found".into(),
                }).unwrap();
                let _ = send_text(&mut socket, msg).await;
                return;
            }
        };

        if room.players.len() >= room.max_players {
            let msg = serde_json::to_string(&WsMessage::Error {
                message: "room is full".into(),
            }).unwrap();
            let _ = send_text(&mut socket, msg).await;
            return;
        }

        let order = room.turn_order.len();
        let color_idx = order % PLAYER_COLORS.len();
        let color = PLAYER_COLORS[color_idx].to_string();

        let player = Player {
            id: token.clone(),
            name: name.clone(),
            color: color.clone(),
            cursor: None,
            direction: "across".into(),
            order,
        };
        room.players.insert(token.clone(), player);
        room.turn_order.push(token.clone());
        room.last_activity = std::time::Instant::now();

        let join_msg = serde_json::to_string(&WsMessage::PlayerJoin {
            id: token.clone(),
            name: name.clone(),
            color: color.clone(),
        }).unwrap();
        let _ = room.tx.send(join_msg);

        let rx = room.tx.subscribe();

        let state_msg = serde_json::to_string(&WsMessage::FullState {
            grid: room.grid.clone(),
            players: room.players.values().map(|p| PlayerInfo {
                id: p.id.clone(),
                name: p.name.clone(),
                color: p.color.clone(),
                cursor: p.cursor,
                direction: p.direction.clone(),
                order: p.order,
            }).collect(),
            current_turn: room.current_player_id().map(|s| s.to_string()),
            started: room.started,
            your_id: token.clone(),
        }).unwrap();
        let _ = send_text(&mut socket, state_msg).await;

        rx
    };

    // main loop
    loop {
        tokio::select! {
            msg = socket.recv() => {
                match msg {
                    Some(Ok(Message::Text(text))) => {
                        let text: &str = &text;
                        if let Ok(ws_msg) = serde_json::from_str::<WsMessage>(text) {
                            let mut rooms = state.rooms.write().await;
                            if let Some(room) = rooms.get_mut(&code) {
                                room.last_activity = std::time::Instant::now();
                                match ws_msg {
                                    WsMessage::Start => {
                                        if token == room.creator && !room.started && room.players.len() >= 2 {
                                            room.started = true;

                                            if matches!(room.mode, RoomMode::Party) {
                                                // party mode - spawn the game loop
                                                let msg = serde_json::to_string(&WsMessage::GameStarted {
                                                    first_player: String::new(),
                                                }).unwrap();
                                                let _ = room.tx.send(msg);
                                                start_party_game(state.rooms.clone(), code.clone());
                                            } else {
                                                // coop/competitive
                                                room.current_turn = 0;
                                                let first = room.turn_order[0].clone();
                                                let msg = serde_json::to_string(&WsMessage::GameStarted {
                                                    first_player: first,
                                                }).unwrap();
                                                let _ = room.tx.send(msg);
                                                room.advance_turn();
                                                room.current_turn = 0;
                                                let id = room.turn_order[0].clone();
                                                let name = room.players.get(&id).map(|p| p.name.clone()).unwrap_or_default();
                                                let color = room.players.get(&id).map(|p| p.color.clone()).unwrap_or_default();
                                                let turn_msg = serde_json::to_string(&WsMessage::TurnChange {
                                                    id, name, color, turn_number: 0,
                                                }).unwrap();
                                                let _ = room.tx.send(turn_msg);
                                            }
                                        }
                                    }
                                    WsMessage::PartySubmit { answer } => {
                                        if let Some(party) = room.party.as_mut() {
                                            if party.phase == PartyPhase::Submit {
                                                let clean = answer.trim().chars().take(50).collect::<String>();
                                                if !clean.is_empty() && !state.blocklist.read().unwrap().contains_blocked(&clean) {
                                                    party.submissions.insert(token.clone(), clean);
                                                }
                                            }
                                        }
                                    }
                                    WsMessage::PartyVote { answer_id } => {
                                        if let Some(party) = room.party.as_mut() {
                                            if party.phase == PartyPhase::Vote {
                                                let is_own = party.answer_ids.get(&answer_id)
                                                    .map(|author| author == &token)
                                                    .unwrap_or(false);
                                                if !is_own && party.answer_ids.contains_key(&answer_id) {
                                                    party.votes.insert(token.clone(), answer_id);
                                                }
                                            }
                                        }
                                    }
                                    WsMessage::PartyAnswer { answer } => {
                                        if let Some(party) = room.party.as_mut() {
                                            if party.phase == PartyPhase::Race && party.sub_mode == PartySubMode::SpeedRound {
                                                // already answered correctly?
                                                if party.speed_correct.iter().any(|(id, _, _)| id == &token) {
                                                    continue;
                                                }
                                                let (_, real_answer) = &party.clues[party.current_round];
                                                if answer.trim().eq_ignore_ascii_case(real_answer.trim()) {
                                                    let rank = party.speed_correct.len();
                                                    let pts = match rank { 0 => 3, 1 => 2, 2 => 1, _ => 0 };
                                                    let pname = room.players.get(&token).map(|p| p.name.clone()).unwrap_or("unknown".into());
                                                    let pcolor = room.players.get(&token).map(|p| p.color.clone()).unwrap_or("#888888".into());
                                                    party.speed_correct.push((token.clone(), pname.clone(), pcolor.clone()));
                                                    *party.scores.entry(token.clone()).or_insert(0) += pts;

                                                    let msg = serde_json::to_string(&WsMessage::PartyCorrect {
                                                        id: token.clone(), name: pname, color: pcolor,
                                                        rank: rank + 1, points: pts,
                                                    }).unwrap();
                                                    let _ = room.tx.send(msg);
                                                }
                                            }
                                        }
                                    }
                                    WsMessage::PartyPick { choice_id } => {
                                        if let Some(party) = room.party.as_mut() {
                                            if party.phase == PartyPhase::Choose && party.sub_mode == PartySubMode::DefinitionRoulette {
                                                if !party.roulette_picks.contains_key(&token) {
                                                    if party.roulette_choices.iter().any(|c| c.id == choice_id) {
                                                        party.roulette_picks.insert(token.clone(), choice_id);
                                                    }
                                                }
                                            }
                                        }
                                    }
                                    WsMessage::Pass => {
                                        if room.started && room.current_player_id() == Some(&token) {
                                            room.advance_turn();
                                        }
                                    }
                                    WsMessage::Cursor { row, col, direction } => {
                                        if let Some(p) = room.players.get_mut(&token) {
                                            p.cursor = Some((row, col));
                                            p.direction = direction.clone();
                                        }
                                        let out = serde_json::to_string(&WsMessage::PlayerCursor {
                                            id: token.clone(), row, col, direction,
                                        }).unwrap();
                                        let _ = room.tx.send(out);
                                    }
                                    WsMessage::CellUpdate { row, col, letter } => {
                                        // only current player can update cells
                                        if !room.started || room.current_player_id() != Some(&token) {
                                            continue;
                                        }
                                        if row < room.height && col < room.width {
                                            room.grid[row][col] = letter.clone();
                                            let out = serde_json::to_string(&WsMessage::ServerCellUpdate {
                                                id: token.clone(), row, col, letter,
                                            }).unwrap();
                                            let _ = room.tx.send(out);
                                        }
                                    }
                                    WsMessage::CellClear { row, col } => {
                                        if !room.started || room.current_player_id() != Some(&token) {
                                            continue;
                                        }
                                        if row < room.height && col < room.width {
                                            room.grid[row][col] = String::new();
                                            let out = serde_json::to_string(&WsMessage::ServerCellClear {
                                                id: token.clone(), row, col,
                                            }).unwrap();
                                            let _ = room.tx.send(out);
                                        }
                                    }
                                    _ => {}
                                }
                            }
                        }
                    }
                    Some(Ok(Message::Close(_))) | None => break,
                    _ => {}
                }
            }
            msg = rx.recv() => {
                match msg {
                    Ok(text) => {
                        if send_text(&mut socket, text).await.is_err() {
                            break;
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }

    // leave room
    {
        let mut rooms = state.rooms.write().await;
        if let Some(room) = rooms.get_mut(&code) {
            let is_creator = room.creator == token;
            let was_current = room.current_player_id() == Some(&token);
            room.players.remove(&token);
            room.remove_from_turn_order(&token);

            let leave_msg = serde_json::to_string(&WsMessage::PlayerLeave {
                id: token.clone(),
            }).unwrap();
            let _ = room.tx.send(leave_msg);

            // if host leaves or <2 players remain in a party game, end it
            let should_end_party = matches!(room.mode, RoomMode::Party)
                && room.started
                && (is_creator || room.players.len() < 2);
            if should_end_party {
                if let Some(party) = room.party.as_mut() {
                    if !matches!(party.phase, PartyPhase::Finished) {
                        party.phase = PartyPhase::Finished;
                        let score_list: Vec<PartyScoreInfo> = room.players.values().map(|p| {
                            PartyScoreInfo {
                                id: p.id.clone(), name: p.name.clone(), color: p.color.clone(),
                                score: party.scores.get(&p.id).copied().unwrap_or(0),
                            }
                        }).collect();
                        let msg = serde_json::to_string(&WsMessage::PartyFinal { scores: score_list }).unwrap();
                        let _ = room.tx.send(msg);
                    }
                }
            }

            // if the leaving player had the turn, advance
            if was_current && room.started && !room.turn_order.is_empty() && !matches!(room.mode, RoomMode::Party) {
                let idx = room.current_turn % room.turn_order.len();
                let id = room.turn_order[idx].clone();
                let name = room.players.get(&id).map(|p| p.name.clone()).unwrap_or_default();
                let color = room.players.get(&id).map(|p| p.color.clone()).unwrap_or_default();
                let turn_msg = serde_json::to_string(&WsMessage::TurnChange {
                    id, name, color, turn_number: room.current_turn,
                }).unwrap();
                let _ = room.tx.send(turn_msg);
            }

            if room.players.is_empty() {
                rooms.remove(&code);
            }
        }
    }
}

// -- cleanup --

pub fn start_room_cleanup(rooms: Rooms) {
    tokio::spawn(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(300)).await;
            let mut rooms = rooms.write().await;
            let now = std::time::Instant::now();
            rooms.retain(|_, room| {
                now.duration_since(room.last_activity).as_secs() < 7200
            });
        }
    });
}
