use axum::{
    extract::{Path, Query, State},
    http::{header, HeaderValue, Method, StatusCode},
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{
    collections::{BTreeMap, HashMap, HashSet},
    env, fs,
    net::{IpAddr, Ipv4Addr, SocketAddr},
    path::{Path as FsPath, PathBuf},
    sync::{Arc, Mutex},
    time::Duration,
};
use tokio::net::TcpListener;
use tower_http::{
    cors::{AllowOrigin, CorsLayer},
    trace::TraceLayer,
};

const MAX_TEAM_NAME_LENGTH: usize = 80;
const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);

struct AppState {
    battles_db: PathBuf,
    teams_db: PathBuf,
    tournaments_db: PathBuf,
    leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
    vehicle_names: HashMap<String, HashMap<String, String>>,
    vehicle_icons: HashMap<String, String>,
}

struct CachedLeaderboard {
    teams: Vec<TeamLeaderboardRow>,
}

#[derive(Debug)]
struct ApiError {
    status: StatusCode,
    message: String,
}

impl ApiError {
    fn bad_request(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::BAD_REQUEST,
            message: message.into(),
        }
    }

    fn not_found(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::NOT_FOUND,
            message: message.into(),
        }
    }

    fn internal(message: impl Into<String>) -> Self {
        Self {
            status: StatusCode::INTERNAL_SERVER_ERROR,
            message: message.into(),
        }
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        (self.status, Json(json!({ "error": self.message }))).into_response()
    }
}

type ApiResult<T> = Result<Json<T>, ApiError>;

#[derive(Deserialize)]
struct LimitQuery {
    limit: Option<u32>,
}

#[derive(Deserialize)]
struct LangQuery {
    lang: Option<String>,
}

#[derive(Deserialize)]
struct ResolveQuery {
    name: String,
}

#[derive(Deserialize)]
struct SearchQuery {
    q: Option<String>,
    name: Option<String>,
    limit: Option<u32>,
}

#[derive(Serialize)]
struct HealthResponse {
    ok: bool,
    service: &'static str,
    battles_db: String,
    teams_db: String,
    databases: BTreeMap<&'static str, bool>,
}

#[derive(Clone, Serialize)]
struct LeaderboardResponse {
    teams: Vec<TeamLeaderboardRow>,
}

#[derive(Serialize)]
struct PlayerLeaderboardResponse {
    players: Vec<PlayerLeaderboardRow>,
}

#[derive(Serialize)]
struct RecentGamesResponse {
    matches: Vec<GameRow>,
}

#[derive(Serialize)]
struct SearchResponse {
    teams: Vec<TeamSearchRow>,
}

#[derive(Serialize)]
struct ResolveResponse {
    team_id: i64,
    name: String,
}

#[derive(Serialize)]
struct TeamSearchRow {
    team_id: i64,
    name: String,
    members: i64,
}

#[derive(Clone, Serialize)]
struct TeamLeaderboardRow {
    team_id: i64,
    name: String,
    player_count: i64,
    total_battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
    total_kills: i64,
}

#[derive(Serialize)]
struct PlayerLeaderboardRow {
    uid: String,
    nick: Option<String>,
    total_battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
    ground_kills: i64,
    air_kills: i64,
    total_kills: i64,
    assists: i64,
    captures: i64,
    deaths: i64,
    score: i64,
    kdr: f64,
    teams_seen: i64,
    last_seen: i64,
}

#[derive(Serialize)]
struct TeamDetail {
    team_id: i64,
    name: String,
    members: i64,
    captain_uid: Option<String>,
    data_set: &'static str,
    team_summary: TeamSummary,
    players: Vec<PlayerSummary>,
}

#[derive(Default, Serialize)]
struct TeamSummary {
    player_count: i64,
    total_battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
    kdr: f64,
    total_kills: i64,
    total_points: i64,
}

#[derive(Serialize)]
struct PlayerSummary {
    uid: String,
    nick: Option<String>,
    role: String,
    total_battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
    total_kills: i64,
    ground_kills: i64,
    air_kills: i64,
    assists: i64,
    deaths: i64,
    kdr: f64,
}

#[derive(Serialize)]
struct HistoryResponse {
    team_id: i64,
    name: String,
    history: Vec<PeriodHistory>,
}

#[derive(Serialize)]
struct PeriodHistory {
    period: String,
    battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
}

#[derive(Serialize)]
struct GamesResponse {
    team_id: i64,
    name: String,
    games: Vec<GameRow>,
}

#[derive(Serialize)]
struct GameResponse {
    game: GameRow,
    participants: Vec<GameParticipant>,
}

#[derive(Serialize)]
struct TournamentsResponse {
    tournaments: Vec<TournamentSummary>,
}

#[derive(Serialize)]
struct TournamentSummary {
    tournament_id: i64,
    name: Option<String>,
    format: Option<String>,
    status: Option<String>,
    match_count: i64,
    team_count: i64,
    date_start: Option<i64>,
    date_end: Option<i64>,
}

#[derive(Serialize)]
struct TournamentDetailResponse {
    tournament_id: i64,
    name: Option<String>,
    format: Option<String>,
    status: Option<String>,
    match_count: i64,
    team_count: i64,
    date_start: Option<i64>,
    date_end: Option<i64>,
    matches: Vec<TournamentMatchRow>,
    standings: Vec<TournamentStandingRow>,
}

#[derive(Serialize)]
struct TournamentMatchRow {
    match_id: String,
    type_bracket: String,
    side: Option<String>,
    round: Option<i64>,
    position: Option<i64>,
    team_a_name: Option<String>,
    team_b_name: Option<String>,
    winner_name: Option<String>,
    score_a: i64,
    score_b: i64,
    status: String,
    battles: Vec<TournamentBattleRow>,
}

#[derive(Serialize)]
struct TournamentBattleRow {
    session_hex: String,
    position: Option<i64>,
    have_replay: bool,
}

#[derive(Serialize)]
struct TournamentStandingRow {
    group_index: i64,
    team_name: Option<String>,
    points: i64,
    wins: i64,
    draws: i64,
    losses: i64,
    buchholz: f64,
    rank: Option<i64>,
}

#[derive(Serialize)]
struct GameLogsResponse {
    chat_log: Vec<String>,
    battle_log: Vec<String>,
    event_log: Value,
}

#[derive(Serialize)]
struct GameRow {
    #[serde(skip_serializing_if = "Option::is_none")]
    team_name: Option<String>,
    session_id: String,
    timestamp: i64,
    endtime_unix: i64,
    map_name: Option<String>,
    mission_mode: Option<String>,
    result: String,
    player_count: i64,
    winning_team: Option<String>,
    losing_team: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tournament_id: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    tournament_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    duration: Option<f64>,
    draw: bool,
    stats: GameStats,
}

#[derive(Serialize)]
struct GameStats {
    ground_kills: i64,
    air_kills: i64,
    assists: i64,
    captures: i64,
    deaths: i64,
    score: i64,
    missile_evades: i64,
    shell_interceptions: i64,
    team_kills_stat: i64,
}

#[derive(Serialize)]
struct GameParticipant {
    team_name: String,
    result: String,
    player_count: i64,
    stats: GameStats,
    players: Vec<GamePlayer>,
}

#[derive(Serialize)]
struct GamePlayer {
    uid: String,
    nick: Option<String>,
    vehicles: Vec<Vehicle>,
    stats: GameStats,
}

#[derive(Serialize)]
struct Vehicle {
    cdk: String,
    name: String,
    icon: String,
}

#[derive(Serialize)]
struct PlayerSearchResponse {
    players: Vec<PlayerRef>,
}

#[derive(Serialize)]
struct PlayerRef {
    uid: String,
    nick: Option<String>,
}

#[derive(Serialize)]
struct PlayerProfile {
    uid: String,
    nick: Option<String>,
    data_set: &'static str,
    career: PlayerCareer,
    teams: Vec<PlayerTeamRef>,
}

#[derive(Serialize)]
struct PlayerCareer {
    total_battles: i64,
    wins: i64,
    losses: i64,
    win_rate: f64,
    ground_kills: i64,
    air_kills: i64,
    total_kills: i64,
    assists: i64,
    captures: i64,
    deaths: i64,
    kdr: f64,
}

#[derive(Serialize)]
struct PlayerTeamRef {
    team_id: Option<i64>,
    team_name: Option<String>,
    games: i64,
    last_seen: i64,
}

struct TeamRecord {
    team_id: i64,
    name: String,
    members: i64,
    captain_uid: Option<String>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    load_root_env();
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .init();

    let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
    let port = env_u16("BACKEND_PORT")
        .or_else(|| env_u16("PORT"))
        .unwrap_or(6000);
    let state = Arc::new(AppState {
        battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
        teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"),
        tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.db"),
        leaderboard_cache: Mutex::new(None),
        vehicle_names: load_vehicle_names(&resolve_db_path(
            "VEHICLE_TRANSLATIONS_JSON",
            "vehicle_translations.json",
        )),
        vehicle_icons: load_vehicle_icons(&resolve_db_path(
            "VEHICLE_DATA_CACHE_JSON",
            "vehicle_data_cache.json",
        )),
    });
    tracing::info!(
        "loaded {} vehicle name maps, {} vehicle icons",
        state.vehicle_names.len(),
        state.vehicle_icons.len()
    );
    spawn_leaderboard_refresh(state.clone());

    let app = Router::new()
        .route("/health", get(health))
        .route("/api/tss/leaderboard/teams", get(leaderboard))
        .route("/api/tss/leaderboard/players", get(player_leaderboard))
        .route("/api/tss/games/recent", get(recent_games))
        .route("/api/tss/tournaments", get(tournaments))
        .route("/api/tss/tournaments/{tournament_id}", get(tournament_detail))
        .route("/api/tss/games/{session_id}", get(game_detail))
        .route("/api/tss/games/{session_id}/logs", get(game_logs))
        .route("/api/tss/teams/resolve", get(resolve_team))
        .route("/api/tss/teams/search", get(search_teams))
        .route("/api/tss/teams/{team}", get(team_detail))
        .route("/api/tss/teams/{team}/history", get(team_history))
        .route("/api/tss/teams/{team}/games", get(team_games))
        .route("/api/tss/players/resolve", get(resolve_player))
        .route("/api/tss/players/search", get(search_players))
        .route("/api/tss/player/{uid}", get(player_detail))
        .layer(
            CorsLayer::new()
                .allow_methods([Method::GET])
                .allow_origin(allowed_origins())
                .allow_headers([header::ACCEPT, header::CONTENT_TYPE]),
        )
        .layer(TraceLayer::new_for_http())
        .with_state(state);

    let addr = SocketAddr::from((host, port));
    let listener = TcpListener::bind(addr).await?;
    tracing::info!("tssbot backend listening on http://{}", addr);
    axum::serve(listener, app)
        .with_graceful_shutdown(shutdown_signal())
        .await?;
    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
}

async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
    let mut databases = BTreeMap::new();
    databases.insert(
        "battles",
        Connection::open_with_flags(
            &state.battles_db,
            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
        )
        .is_ok(),
    );
    databases.insert(
        "teams",
        Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
            .is_ok(),
    );

    Json(HealthResponse {
        ok: databases.values().all(|ok| *ok),
        service: "tssbot-backend",
        battles_db: state.battles_db.display().to_string(),
        teams_db: state.teams_db.display().to_string(),
        databases,
    })
}

async fn leaderboard(
    State(state): State<Arc<AppState>>,
    Query(query): Query<LimitQuery>,
) -> ApiResult<LeaderboardResponse> {
    let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100);
    if let Some(teams) = cached_leaderboard(&state, limit)? {
        return Ok(Json(LeaderboardResponse { teams }));
    }

    let teams = leaderboard_roster_rows(&state, limit)?;
    Ok(Json(LeaderboardResponse { teams }))
}

async fn player_leaderboard(
    State(state): State<Arc<AppState>>,
    Query(query): Query<LimitQuery>,
) -> ApiResult<PlayerLeaderboardResponse> {
    let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100));
    let battles_conn = open_db(&state.battles_db)?;
    let players = player_leaderboard_rows(&battles_conn, limit)?;
    Ok(Json(PlayerLeaderboardResponse { players }))
}

fn leaderboard_teams(state: &AppState, limit: usize) -> Result<Vec<TeamRecord>, ApiError> {
    let teams_conn = open_db(&state.teams_db)?;
    // Deduplicate teams by name across tournaments — pick the highest team_id
    // (most recent) per name for the roster count, but stats come from team_name.
    let mut stmt = teams_conn
        .prepare(
            "SELECT MAX(team_id), name, MAX(members), MAX(captain_uid)
             FROM teams_data
             GROUP BY name COLLATE NOCASE
             ORDER BY MAX(members) DESC, name COLLATE NOCASE ASC
             LIMIT ?1",
        )
        .map_err(db_error)?;

    let teams = stmt
        .query_map(params![limit as i64], |row| read_team_record(row))
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(teams)
}

fn leaderboard_roster_rows(
    state: &AppState,
    limit: usize,
) -> Result<Vec<TeamLeaderboardRow>, ApiError> {
    leaderboard_teams(state, limit).map(|teams| {
        teams
            .into_iter()
            .map(|team| TeamLeaderboardRow {
                team_id: team.team_id,
                name: team.name,
                player_count: team.members,
                total_battles: 0,
                wins: 0,
                losses: 0,
                win_rate: 0.0,
                total_kills: 0,
            })
            .collect()
    })
}

fn leaderboard_rows(state: &AppState, limit: usize) -> Result<Vec<TeamLeaderboardRow>, ApiError> {
    let battles_conn = open_db(&state.battles_db)?;
    let teams = leaderboard_teams(state, limit)?;
    let team_names = teams
        .iter()
        .map(|team| team.name.as_str())
        .collect::<Vec<_>>();
    let mut summaries = team_summaries_for(&battles_conn, &team_names)?;

    let mut rows = Vec::with_capacity(teams.len());
    for team in teams {
        let summary = summaries
            .remove(&team.name.to_ascii_lowercase())
            .unwrap_or_default();
        rows.push(TeamLeaderboardRow {
            team_id: team.team_id,
            name: team.name,
            player_count: team.members,
            total_battles: summary.total_battles,
            wins: summary.wins,
            losses: summary.losses,
            win_rate: summary.win_rate,
            total_kills: summary.total_kills,
        });
    }

    Ok(rows)
}

fn spawn_leaderboard_refresh(state: Arc<AppState>) {
    tokio::spawn(async move {
        loop {
            let refresh_state = state.clone();
            match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await {
                Ok(Ok(rows)) => {
                    if let Err(error) = cache_leaderboard(&state, &rows) {
                        tracing::warn!("could not cache leaderboard refresh: {}", error.message);
                    }
                }
                Ok(Err(error)) => {
                    tracing::warn!("could not refresh leaderboard cache: {}", error.message);
                }
                Err(error) => {
                    tracing::warn!("leaderboard refresh task failed: {}", error);
                }
            }
            tokio::time::sleep(LEADERBOARD_CACHE_TTL).await;
        }
    });
}

async fn recent_games(
    State(state): State<Arc<AppState>>,
    Query(query): Query<LimitQuery>,
) -> ApiResult<RecentGamesResponse> {
    let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100));
    let battles_conn = open_db(&state.battles_db)?;
    let matches = recent_games_for(&battles_conn, limit)?;
    Ok(Json(RecentGamesResponse { matches }))
}

async fn tournaments(State(state): State<Arc<AppState>>) -> ApiResult<TournamentsResponse> {
    let conn = open_db(&state.tournaments_db)?;
    let tournaments = tournaments_list(&conn)?;
    Ok(Json(TournamentsResponse { tournaments }))
}

async fn tournament_detail(
    State(state): State<Arc<AppState>>,
    Path(tournament_id): Path<String>,
) -> ApiResult<TournamentDetailResponse> {
    let tid = validate_tournament_id(&tournament_id)?;
    let conn = open_db(&state.tournaments_db)?;
    let summary = tournament_summary_for(&conn, tid)?
        .ok_or_else(|| ApiError::not_found("Tournament not found"))?;
    let standings = tournament_standings_for(&conn, tid)?;
    let mut matches = tournament_match_rows_for(&conn, tid)?;
    attach_battles(&conn, &state.battles_db, tid, &mut matches)?;

    Ok(Json(TournamentDetailResponse {
        tournament_id: summary.tournament_id,
        name: summary.name,
        format: summary.format,
        status: summary.status,
        match_count: summary.match_count,
        team_count: summary.team_count,
        date_start: summary.date_start,
        date_end: summary.date_end,
        matches,
        standings,
    }))
}

async fn game_detail(
    State(state): State<Arc<AppState>>,
    Path(session_id): Path<String>,
    Query(query): Query<LangQuery>,
) -> ApiResult<GameResponse> {
    let session_id = validate_session_id(&session_id)?;
    let lang = query.lang.as_deref().unwrap_or("en");
    let battles_conn = open_db(&state.battles_db)?;
    let game = game_for(&battles_conn, session_id)?
        .ok_or_else(|| ApiError::not_found("Game not found"))?;
    let participants = game_participants_for(&battles_conn, session_id, &state, lang)?;
    Ok(Json(GameResponse { game, participants }))
}

async fn game_logs(
    State(state): State<Arc<AppState>>,
    Path(session_id): Path<String>,
) -> ApiResult<GameLogsResponse> {
    let session_id = validate_session_id(&session_id)?;
    let conn = open_db(&state.battles_db)?;
    // Logs are non-critical: a missing match_logs table or row yields empty arrays.
    let row: Option<(Option<String>, Option<String>, Option<String>)> = match conn
        .query_row(
            "SELECT chat_log_json, battle_log_json, event_log_json FROM match_logs WHERE session_id = ?1",
            params![session_id],
            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
        )
        .optional()
    {
        Ok(row) => row,
        Err(_) => conn
            .query_row(
                "SELECT chat_log_json, battle_log_json FROM match_logs WHERE session_id = ?1",
                params![session_id],
                |r| Ok((r.get(0)?, r.get(1)?, None)),
            )
            .optional()
            .unwrap_or(None),
    };
    let parse = |s: Option<String>| -> Vec<String> {
        s.and_then(|t| serde_json::from_str(&t).ok())
            .unwrap_or_default()
    };
    let parse_event_log = |s: Option<String>| -> Value {
        s.and_then(|t| serde_json::from_str(&t).ok())
            .unwrap_or_else(|| json!({ "kills": [], "damage": [] }))
    };
    let (chat, battle, event_log) = row.unwrap_or((None, None, None));
    Ok(Json(GameLogsResponse {
        chat_log: parse(chat),
        battle_log: parse(battle),
        event_log: parse_event_log(event_log),
    }))
}

async fn resolve_team(
    State(state): State<Arc<AppState>>,
    Query(query): Query<ResolveQuery>,
) -> ApiResult<ResolveResponse> {
    let name = validate_team_name(&query.name)?;
    let conn = open_db(&state.teams_db)?;
    let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
    Ok(Json(ResolveResponse {
        team_id: team.team_id,
        name: team.name,
    }))
}

async fn search_teams(
    State(state): State<Arc<AppState>>,
    Query(query): Query<SearchQuery>,
) -> ApiResult<SearchResponse> {
    let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or("");
    let name = validate_team_name(raw)?;
    let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20));
    let like = format!("%{}%", escape_like(name));
    let conn = open_db(&state.teams_db)?;
    let mut stmt = conn
        .prepare(
            "SELECT team_id, name, members
             FROM teams_data
             WHERE name LIKE ?1 ESCAPE '\\'
             ORDER BY
               CASE WHEN name = ?2 COLLATE NOCASE THEN 0 ELSE 1 END,
               members DESC,
               name COLLATE NOCASE ASC
             LIMIT ?3",
        )
        .map_err(db_error)?;

    let teams = stmt
        .query_map(params![like, name, limit], |row| {
            Ok(TeamSearchRow {
                team_id: row.get(0)?,
                name: row.get(1)?,
                members: row.get(2)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    Ok(Json(SearchResponse { teams }))
}

async fn team_detail(
    State(state): State<Arc<AppState>>,
    Path(team_name): Path<String>,
) -> ApiResult<TeamDetail> {
    let decoded = decode_path_team(&team_name)?;
    let teams_conn = open_db(&state.teams_db)?;
    let battles_conn = open_db(&state.battles_db)?;
    let team =
        find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
    let summary = team_summary_for(&battles_conn, &team.name)?;
    // team_id is the most recent tournament entry — used only for the roster lookup.
    let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id, &team.name)?;

    Ok(Json(TeamDetail {
        team_id: team.team_id,
        name: team.name,
        members: team.members,
        captain_uid: team.captain_uid,
        data_set: "tss",
        team_summary: summary,
        players,
    }))
}

async fn team_history(
    State(state): State<Arc<AppState>>,
    Path(team_name): Path<String>,
) -> ApiResult<HistoryResponse> {
    let decoded = decode_path_team(&team_name)?;
    let teams_conn = open_db(&state.teams_db)?;
    let battles_conn = open_db(&state.battles_db)?;
    let team =
        find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;

    let history = period_history_for(&battles_conn, &team.name)?;

    Ok(Json(HistoryResponse {
        team_id: team.team_id,
        name: team.name,
        history,
    }))
}

async fn team_games(
    State(state): State<Arc<AppState>>,
    Path(team_name): Path<String>,
) -> ApiResult<GamesResponse> {
    let decoded = decode_path_team(&team_name)?;
    let teams_conn = open_db(&state.teams_db)?;
    let battles_conn = open_db(&state.battles_db)?;
    let team =
        find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
    let games = games_for(&battles_conn, &team.name)?;

    Ok(Json(GamesResponse {
        team_id: team.team_id,
        name: team.name,
        games,
    }))
}

async fn resolve_player(
    State(state): State<Arc<AppState>>,
    Query(query): Query<ResolveQuery>,
) -> ApiResult<PlayerSearchResponse> {
    let name = validate_player_name(&query.name)?;
    let conn = open_db(&state.battles_db)?;
    let players = player_resolve(&conn, name)?;
    if players.is_empty() {
        return Err(ApiError::not_found("Player not found"));
    }
    Ok(Json(PlayerSearchResponse { players }))
}

async fn search_players(
    State(state): State<Arc<AppState>>,
    Query(query): Query<SearchQuery>,
) -> ApiResult<PlayerSearchResponse> {
    let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or("");
    let name = validate_player_name(raw)?;
    let limit = i64::from(query.limit.unwrap_or(25).clamp(1, 25));
    let conn = open_db(&state.battles_db)?;
    let players = player_search(&conn, name, limit)?;
    Ok(Json(PlayerSearchResponse { players }))
}

async fn player_detail(
    State(state): State<Arc<AppState>>,
    Path(uid): Path<String>,
) -> ApiResult<PlayerProfile> {
    let uid = validate_uid(&uid)?;
    let conn = open_db(&state.battles_db)?;
    let career =
        player_career_for(&conn, &uid)?.ok_or_else(|| ApiError::not_found("Player not found"))?;
    let nick = latest_nick_for(&conn, &uid)?;
    let teams = player_teams_for(&conn, &uid)?;
    Ok(Json(PlayerProfile {
        uid,
        nick,
        data_set: "tss",
        career,
        teams,
    }))
}

fn open_db(path: &FsPath) -> Result<Connection, ApiError> {
    Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
        ApiError::internal(format!("Could not open {}: {}", path.display(), error))
    })
}

fn db_error(error: rusqlite::Error) -> ApiError {
    ApiError::internal(format!("Database query failed: {}", error))
}

fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TeamRecord> {
    Ok(TeamRecord {
        team_id: row.get(0)?,
        name: row.get(1)?,
        members: row.get(2)?,
        captain_uid: row.get(3)?,
    })
}

fn find_team(conn: &Connection, name: &str) -> Result<Option<TeamRecord>, ApiError> {
    // Return the most recent tournament entry for this team (highest team_id).
    conn.query_row(
        "SELECT team_id, name, members, captain_uid
         FROM teams_data
         WHERE name = ?2 COLLATE NOCASE OR team_id = ?1
         ORDER BY team_id DESC
         LIMIT 1",
        params![name.parse::<i64>().ok(), name],
        read_team_record,
    )
    .optional()
    .map_err(db_error)
}

fn team_summary_for(conn: &Connection, team_name: &str) -> Result<TeamSummary, ApiError> {
    conn.query_row(
        "SELECT
            COUNT(DISTINCT session_id),
            COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
            COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END),
            COALESCE(SUM(ground_kills), 0),
            COALESCE(SUM(air_kills), 0),
            COALESCE(SUM(assists), 0),
            COALESCE(SUM(deaths), 0),
            COALESCE(SUM(score), 0),
            COUNT(DISTINCT UID)
         FROM player_games_hist
         WHERE team_name = ?1 COLLATE NOCASE",
        params![team_name],
        |row| {
            let battles: i64 = row.get(0)?;
            let wins: i64 = row.get(1)?;
            let losses: i64 = row.get(2)?;
            let ground: i64 = row.get(3)?;
            let air: i64 = row.get(4)?;
            let assists: i64 = row.get(5)?;
            let deaths: i64 = row.get(6)?;
            let score: i64 = row.get(7)?;
            let player_count: i64 = row.get(8)?;
            let total_kills = ground + air;
            Ok(TeamSummary {
                player_count,
                total_battles: battles,
                wins,
                losses,
                win_rate: percent(wins, battles),
                kdr: ratio(total_kills, deaths),
                total_kills,
                total_points: score + assists + total_kills,
            })
        },
    )
    .map_err(db_error)
}

fn team_summaries_for(
    conn: &Connection,
    team_names: &[&str],
) -> Result<HashMap<String, TeamSummary>, ApiError> {
    if team_names.is_empty() {
        return Ok(HashMap::new());
    }

    let placeholders = std::iter::repeat("?")
        .take(team_names.len())
        .collect::<Vec<_>>()
        .join(", ");
    let sql = format!(
        "SELECT
            team_name,
            COUNT(DISTINCT session_id),
            COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
            COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END),
            COALESCE(SUM(ground_kills), 0),
            COALESCE(SUM(air_kills), 0),
            COALESCE(SUM(assists), 0),
            COALESCE(SUM(deaths), 0),
            COALESCE(SUM(score), 0),
            COUNT(DISTINCT UID)
         FROM player_games_hist
         WHERE team_name COLLATE NOCASE IN ({placeholders})
         GROUP BY team_name COLLATE NOCASE"
    );
    let mut stmt = conn.prepare(&sql).map_err(db_error)?;

    let summaries = stmt
        .query_map(rusqlite::params_from_iter(team_names.iter()), |row| {
            let name: String = row.get(0)?;
            let battles: i64 = row.get(1)?;
            let wins: i64 = row.get(2)?;
            let losses: i64 = row.get(3)?;
            let ground: i64 = row.get(4)?;
            let air: i64 = row.get(5)?;
            let assists: i64 = row.get(6)?;
            let deaths: i64 = row.get(7)?;
            let score: i64 = row.get(8)?;
            let player_count: i64 = row.get(9)?;
            let total_kills = ground + air;
            Ok((
                name.to_ascii_lowercase(),
                TeamSummary {
                    player_count,
                    total_battles: battles,
                    wins,
                    losses,
                    win_rate: percent(wins, battles),
                    kdr: ratio(total_kills, deaths),
                    total_kills,
                    total_points: score + assists + total_kills,
                },
            ))
        })
        .map_err(db_error)?
        .collect::<Result<HashMap<_, _>, _>>()
        .map_err(db_error)?;

    Ok(summaries)
}

fn cached_leaderboard(
    state: &AppState,
    limit: usize,
) -> Result<Option<Vec<TeamLeaderboardRow>>, ApiError> {
    let cache = state
        .leaderboard_cache
        .lock()
        .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?;
    Ok(cache.as_ref().and_then(|cached| {
        (cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect())
    }))
}

fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> {
    let mut cache = state
        .leaderboard_cache
        .lock()
        .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?;
    *cache = Some(CachedLeaderboard {
        teams: teams.to_vec(),
    });
    Ok(())
}

fn player_summaries_for(
    teams_conn: &Connection,
    battles_conn: &Connection,
    // team_id: roster for this specific tournament entry only
    team_id: i64,
    // team_name: cross-tournament stats key
    team_name: &str,
) -> Result<Vec<PlayerSummary>, ApiError> {
    let mut stmt = teams_conn
        .prepare(
            "SELECT uid, role
             FROM team_members
             WHERE team_id = ?1
             ORDER BY uid",
        )
        .map_err(db_error)?;
    let members = stmt
        .query_map(params![team_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    let mut stats_stmt = battles_conn
        .prepare(
            "SELECT
                UID,
                COUNT(DISTINCT session_id),
                COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
                COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END),
                COALESCE(SUM(ground_kills), 0),
                COALESCE(SUM(air_kills), 0),
                COALESCE(SUM(assists), 0),
                COALESCE(SUM(deaths), 0),
                (SELECT nick FROM player_games_hist
                 WHERE UID = p.UID AND nick IS NOT NULL
                 ORDER BY endtime_unix DESC LIMIT 1)
             FROM player_games_hist p
             WHERE team_name = ?1 COLLATE NOCASE
             GROUP BY UID",
        )
        .map_err(db_error)?;

    let mut summaries = stats_stmt
        .query_map(params![team_name], |row| {
            let uid: String = row.get(0)?;
            let battles: i64 = row.get(1)?;
            let wins: i64 = row.get(2)?;
            let losses: i64 = row.get(3)?;
            let ground: i64 = row.get(4)?;
            let air: i64 = row.get(5)?;
            let assists: i64 = row.get(6)?;
            let deaths: i64 = row.get(7)?;
            let nick: Option<String> = row.get(8)?;
            let total_kills = ground + air;
            Ok((
                uid.clone(),
                PlayerSummary {
                    uid,
                    nick,
                    role: String::new(),
                    total_battles: battles,
                    wins,
                    losses,
                    win_rate: percent(wins, battles),
                    total_kills,
                    ground_kills: ground,
                    air_kills: air,
                    assists,
                    deaths,
                    kdr: ratio(total_kills, deaths),
                },
            ))
        })
        .map_err(db_error)?
        .collect::<Result<HashMap<_, _>, _>>()
        .map_err(db_error)?;

    let mut out = Vec::with_capacity(members.len());
    for (uid, role) in members {
        let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary {
            uid,
            nick: None,
            role: String::new(),
            total_battles: 0,
            wins: 0,
            losses: 0,
            win_rate: 0.0,
            total_kills: 0,
            ground_kills: 0,
            air_kills: 0,
            assists: 0,
            deaths: 0,
            kdr: 0.0,
        });
        summary.role = role;
        out.push(summary);
    }

    out.sort_by(|a, b| b.total_battles.cmp(&a.total_battles));
    Ok(out)
}

fn player_leaderboard_rows(
    conn: &Connection,
    limit: i64,
) -> Result<Vec<PlayerLeaderboardRow>, ApiError> {
    let mut stmt = conn
        .prepare(
            "WITH per_session AS (
                SELECT
                    UID,
                    session_id,
                    MAX(victor_bool) AS victor_bool,
                    MAX(ground_kills) AS ground_kills,
                    MAX(air_kills) AS air_kills,
                    MAX(assists) AS assists,
                    MAX(captures) AS captures,
                    MAX(deaths) AS deaths,
                    MAX(score) AS score,
                    MAX(endtime_unix) AS endtime_unix,
                    MAX(team_name) AS team_name
                FROM player_games_hist
                WHERE UID IS NOT NULL AND UID != ''
                GROUP BY UID, session_id
             ),
             latest_names AS (
                SELECT UID, nick
                FROM (
                    SELECT
                        UID,
                        nick,
                        ROW_NUMBER() OVER (
                            PARTITION BY UID
                            ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC
                        ) AS rn
                    FROM player_games_hist
                    WHERE nick IS NOT NULL AND nick != ''
                )
                WHERE rn = 1
             )
             SELECT
                p.UID,
                n.nick,
                COUNT(*) AS battles,
                COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins,
                COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses,
                COALESCE(SUM(p.ground_kills), 0) AS ground_kills,
                COALESCE(SUM(p.air_kills), 0) AS air_kills,
                COALESCE(SUM(p.assists), 0) AS assists,
                COALESCE(SUM(p.captures), 0) AS captures,
                COALESCE(SUM(p.deaths), 0) AS deaths,
                COALESCE(SUM(p.score), 0) AS score,
                COUNT(DISTINCT p.team_name) AS teams_seen,
                MAX(p.endtime_unix) AS last_seen
             FROM per_session p
             LEFT JOIN latest_names n ON n.UID = p.UID
             GROUP BY p.UID
             ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC
             LIMIT ?1",
        )
        .map_err(db_error)?;

    let players = stmt
        .query_map(params![limit], |row| {
            let ground: i64 = row.get(5)?;
            let air: i64 = row.get(6)?;
            let battles: i64 = row.get(2)?;
            let wins: i64 = row.get(3)?;
            let deaths: i64 = row.get(9)?;
            let total_kills = ground + air;
            Ok(PlayerLeaderboardRow {
                uid: row.get(0)?,
                nick: row.get(1)?,
                total_battles: battles,
                wins,
                losses: row.get(4)?,
                win_rate: percent(wins, battles),
                ground_kills: ground,
                air_kills: air,
                total_kills,
                assists: row.get(7)?,
                captures: row.get(8)?,
                deaths,
                score: row.get(10)?,
                kdr: ratio(total_kills, deaths),
                teams_seen: row.get(11)?,
                last_seen: row.get(12)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    Ok(players)
}

fn player_search(conn: &Connection, query: &str, limit: i64) -> Result<Vec<PlayerRef>, ApiError> {
    let like = format!("%{}%", escape_like(query));
    let mut stmt = conn
        .prepare(
            "SELECT UID, MIN(nick) AS nick, MAX(endtime_unix) AS last_seen
             FROM player_games_hist
             WHERE nick LIKE ?1 ESCAPE '\\' COLLATE NOCASE
             GROUP BY UID
             ORDER BY
               CASE WHEN MIN(nick) = ?2 COLLATE NOCASE THEN 0 ELSE 1 END,
               last_seen DESC
             LIMIT ?3",
        )
        .map_err(db_error)?;
    let players = stmt
        .query_map(params![like, query, limit], |row| {
            Ok(PlayerRef {
                uid: row.get(0)?,
                nick: row.get(1)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(players)
}

fn player_resolve(conn: &Connection, name: &str) -> Result<Vec<PlayerRef>, ApiError> {
    // Exact nick match first; fall back to substring search.
    let mut stmt = conn
        .prepare(
            "SELECT UID, MIN(nick) AS nick
             FROM player_games_hist
             WHERE nick = ?1 COLLATE NOCASE
             GROUP BY UID
             ORDER BY nick
             LIMIT 25",
        )
        .map_err(db_error)?;
    let exact = stmt
        .query_map(params![name], |row| {
            Ok(PlayerRef {
                uid: row.get(0)?,
                nick: row.get(1)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    if !exact.is_empty() {
        return Ok(exact);
    }
    player_search(conn, name, 25)
}

fn latest_nick_for(conn: &Connection, uid: &str) -> Result<Option<String>, ApiError> {
    conn.query_row(
        "SELECT nick FROM player_games_hist WHERE UID = ?1 ORDER BY endtime_unix DESC LIMIT 1",
        params![uid],
        |row| row.get::<_, Option<String>>(0),
    )
    .optional()
    .map_err(db_error)
    .map(|opt| opt.flatten())
}

fn player_career_for(conn: &Connection, uid: &str) -> Result<Option<PlayerCareer>, ApiError> {
    // Player totals repeat across a player's per-vehicle rows for a session, so
    // collapse to one value per session (MAX) before summing.
    conn.query_row(
        "SELECT
            COUNT(*) AS battles,
            COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'WIN'  THEN 1 ELSE 0 END), 0) AS wins,
            COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses,
            COALESCE(SUM(gk), 0), COALESCE(SUM(ak), 0),
            COALESCE(SUM(asi), 0), COALESCE(SUM(cap), 0), COALESCE(SUM(de), 0)
         FROM (
            SELECT session_id,
                   MAX(victor_bool)  AS victor_bool,
                   MAX(ground_kills) AS gk, MAX(air_kills) AS ak,
                   MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de
            FROM player_games_hist
            WHERE UID = ?1
            GROUP BY session_id
         )",
        params![uid],
        |row| {
            let battles: i64 = row.get(0)?;
            let wins: i64 = row.get(1)?;
            let losses: i64 = row.get(2)?;
            let ground: i64 = row.get(3)?;
            let air: i64 = row.get(4)?;
            let assists: i64 = row.get(5)?;
            let captures: i64 = row.get(6)?;
            let deaths: i64 = row.get(7)?;
            let total_kills = ground + air;
            Ok((
                battles,
                wins,
                losses,
                ground,
                air,
                assists,
                captures,
                deaths,
                total_kills,
            ))
        },
    )
    .optional()
    .map_err(db_error)
    .map(|opt| {
        opt.and_then(
            |(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| {
                if battles == 0 {
                    None
                } else {
                    Some(PlayerCareer {
                        total_battles: battles,
                        wins,
                        losses,
                        win_rate: percent(wins, battles),
                        ground_kills: ground,
                        air_kills: air,
                        total_kills,
                        assists,
                        captures,
                        deaths,
                        kdr: ratio(total_kills, deaths),
                    })
                }
            },
        )
    })
}

fn player_teams_for(conn: &Connection, uid: &str) -> Result<Vec<PlayerTeamRef>, ApiError> {
    let mut stmt = conn
        .prepare(
            "SELECT team_id, MAX(team_name) AS team_name,
                    COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen
             FROM player_games_hist
             WHERE UID = ?1 AND team_id IS NOT NULL
             GROUP BY team_id
             ORDER BY last_seen DESC",
        )
        .map_err(db_error)?;
    let teams = stmt
        .query_map(params![uid], |row| {
            Ok(PlayerTeamRef {
                team_id: row.get(0)?,
                team_name: row.get(1)?,
                games: row.get(2)?,
                last_seen: row.get(3)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(teams)
}

fn period_history_for(conn: &Connection, team_name: &str) -> Result<Vec<PeriodHistory>, ApiError> {
    let mut stmt = conn
        .prepare(
            "SELECT
                strftime('%Y-%m', endtime_unix, 'unixepoch') AS period,
                COUNT(DISTINCT session_id),
                COUNT(DISTINCT CASE WHEN victor_bool = 'Win' THEN session_id END),
                COUNT(DISTINCT CASE WHEN victor_bool != 'Win' THEN session_id END)
             FROM player_games_hist
             WHERE team_name = ?1 COLLATE NOCASE AND endtime_unix > 0
             GROUP BY period
             ORDER BY period ASC",
        )
        .map_err(db_error)?;

    let rows = stmt
        .query_map(params![team_name], |row| {
            let period: String = row.get(0)?;
            let battles: i64 = row.get(1)?;
            let wins: i64 = row.get(2)?;
            let losses: i64 = row.get(3)?;
            Ok(PeriodHistory {
                period,
                battles,
                wins,
                losses,
                win_rate: percent(wins, battles),
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(rows)
}

fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiError> {
    // Reduce per-vehicle duplicate rows to one row per UID (MAX) before summing.
    let mut stmt = conn
        .prepare(
            "WITH per_player AS (
                SELECT session_id, UID,
                    MAX(endtime_unix) AS endtime_unix,
                    MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won,
                    MAX(ground_kills) AS ground_kills,
                    MAX(air_kills) AS air_kills,
                    MAX(assists) AS assists,
                    MAX(captures) AS captures,
                    MAX(deaths) AS deaths,
                    MAX(score) AS score,
                    MAX(missile_evades) AS missile_evades,
                    MAX(shell_interceptions) AS shell_interceptions,
                    MAX(team_kills_stat) AS team_kills_stat
                FROM player_games_hist
                WHERE team_name = ?1 COLLATE NOCASE
                GROUP BY session_id, UID
             )
             SELECT
                pp.session_id,
                COALESCE(m.endtime_unix, MAX(pp.endtime_unix), 0) AS timestamp,
                m.mission_name,
                m.mission_mode,
                m.tournament_name,
                m.duration,
                COALESCE(m.draw, 0),
                CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
                COUNT(*),
                COALESCE(SUM(pp.ground_kills), 0),
                COALESCE(SUM(pp.air_kills), 0),
                COALESCE(SUM(pp.assists), 0),
                COALESCE(SUM(pp.captures), 0),
                COALESCE(SUM(pp.deaths), 0),
                COALESCE(SUM(pp.score), 0),
                COALESCE(SUM(pp.missile_evades), 0),
                COALESCE(SUM(pp.shell_interceptions), 0),
                COALESCE(SUM(pp.team_kills_stat), 0),
                (SELECT pg.team_name
                 FROM player_games_hist pg
                 WHERE pg.session_id = pp.session_id
                   AND pg.team_name IS NOT NULL
                   AND pg.team_name != ''
                   AND pg.victor_bool = 'Win'
                 GROUP BY pg.team_name COLLATE NOCASE
                 ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
                 LIMIT 1),
                (SELECT pg.team_name
                 FROM player_games_hist pg
                 WHERE pg.session_id = pp.session_id
                   AND pg.team_name IS NOT NULL
                   AND pg.team_name != ''
                   AND pg.victor_bool = 'Loss'
                 GROUP BY pg.team_name COLLATE NOCASE
                 ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
                 LIMIT 1)
             FROM per_player pp
             LEFT JOIN match_summary m ON m.session_id = pp.session_id
             GROUP BY pp.session_id
             ORDER BY timestamp DESC
             LIMIT 100",
        )
        .map_err(db_error)?;

    let rows = stmt
        .query_map(params![team_name], |row| {
            let session_id: String = row.get(0)?;
            let timestamp: i64 = row.get(1)?;
            let map_name: Option<String> = row.get(2)?;
            let mission_mode: Option<String> = row.get(3)?;
            let draw_int: i64 = row.get(6)?;
            Ok(GameRow {
                team_name: None,
                session_id,
                timestamp,
                endtime_unix: timestamp,
                map_name,
                mission_mode,
                result: row.get(7)?,
                player_count: row.get(8)?,
                winning_team: row.get(18)?,
                losing_team: row.get(19)?,
                tournament_id: None,
                tournament_name: row.get(4)?,
                duration: row.get(5)?,
                draw: draw_int != 0,
                stats: GameStats {
                    ground_kills: row.get(9)?,
                    air_kills: row.get(10)?,
                    assists: row.get(11)?,
                    captures: row.get(12)?,
                    deaths: row.get(13)?,
                    score: row.get(14)?,
                    missile_evades: row.get(15)?,
                    shell_interceptions: row.get(16)?,
                    team_kills_stat: row.get(17)?,
                },
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(rows)
}

fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiError> {
    // One row per SESSION (not per team) so the battle-logs list shows each game
    // once. Both team names come from the winner/loser subqueries; player_count is
    // the larger team's size so the "NvN" label is per-side.
    let mut stmt = conn
        .prepare(
            "WITH recent AS (
                SELECT session_id, MAX(endtime_unix) AS timestamp
                FROM player_games_hist
                WHERE team_name IS NOT NULL AND team_name != ''
                GROUP BY session_id
                ORDER BY timestamp DESC
                LIMIT ?1
             ),
             team_size AS (
                SELECT session_id, team_name, COUNT(DISTINCT UID) AS players
                FROM player_games_hist
                WHERE session_id IN (SELECT session_id FROM recent)
                  AND team_name IS NOT NULL AND team_name != ''
                GROUP BY session_id, team_name COLLATE NOCASE
             )
             SELECT
                r.session_id,
                COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp,
                m.mission_name,
                m.mission_mode,
                m.tournament_name,
                m.duration,
                COALESCE(m.draw, 0),
                (SELECT MAX(players) FROM team_size ts WHERE ts.session_id = r.session_id) AS player_count,
                (SELECT pg.team_name
                 FROM player_games_hist pg
                 WHERE pg.session_id = r.session_id
                   AND pg.team_name IS NOT NULL
                   AND pg.team_name != ''
                   AND pg.victor_bool = 'Win'
                 GROUP BY pg.team_name COLLATE NOCASE
                 ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
                 LIMIT 1),
                (SELECT pg.team_name
                 FROM player_games_hist pg
                 WHERE pg.session_id = r.session_id
                   AND pg.team_name IS NOT NULL
                   AND pg.team_name != ''
                   AND pg.victor_bool = 'Loss'
                 GROUP BY pg.team_name COLLATE NOCASE
                 ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
                 LIMIT 1)
             FROM recent r
             LEFT JOIN match_summary m ON m.session_id = r.session_id
             ORDER BY timestamp DESC
             LIMIT ?1",
        )
        .map_err(db_error)?;

    let rows = stmt
        .query_map(params![limit], |row| {
            let timestamp: i64 = row.get(1)?;
            let draw_int: i64 = row.get(6)?;
            Ok(GameRow {
                team_name: None,
                session_id: row.get(0)?,
                timestamp,
                endtime_unix: timestamp,
                map_name: row.get(2)?,
                mission_mode: row.get(3)?,
                result: String::new(),
                player_count: row.get(7)?,
                winning_team: row.get(8)?,
                losing_team: row.get(9)?,
                tournament_id: None,
                tournament_name: row.get(4)?,
                duration: row.get(5)?,
                draw: draw_int != 0,
                stats: GameStats {
                    ground_kills: 0,
                    air_kills: 0,
                    assists: 0,
                    captures: 0,
                    deaths: 0,
                    score: 0,
                    missile_evades: 0,
                    shell_interceptions: 0,
                    team_kills_stat: 0,
                },
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    Ok(rows)
}

fn tournaments_list(conn: &Connection) -> Result<Vec<TournamentSummary>, ApiError> {
    let mut stmt = conn
        .prepare(
            "SELECT tournament_id, name, format, status, match_count, team_count,
                    date_start, date_end
             FROM tournaments
             ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC
             LIMIT 500",
        )
        .map_err(db_error)?;
    let rows = stmt
        .query_map([], read_tournament_summary)
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(rows)
}

fn tournament_summary_for(
    conn: &Connection,
    tid: i64,
) -> Result<Option<TournamentSummary>, ApiError> {
    conn.query_row(
        "SELECT tournament_id, name, format, status, match_count, team_count,
                date_start, date_end
         FROM tournaments WHERE tournament_id = ?1",
        params![tid],
        read_tournament_summary,
    )
    .optional()
    .map_err(db_error)
}

fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<TournamentSummary> {
    Ok(TournamentSummary {
        tournament_id: row.get(0)?,
        name: row.get(1)?,
        format: row.get(2)?,
        status: row.get(3)?,
        match_count: row.get(4)?,
        team_count: row.get(5)?,
        date_start: row.get(6)?,
        date_end: row.get(7)?,
    })
}

fn tournament_match_rows_for(
    conn: &Connection,
    tid: i64,
) -> Result<Vec<TournamentMatchRow>, ApiError> {
    // Order: winner side, then final, then loser; group/swiss after. Within a
    // side, by round then position (the schedule's round/matchNumber). NULLs last.
    let mut stmt = conn
        .prepare(
            "SELECT match_id, type_bracket, side, round, position,
                    team_a_name, team_b_name, winner_name, score_a, score_b, status
             FROM tournament_matches
             WHERE tournament_id = ?1
             ORDER BY
                CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2
                          WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END,
                round IS NULL, round,
                position IS NULL, position,
                time_start, match_id",
        )
        .map_err(db_error)?;
    let rows = stmt
        .query_map(params![tid], |row| {
            Ok(TournamentMatchRow {
                match_id: row.get(0)?,
                type_bracket: row.get(1)?,
                side: row.get(2)?,
                round: row.get(3)?,
                position: row.get(4)?,
                team_a_name: row.get(5)?,
                team_b_name: row.get(6)?,
                winner_name: row.get(7)?,
                score_a: row.get(8)?,
                score_b: row.get(9)?,
                status: row.get(10)?,
                battles: Vec::new(),
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(rows)
}

fn tournament_standings_for(
    conn: &Connection,
    tid: i64,
) -> Result<Vec<TournamentStandingRow>, ApiError> {
    let mut stmt = conn
        .prepare(
            "SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank
             FROM tournament_standings
             WHERE tournament_id = ?1
             ORDER BY group_index, rank IS NULL, rank, points DESC",
        )
        .map_err(db_error)?;
    let rows = stmt
        .query_map(params![tid], |row| {
            Ok(TournamentStandingRow {
                group_index: row.get(0)?,
                team_name: row.get(1)?,
                points: row.get(2)?,
                wins: row.get(3)?,
                draws: row.get(4)?,
                losses: row.get(5)?,
                buchholz: row.get(6)?,
                rank: row.get(7)?,
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;
    Ok(rows)
}

// Attach each match's battles and mark which ones we actually hold a replay for
// (session_hex present in match_summary over in the battles DB).
fn attach_battles(
    conn: &Connection,
    battles_db: &FsPath,
    tid: i64,
    matches: &mut [TournamentMatchRow],
) -> Result<(), ApiError> {
    let mut index: HashMap<(String, String), usize> = HashMap::new();
    for (i, m) in matches.iter().enumerate() {
        index.insert((m.match_id.clone(), m.type_bracket.clone()), i);
    }

    let mut stmt = conn
        .prepare(
            "SELECT match_id, type_bracket, session_hex, position
             FROM tournament_battles
             WHERE tournament_id = ?1
             ORDER BY match_id, type_bracket, position IS NULL, position",
        )
        .map_err(db_error)?;
    let battles = stmt
        .query_map(params![tid], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, Option<i64>>(3)?,
            ))
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    let hexes: Vec<String> = battles.iter().map(|(_, _, h, _)| h.clone()).collect();
    let held = held_session_ids(battles_db, &hexes)?;

    for (match_id, type_bracket, session_hex, position) in battles {
        if let Some(&idx) = index.get(&(match_id, type_bracket)) {
            let have_replay = held.contains(&session_hex);
            matches[idx].battles.push(TournamentBattleRow {
                session_hex,
                position,
                have_replay,
            });
        }
    }
    Ok(())
}

fn held_session_ids(
    battles_db: &FsPath,
    hexes: &[String],
) -> Result<HashSet<String>, ApiError> {
    let mut held = HashSet::new();
    if hexes.is_empty() {
        return Ok(held);
    }
    let conn = open_db(battles_db)?;
    for chunk in hexes.chunks(400) {
        let placeholders = std::iter::repeat("?")
            .take(chunk.len())
            .collect::<Vec<_>>()
            .join(",");
        let sql = format!(
            "SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})"
        );
        let mut stmt = conn.prepare(&sql).map_err(db_error)?;
        let rows = stmt
            .query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
                row.get::<_, String>(0)
            })
            .map_err(db_error)?;
        for r in rows {
            held.insert(r.map_err(db_error)?);
        }
    }
    Ok(held)
}


fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> {
    // player_games_hist stores one row per used vehicle per player, with the
    // per-player stats duplicated across those rows. Reduce to one row per UID
    // (MAX, since the values are identical) BEFORE summing team/game totals.
    conn.query_row(
        "SELECT
            ?1 AS session_id,
            COALESCE(m.endtime_unix, agg.timestamp, 0) AS timestamp,
            m.mission_name,
            m.mission_mode,
            m.tournament_name,
            m.duration,
            COALESCE(m.draw, 0),
            agg.player_count,
            agg.ground_kills,
            agg.air_kills,
            agg.assists,
            agg.captures,
            agg.deaths,
            agg.score,
            agg.missile_evades,
            agg.shell_interceptions,
            agg.team_kills_stat,
            (SELECT pg.team_name
             FROM player_games_hist pg
             WHERE pg.session_id = ?1
               AND pg.team_name IS NOT NULL
               AND pg.team_name != ''
               AND pg.victor_bool = 'Win'
             GROUP BY pg.team_name COLLATE NOCASE
             ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
             LIMIT 1),
            (SELECT pg.team_name
             FROM player_games_hist pg
             WHERE pg.session_id = ?1
               AND pg.team_name IS NOT NULL
               AND pg.team_name != ''
               AND pg.victor_bool = 'Loss'
             GROUP BY pg.team_name COLLATE NOCASE
             ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
             LIMIT 1),
            m.tournament_id
         FROM (
            SELECT
                COUNT(*) AS player_count,
                MAX(endtime_unix) AS timestamp,
                COALESCE(SUM(ground_kills), 0) AS ground_kills,
                COALESCE(SUM(air_kills), 0) AS air_kills,
                COALESCE(SUM(assists), 0) AS assists,
                COALESCE(SUM(captures), 0) AS captures,
                COALESCE(SUM(deaths), 0) AS deaths,
                COALESCE(SUM(score), 0) AS score,
                COALESCE(SUM(missile_evades), 0) AS missile_evades,
                COALESCE(SUM(shell_interceptions), 0) AS shell_interceptions,
                COALESCE(SUM(team_kills_stat), 0) AS team_kills_stat
            FROM (
                SELECT UID,
                    MAX(endtime_unix) AS endtime_unix,
                    MAX(ground_kills) AS ground_kills,
                    MAX(air_kills) AS air_kills,
                    MAX(assists) AS assists,
                    MAX(captures) AS captures,
                    MAX(deaths) AS deaths,
                    MAX(score) AS score,
                    MAX(missile_evades) AS missile_evades,
                    MAX(shell_interceptions) AS shell_interceptions,
                    MAX(team_kills_stat) AS team_kills_stat
                FROM player_games_hist
                WHERE session_id = ?1
                GROUP BY UID
            )
         ) agg
         LEFT JOIN match_summary m ON m.session_id = ?1
         WHERE agg.player_count > 0",
        params![session_id],
        |row| {
            let timestamp: i64 = row.get(1)?;
            let draw_int: i64 = row.get(6)?;
            Ok(GameRow {
                team_name: None,
                session_id: row.get(0)?,
                timestamp,
                endtime_unix: timestamp,
                map_name: row.get(2)?,
                mission_mode: row.get(3)?,
                result: String::new(),
                player_count: row.get(7)?,
                winning_team: row.get(17)?,
                losing_team: row.get(18)?,
                tournament_id: row.get(19)?,
                tournament_name: row.get(4)?,
                duration: row.get(5)?,
                draw: draw_int != 0,
                stats: GameStats {
                    ground_kills: row.get(8)?,
                    air_kills: row.get(9)?,
                    assists: row.get(10)?,
                    captures: row.get(11)?,
                    deaths: row.get(12)?,
                    score: row.get(13)?,
                    missile_evades: row.get(14)?,
                    shell_interceptions: row.get(15)?,
                    team_kills_stat: row.get(16)?,
                },
            })
        },
    )
    .optional()
    .map_err(db_error)
}

fn game_participants_for(
    conn: &Connection,
    session_id: &str,
    state: &AppState,
    lang: &str,
) -> Result<Vec<GameParticipant>, ApiError> {
    let mut stmt = conn
        .prepare(
            "SELECT
                pp.team_name,
                CASE WHEN MAX(pp.won) = 1 THEN 'Win' ELSE 'Loss' END AS result,
                COUNT(*),
                COALESCE(SUM(pp.ground_kills), 0),
                COALESCE(SUM(pp.air_kills), 0),
                COALESCE(SUM(pp.assists), 0),
                COALESCE(SUM(pp.captures), 0),
                COALESCE(SUM(pp.deaths), 0),
                COALESCE(SUM(pp.score), 0),
                COALESCE(SUM(pp.missile_evades), 0),
                COALESCE(SUM(pp.shell_interceptions), 0),
                COALESCE(SUM(pp.team_kills_stat), 0)
             FROM (
                SELECT UID, team_name,
                    MAX(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END) AS won,
                    MAX(ground_kills) AS ground_kills,
                    MAX(air_kills) AS air_kills,
                    MAX(assists) AS assists,
                    MAX(captures) AS captures,
                    MAX(deaths) AS deaths,
                    MAX(score) AS score,
                    MAX(missile_evades) AS missile_evades,
                    MAX(shell_interceptions) AS shell_interceptions,
                    MAX(team_kills_stat) AS team_kills_stat
                FROM player_games_hist
                WHERE session_id = ?1 AND team_name IS NOT NULL AND team_name != ''
                GROUP BY UID, team_name COLLATE NOCASE
             ) pp
             GROUP BY pp.team_name COLLATE NOCASE
             ORDER BY
               CASE WHEN MAX(pp.won) = 1 THEN 0 ELSE 1 END,
               pp.team_name COLLATE NOCASE",
        )
        .map_err(db_error)?;

    let mut participants = stmt
        .query_map(params![session_id], |row| {
            Ok(GameParticipant {
                team_name: row.get(0)?,
                result: row.get(1)?,
                player_count: row.get(2)?,
                stats: GameStats {
                    ground_kills: row.get(3)?,
                    air_kills: row.get(4)?,
                    assists: row.get(5)?,
                    captures: row.get(6)?,
                    deaths: row.get(7)?,
                    score: row.get(8)?,
                    missile_evades: row.get(9)?,
                    shell_interceptions: row.get(10)?,
                    team_kills_stat: row.get(11)?,
                },
                players: Vec::new(),
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    for participant in &mut participants {
        participant.players =
            game_players_for(conn, session_id, &participant.team_name, state, lang)?;
    }

    Ok(participants)
}

fn game_players_for(
    conn: &Connection,
    session_id: &str,
    team_name: &str,
    state: &AppState,
    lang: &str,
) -> Result<Vec<GamePlayer>, ApiError> {
    // Stats are duplicated across a player's per-vehicle rows, so take MAX (not
    // SUM) per UID. vehicle_internal collected (DISTINCT, comma-joined) for the lineup.
    let mut stmt = conn
        .prepare(
            "SELECT
                p.UID,
                (SELECT pg.nick
                 FROM player_games_hist pg
                 WHERE pg.session_id = p.session_id
                   AND pg.UID = p.UID
                   AND pg.nick IS NOT NULL
                   AND pg.nick != ''
                 ORDER BY pg.endtime_unix DESC
                 LIMIT 1),
                MAX(p.ground_kills),
                MAX(p.air_kills),
                MAX(p.assists),
                MAX(p.captures),
                MAX(p.deaths),
                MAX(p.score),
                MAX(p.missile_evades),
                MAX(p.shell_interceptions),
                MAX(p.team_kills_stat),
                GROUP_CONCAT(DISTINCT p.vehicle_internal)
             FROM player_games_hist p
             WHERE p.session_id = ?1 AND p.team_name = ?2 COLLATE NOCASE
             GROUP BY p.UID
             ORDER BY MAX(p.score) DESC, p.UID",
        )
        .map_err(db_error)?;

    let players = stmt
        .query_map(params![session_id, team_name], |row| {
            let cdks: Option<String> = row.get(11)?;
            let vehicles = cdks
                .unwrap_or_default()
                .split(',')
                .map(|c| c.trim())
                .filter(|c| !c.is_empty())
                .map(|cdk| Vehicle {
                    name: lookup_vehicle_name(&state.vehicle_names, cdk, lang),
                    icon: lookup_vehicle_icon(&state.vehicle_icons, cdk),
                    cdk: cdk.to_string(),
                })
                .collect();
            Ok(GamePlayer {
                uid: row.get(0)?,
                nick: row.get(1)?,
                vehicles,
                stats: GameStats {
                    ground_kills: row.get(2)?,
                    air_kills: row.get(3)?,
                    assists: row.get(4)?,
                    captures: row.get(5)?,
                    deaths: row.get(6)?,
                    score: row.get(7)?,
                    missile_evades: row.get(8)?,
                    shell_interceptions: row.get(9)?,
                    team_kills_stat: row.get(10)?,
                },
            })
        })
        .map_err(db_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(db_error)?;

    Ok(players)
}

fn validate_team_name(name: &str) -> Result<&str, ApiError> {
    let trimmed = name.trim();
    if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
        return Err(ApiError::bad_request(
            "Team name must be 2 to 80 characters",
        ));
    }
    Ok(trimmed)
}

fn validate_player_name(name: &str) -> Result<&str, ApiError> {
    let trimmed = name.trim();
    if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
        return Err(ApiError::bad_request(
            "Player name must be 2 to 80 characters",
        ));
    }
    Ok(trimmed)
}

fn validate_uid(value: &str) -> Result<String, ApiError> {
    let trimmed = value.trim();
    if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) {
        return Err(ApiError::bad_request("Invalid player UID"));
    }
    Ok(trimmed.to_string())
}

fn validate_tournament_id(value: &str) -> Result<i64, ApiError> {
    let trimmed = value.trim();
    match trimmed.parse::<i64>() {
        Ok(id) if id > 0 => Ok(id),
        _ => Err(ApiError::bad_request("Invalid tournament ID")),
    }
}

fn validate_session_id(value: &str) -> Result<&str, ApiError> {
    if value.is_empty()
        || value.len() > 96
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
    {
        return Err(ApiError::bad_request("Invalid game ID"));
    }

    Ok(value)
}

fn decode_path_team(value: &str) -> Result<String, ApiError> {
    let decoded = urlencoding::decode(value)
        .map_err(|_| ApiError::bad_request("Invalid team name"))?
        .into_owned();
    validate_team_name(&decoded)?;
    Ok(decoded)
}

fn escape_like(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('%', "\\%")
        .replace('_', "\\_")
}

fn percent(part: i64, total: i64) -> f64 {
    if total <= 0 {
        0.0
    } else {
        (part as f64 / total as f64) * 100.0
    }
}

fn ratio(top: i64, bottom: i64) -> f64 {
    if bottom <= 0 {
        top as f64
    } else {
        top as f64 / bottom as f64
    }
}

fn env_u16(key: &str) -> Option<u16> {
    env::var(key).ok()?.parse().ok()
}

fn env_ip(key: &str) -> Option<IpAddr> {
    env::var(key).ok()?.parse().ok()
}

fn allowed_origins() -> AllowOrigin {
    let origins = env::var("BACKEND_ALLOWED_ORIGINS")
        .or_else(|_| env::var("PUBLIC_ORIGIN"))
        .unwrap_or_default()
        .split(',')
        .filter_map(|origin| {
            let origin = origin.trim();
            if origin.is_empty() {
                return None;
            }
            HeaderValue::from_str(origin).ok()
        })
        .collect::<Vec<_>>();

    if origins.is_empty() {
        AllowOrigin::list(Vec::<HeaderValue>::new())
    } else {
        AllowOrigin::list(origins)
    }
}

// Vehicle cdk keys are lowercased on load and at lookup time so DB casing
// (e.g. "us_M4A2_76W_sherman" vs "us_m4a2_76w_sherman") never misses, mirroring
// the Python LangTableReader's case-insensitive behaviour.
fn load_vehicle_names(path: &FsPath) -> HashMap<String, HashMap<String, String>> {
    let parsed: HashMap<String, HashMap<String, String>> = match fs::read_to_string(path) {
        Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
        Err(_) => HashMap::new(),
    };
    parsed
        .into_iter()
        .map(|(k, v)| (k.to_lowercase(), v))
        .collect()
}

fn load_vehicle_icons(path: &FsPath) -> HashMap<String, String> {
    // vehicle_data_cache_all.json is [[cdk, name, icon, tags], ...]
    let raw: Vec<serde_json::Value> = match fs::read_to_string(path) {
        Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
        Err(_) => Vec::new(),
    };
    let mut out = HashMap::new();
    for entry in raw {
        if let (Some(cdk), Some(icon)) = (
            entry.get(0).and_then(|v| v.as_str()),
            entry.get(2).and_then(|v| v.as_str()),
        ) {
            out.insert(cdk.to_lowercase(), icon.to_string());
        }
    }
    out
}

fn lookup_vehicle_name(
    names: &HashMap<String, HashMap<String, String>>,
    cdk: &str,
    lang: &str,
) -> String {
    if let Some(by_lang) = names.get(&cdk.to_lowercase()) {
        if let Some(n) = by_lang.get(lang) {
            return n.clone();
        }
        if let Some(n) = by_lang.get("en") {
            return n.clone();
        }
    }
    cdk.to_string()
}

fn lookup_vehicle_icon(icons: &HashMap<String, String>, cdk: &str) -> String {
    icons
        .get(&cdk.to_lowercase())
        .cloned()
        .unwrap_or_else(|| format!("{}.png", cdk.to_lowercase()))
}

fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf {
    let path = if let Ok(raw) = env::var(env_key) {
        PathBuf::from(expand_home(&raw))
    } else if let Ok(storage) = env::var("STORAGE_VOL_PATH") {
        PathBuf::from(expand_home(&storage)).join(default_file)
    } else {
        PathBuf::from(default_file)
    };
    if path.is_absolute() {
        path
    } else {
        env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(path)
    }
}

fn expand_home(value: &str) -> String {
    if value == "~" {
        return home_dir()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| value.to_string());
    }
    if let Some(rest) = value
        .strip_prefix("~/")
        .or_else(|| value.strip_prefix("~\\"))
    {
        if let Some(home) = home_dir() {
            return home.join(rest).display().to_string();
        }
    }
    value.to_string()
}

fn home_dir() -> Option<PathBuf> {
    env::var_os("HOME")
        .or_else(|| env::var_os("USERPROFILE"))
        .map(PathBuf::from)
}

fn load_root_env() {
    let candidates = [
        env::current_dir().ok().map(|path| path.join(".env")),
        env::current_dir()
            .ok()
            .and_then(|path| path.parent().map(|parent| parent.join(".env"))),
        Some(
            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
                .join("..")
                .join(".env"),
        ),
    ];

    for candidate in candidates.into_iter().flatten() {
        if candidate.exists() {
            load_env_file(&candidate);
            return;
        }
    }
}

fn load_env_file(path: &FsPath) {
    let Ok(contents) = fs::read_to_string(path) else {
        return;
    };
    for line in contents.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }
        let Some((key, value)) = trimmed.split_once('=') else {
            continue;
        };
        let key = key.trim();
        if key.is_empty() || env::var_os(key).is_some() {
            continue;
        }
        let value = value
            .trim()
            .trim_matches('"')
            .trim_matches('\'')
            .to_string();
        env::set_var(key, value);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn vehicle_name_prefers_lang_then_en_then_cdk() {
        let mut names = HashMap::new();
        let mut t = HashMap::new();
        t.insert("en".to_string(), "T-34".to_string());
        t.insert("ru".to_string(), "Т-34".to_string());
        names.insert("ussr_t_34".to_string(), t);
        assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "ru"), "Т-34");
        assert_eq!(lookup_vehicle_name(&names, "ussr_t_34", "de"), "T-34");
        assert_eq!(
            lookup_vehicle_name(&names, "unknown_cdk", "en"),
            "unknown_cdk"
        );
    }

    #[test]
    fn vehicle_icon_falls_back_to_cdk_png() {
        let mut icons = HashMap::new();
        icons.insert("ussr_t_34".to_string(), "ussr_t_34.png".to_string());
        assert_eq!(lookup_vehicle_icon(&icons, "ussr_t_34"), "ussr_t_34.png");
        assert_eq!(lookup_vehicle_icon(&icons, "germ_pz"), "germ_pz.png");
    }

    #[test]
    fn load_vehicle_icons_parses_cache_array() {
        let dir = std::env::temp_dir();
        let p = dir.join("test_vehicle_cache_all.json");
        fs::write(
            &p,
            r#"[["ussr_t_34","T-34","ussr_t_34.png",{}],["germ_pz","Pz","germ_pz.png",{}]]"#,
        )
        .unwrap();
        let icons = load_vehicle_icons(&p);
        assert_eq!(icons.get("ussr_t_34").unwrap(), "ussr_t_34.png");
        assert_eq!(icons.len(), 2);
        let _ = fs::remove_file(&p);
    }
}
