'use strict';

const express = require('express');
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const zlib = require('zlib');

const { startWatcher, buildPublicStats, recordLiveLap, onSessionComplete, deleteFastestLap } = require('./results-db');

// Register Discord race results callback
onSessionComplete(({ track, type, results }) => {
  if (type === 'Race') discordRaceResults(1, results, track, type);
});

// ── ISO 3166-1 alpha-3 → alpha-2 nation code map (for Discord flag emojis) ────
const NATION_ALPHA3_TO_ALPHA2 = {
  AFG:'af',ALB:'al',DZA:'dz',AND:'ad',AGO:'ao',ATG:'ag',ARG:'ar',ARM:'am',AUS:'au',AUT:'at',
  AZE:'az',BHS:'bs',BHR:'bh',BGD:'bd',BRB:'bb',BLR:'by',BEL:'be',BLZ:'bz',BEN:'bj',BTN:'bt',
  BOL:'bo',BIH:'ba',BWA:'bw',BRA:'br',BRN:'bn',BGR:'bg',BFA:'bf',BDI:'bi',CPV:'cv',KHM:'kh',
  CMR:'cm',CAN:'ca',CAF:'cf',TCD:'td',CHL:'cl',CHN:'cn',COL:'co',COM:'km',COD:'cd',COG:'cg',
  CRI:'cr',HRV:'hr',CUB:'cu',CYP:'cy',CZE:'cz',DNK:'dk',DJI:'dj',DMA:'dm',DOM:'do',ECU:'ec',
  EGY:'eg',SLV:'sv',GNQ:'gq',ERI:'er',EST:'ee',SWZ:'sz',ETH:'et',FJI:'fj',FIN:'fi',FRA:'fr',
  GAB:'ga',GMB:'gm',GEO:'ge',DEU:'de',GHA:'gh',GRC:'gr',GRD:'gd',GTM:'gt',GIN:'gn',GNB:'gw',
  GUY:'gy',HTI:'ht',HND:'hn',HUN:'hu',ISL:'is',IND:'in',IDN:'id',IRN:'ir',IRQ:'iq',IRL:'ie',
  ISR:'il',ITA:'it',JAM:'jm',JPN:'jp',JOR:'jo',KAZ:'kz',KEN:'ke',KIR:'ki',PRK:'kp',KOR:'kr',
  KWT:'kw',KGZ:'kg',LAO:'la',LVA:'lv',LBN:'lb',LSO:'ls',LBR:'lr',LBY:'ly',LIE:'li',LTU:'lt',
  LUX:'lu',MDG:'mg',MWI:'mw',MYS:'my',MDV:'mv',MLI:'ml',MLT:'mt',MHL:'mh',MRT:'mr',MUS:'mu',
  MEX:'mx',FSM:'fm',MDA:'md',MCO:'mc',MNG:'mn',MNE:'me',MAR:'ma',MOZ:'mz',MMR:'mm',NAM:'na',
  NRU:'nr',NPL:'np',NLD:'nl',NZL:'nz',NIC:'ni',NER:'ne',NGA:'ng',MKD:'mk',NOR:'no',OMN:'om',
  PAK:'pk',PLW:'pw',PAN:'pa',PNG:'pg',PRY:'py',PER:'pe',PHL:'ph',POL:'pl',PRT:'pt',QAT:'qa',
  ROU:'ro',RUS:'ru',RWA:'rw',KNA:'kn',LCA:'lc',VCT:'vc',WSM:'ws',SMR:'sm',STP:'st',SAU:'sa',
  SEN:'sn',SRB:'rs',SYC:'sc',SLE:'sl',SGP:'sg',SVK:'sk',SVN:'si',SLB:'sb',SOM:'so',ZAF:'za',
  SSD:'ss',ESP:'es',LKA:'lk',SDN:'sd',SUR:'sr',SWE:'se',CHE:'ch',SYR:'sy',TWN:'tw',TJK:'tj',
  TZA:'tz',THA:'th',TLS:'tl',TGO:'tg',TON:'to',TTO:'tt',TUN:'tn',TUR:'tr',TKM:'tm',UGA:'ug',
  UKR:'ua',ARE:'ae',GBR:'gb',USA:'us',URY:'uy',UZB:'uz',VUT:'vu',VEN:'ve',VNM:'vn',YEM:'ye',
  ZMB:'zm',ZWE:'zw',REU:'re',BGR:'bg',LTU:'lt',
};

/** Convert a nation code (alpha-3 or alpha-2) to Discord :flag_xx: emoji string */
function nationToFlag(nation) {
  if (!nation) return '';
  const upper = nation.toUpperCase();
  // If already 2-letter, use directly
  const code = upper.length === 3 ? (NATION_ALPHA3_TO_ALPHA2[upper] || upper.toLowerCase()) : upper.toLowerCase();
  return `:flag_${code}:`;
}

// Lightweight steamId -> nation cache, updated after each results scan
const resultsDbNationCache = new Map();
function refreshNationCache() {
  try {
    const stats = buildPublicStats();
    for (const d of stats.driverRankings) {
      if (d.steamId && d.nation) resultsDbNationCache.set(d.steamId, d.nation);
    }
  } catch {}
}
// Refresh once at startup and every 5 minutes
setTimeout(refreshNationCache, 3000);
setInterval(refreshNationCache, 5 * 60 * 1000);

const app = express();

// ─── CORS for public endpoints ────────────────────────────────────────────────
app.use('/status', (req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});
app.use('/stats', (req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});
app.use('/results', (req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

// GET /statistics — public statistics page
app.get('/statistics', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'statistics.html')));
app.use('/statistics', (req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  next();
});
app.get('/results', (req, res) => {
  try {
    // Use first server's resultsPath
    const settings = loadSettings(1);
    const dir = settings.resultsPath;
    if (!fs.existsSync(dir)) return res.json({ files: [], path: dir });
    const files = fs.readdirSync(dir)
      .filter(f => f.endsWith('.json'))
      .sort().reverse()
      .map(f => {
        const stat = fs.statSync(path.join(dir, f));
        return { name: f, size: stat.size, modified: stat.mtime };
      });
    res.json({ files, count: files.length, path: dir });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// GET /results/:filename — serve a single result file
app.get('/results/:filename', (req, res) => {
  try {
    const filename = path.basename(req.params.filename); // prevent path traversal
    if (!filename.endsWith('.json')) return res.status(400).json({ error: 'Only .json files' });
    const settings = loadSettings(1);
    const filePath = path.join(settings.resultsPath, filename);
    if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
    res.sendFile(filePath);
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// ─── Load config.json ─────────────────────────────────────────────────────────
const LAUNCHER_CONFIG_PATH = path.join(__dirname, '..', 'config.json');

function loadLauncherConfig() {
  if (!fs.existsSync(LAUNCHER_CONFIG_PATH)) {
    console.error(`[ERROR] config.json not found at ${LAUNCHER_CONFIG_PATH}`);
    process.exit(1);
  }
  try { return JSON.parse(fs.readFileSync(LAUNCHER_CONFIG_PATH, 'utf8')); }
  catch (e) { console.error(`[ERROR] config.json parse error: ${e.message}`); process.exit(1); }
}

function saveLauncherConfig(cfg) {
  fs.writeFileSync(LAUNCHER_CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
}

let launcherCfg = loadLauncherConfig();
const PORT          = launcherCfg.webPort || 3000;
const EXE_PATH      = () => loadLauncherConfig().executablePath || '';
const SERVER_COUNT  = () => Math.max(1, parseInt(loadLauncherConfig().serverCount) || 1);
const SERVER_SUFFIX = () => loadLauncherConfig().serverSuffix || '';

// ─── Session store ────────────────────────────────────────────────────────────
const sessions = new Map();
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;

function createSession(username, role, serverId) {
  const token = crypto.randomBytes(32).toString('hex');
  sessions.set(token, { createdAt: Date.now(), username, role, serverId: serverId || null });
  return token;
}
function getSession(token) {
  if (!token || !sessions.has(token)) return null;
  const s = sessions.get(token);
  if (Date.now() - s.createdAt > SESSION_TTL_MS) { sessions.delete(token); return null; }
  return s;
}
function isValidSession(token) { return !!getSession(token); }

// ─── User helpers ─────────────────────────────────────────────────────────────
function getUsers() {
  const cfg = loadLauncherConfig();
  // Always include legacy adminPassword as built-in admin
  const users = Array.isArray(cfg.users) ? cfg.users : [];
  // Ensure there is at least one admin entry matching adminPassword
  if (!users.find(u => u.role === 'admin')) {
    users.unshift({ username: 'admin', password: cfg.adminPassword || 'admin', role: 'admin' });
  }
  return users;
}

function saveUsers(users) {
  const cfg = loadLauncherConfig();
  cfg.users = users;
  // Keep legacy adminPassword in sync with first admin user's password
  const admin = users.find(u => u.role === 'admin');
  if (admin) cfg.adminPassword = admin.password;
  saveLauncherConfig(cfg);
}

// ─── Multi-server process state ───────────────────────────────────────────────
// serverId is 1-based integer
const serverProcesses = new Map(); // serverId -> ChildProcess
const serverLogs = new Map();      // serverId -> [{time,text,type}]
const serverStartTimes = new Map(); // serverId -> Date.now() when started
const sseClients = new Map();      // serverId -> Set of res objects
const connectedPlayers = new Map(); // serverId -> Map<carNumber, playerData>
const liveTemp    = new Map(); // sid -> temp_c (parsed from log)
const liveSession = new Map();      // serverId -> { type, track, layout, startedAt }
const MAX_LOGS = 500;

function getSseClients(sid) {
  if (!sseClients.has(sid)) sseClients.set(sid, new Set());
  return sseClients.get(sid);
}

function getServerLogs(sid) {
  if (!serverLogs.has(sid)) serverLogs.set(sid, []);
  return serverLogs.get(sid);
}

// ─── Settings paths ───────────────────────────────────────────────────────────
const IS_WINDOWS    = process.platform === 'win32';
const SETTINGS_DIR  = IS_WINDOWS ? path.join('C:', 'ace-manager', 'ACE')     : path.join(os.homedir(), 'ACE');
const RESULTS_DIR   = (() => {
  try {
    const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'config.json'), 'utf8'));
    if (cfg.resultsPath && cfg.resultsPath.trim()) return cfg.resultsPath.trim();
  } catch {}
  return IS_WINDOWS ? path.join('C:', 'ace-manager', 'results') : path.join(os.homedir(), 'results');
})();

const PRESETS_FILE     = path.join(SETTINGS_DIR, 'presets.json');
const DISCORD_FILE     = path.join(SETTINGS_DIR, 'discord.json');
const CATEGORIES_FILE  = path.join(SETTINGS_DIR, 'car_categories.json');

function ensureDirs() {
  if (!fs.existsSync(SETTINGS_DIR)) fs.mkdirSync(SETTINGS_DIR, { recursive: true });
  if (!fs.existsSync(RESULTS_DIR))  fs.mkdirSync(RESULTS_DIR,  { recursive: true });
}

// ─── Discord Webhook ───────────────────────────────────────────────────────
function loadDiscordSettings() {
  try { if (fs.existsSync(DISCORD_FILE)) return JSON.parse(fs.readFileSync(DISCORD_FILE, 'utf8')); } catch {}
  return { webhookUrl: '', botName: 'ACE Manager', playerJoin: false, playerLeave: false, sessionChange: false, raceResults: false, serverStart: false, serverStop: false, fastestLap: false,
    webhooks: { playerJoin: '', playerLeave: '', sessionChange: '', raceResults: '', serverStart: '', serverStop: '', fastestLap: '' } };
}

function saveDiscordSettingsFile(settings) {
  ensureDirs();
  fs.writeFileSync(DISCORD_FILE, JSON.stringify(settings, null, 2), 'utf8');
}

const discordLog = [];
let discordReady = false;

async function sendDiscordWebhook(embedOrText, eventType) {
  const dc = loadDiscordSettings();
  if (eventType && !dc[eventType]) return;
  if (!discordReady && eventType) return; // Skip during startup

  // Per-event webhook takes priority over the global one
  const url = (eventType && dc.webhooks && dc.webhooks[eventType]) || dc.webhookUrl;
  if (!url) return;

  const body = typeof embedOrText === 'string'
    ? { content: embedOrText, username: dc.botName || 'ACE Manager' }
    : { embeds: [embedOrText], username: dc.botName || 'ACE Manager' };

  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const logEntry = { ts: new Date().toISOString(), event: eventType || 'test', status: res.status, ok: res.ok };
    discordLog.unshift(logEntry);
    if (discordLog.length > 50) discordLog.pop();
    if (!res.ok) console.error(`[DISCORD] Webhook failed: ${res.status}`);
  } catch (e) {
    const logEntry = { ts: new Date().toISOString(), event: eventType || 'test', status: 0, ok: false, error: e.message };
    discordLog.unshift(logEntry);
    if (discordLog.length > 50) discordLog.pop();
    console.error(`[DISCORD] Webhook error: ${e.message}`);
  }
}

// Track announced players to prevent duplicate join notifications on session change
const discordAnnouncedPlayers = new Map(); // sid -> Set of player names

function discordPlayerJoin(sid, name, car, nation) {
  if (!discordAnnouncedPlayers.has(sid)) discordAnnouncedPlayers.set(sid, new Set());
  const announced = discordAnnouncedPlayers.get(sid);
  if (announced.has(name)) return; // Already announced, skip
  announced.add(name);

  const settings = loadSettings(sid);
  const flag = nationToFlag(nation);
  sendDiscordWebhook({
    color: 0x00e676,
    title: '👋 Player Joined',
    description: `${flag} **${name}** joined the server`,
    fields: [
      { name: 'Car', value: car || 'Unknown', inline: true },
      { name: 'Server', value: settings.serverName || `Server ${sid}`, inline: true },
    ],
    timestamp: new Date().toISOString(),
  }, 'playerJoin');
}

function discordPlayerLeave(sid, name) {
  // Remove from announced set so rejoining triggers a new notification
  if (discordAnnouncedPlayers.has(sid)) discordAnnouncedPlayers.get(sid).delete(name);

  const settings = loadSettings(sid);
  sendDiscordWebhook({
    color: 0xff5252,
    title: '🚪 Player Left',
    description: `**${name}** left the server`,
    fields: [
      { name: 'Server', value: settings.serverName || `Server ${sid}`, inline: true },
    ],
    timestamp: new Date().toISOString(),
  }, 'playerLeave');
}

function discordSessionChange(sid, sessionType) {
  const settings = loadSettings(sid);
  const icons = { 'Practice': '🔧', 'Qualifying': '⏱️', 'Race': '🏁', 'Warmup': '🌡️' };
  sendDiscordWebhook({
    color: 0x00a8ff,
    title: `${icons[sessionType] || '🏁'} ${sessionType} Started`,
    fields: [
      { name: 'Server', value: settings.serverName || `Server ${sid}`, inline: true },
      { name: 'Track', value: settings.Event?.selectedTrackValue || 'Unknown', inline: true },
    ],
    timestamp: new Date().toISOString(),
  }, 'sessionChange');
}

function discordServerStart(sid) {
  const settings = loadSettings(sid);
  sendDiscordWebhook({
    color: 0x00e676,
    title: '🟢 Server Started',
    description: `**${settings.serverName || 'Server ' + sid}** is now online`,
    fields: [
      { name: 'Track', value: settings.Event?.selectedTrackValue || 'Unknown', inline: true },
      { name: 'Max Players', value: String(settings.maxPlayers || 8), inline: true },
    ],
    timestamp: new Date().toISOString(),
  }, 'serverStart');
}

function discordServerStop(sid, code) {
  const settings = loadSettings(sid);
  sendDiscordWebhook({
    color: 0xff5252,
    title: '🔴 Server Stopped',
    description: `**${settings.serverName || 'Server ' + sid}** went offline`,
    fields: code !== undefined ? [{ name: 'Exit Code', value: String(code), inline: true }] : [],
    timestamp: new Date().toISOString(),
  }, 'serverStop');
}

function discordRaceResults(sid, results, track, sessionType) {
  const settings = loadSettings(sid);
  if (!results || !results.length) return;
  const top10 = results.slice(0, 10);
  const medals = ['🥇', '🥈', '🥉'];
  const lines = top10.map((r, i) => `${medals[i] || `${i + 1}.`} ${nationToFlag(r.nation)} **${r.name}** — ${r.time || r.bestLap || '—'}`);
  const fastestDriver = results.reduce((best, r) => (!best || (r.bestLapMs && r.bestLapMs < best.bestLapMs)) ? r : best, null);

  sendDiscordWebhook({
    color: 0xf5c842,
    title: `🏆 ${sessionType || 'Race'} Results — ${track}`,
    description: lines.join('\n'),
    fields: fastestDriver ? [
      { name: '⚡ Fastest Lap', value: `${nationToFlag(fastestDriver.nation)} ${fastestDriver.name} — ${fastestDriver.bestLap}`, inline: false },
      { name: 'Server', value: settings.serverName || `Server ${sid}`, inline: true },
      { name: 'Drivers', value: String(results.length), inline: true },
    ] : [],
    timestamp: new Date().toISOString(),
  }, 'raceResults');
}

// ── Session fastest lap tracker (sid -> { name, track, timeMs, lap }) ────────
const sessionFastestLap = new Map();

function discordFastestLap(sid, name, lap, track, car, nation, previousRecord) {
  const flag = nationToFlag(nation);
  const fields = [
    { name: '\u23f1\ufe0f Lap Time', value: lap, inline: true },
    { name: 'Car', value: car || 'Unknown', inline: true },
    { name: 'Track', value: track, inline: true },
  ];

  if (previousRecord) {
    const prevFlag = nationToFlag(previousRecord.nation);
    const prevLine = [prevFlag, previousRecord.name, '\u2014', previousRecord.time, '\u2014', previousRecord.car, '\u2014', previousRecord.date].filter(Boolean).join(' ');
    fields.push({ name: '📋 Previous Record', value: prevLine, inline: false });
  }

  sendDiscordWebhook({
    color: 0xa855f7,
    title: '\u26a1 New Fastest Lap!',
    description: `${flag} **${name}** set a new track record on **${track}**`,
    fields,
    timestamp: new Date().toISOString(),
  }, 'fastestLap');
}

function loadPresets() {
  if (fs.existsSync(PRESETS_FILE)) {
    try { return JSON.parse(fs.readFileSync(PRESETS_FILE, 'utf8')); } catch {}
  }
  return [];
}

function savePresets(presets) {
  ensureDirs();
  fs.writeFileSync(PRESETS_FILE, JSON.stringify(presets, null, 2), 'utf8');
}

function loadCategories() {
  if (fs.existsSync(CATEGORIES_FILE)) {
    try { return JSON.parse(fs.readFileSync(CATEGORIES_FILE, 'utf8')); } catch {}
  }
  return [];
}

function saveCategories(cats) {
  ensureDirs();
  fs.writeFileSync(CATEGORIES_FILE, JSON.stringify(cats, null, 2), 'utf8');
}

function settingsPath(sid) {
  return path.join(SETTINGS_DIR, `server_${sid}.json`);
}

// ─── Middleware ───────────────────────────────────────────────────────────────
app.use(express.json());

function getToken(req) {
  return req.headers['x-auth-token'] || req.query.token || '';
}

function requireAuth(req, res, next) {
  const s = getSession(getToken(req));
  if (!s) return res.status(401).json({ error: 'Unauthorized' });
  req.session = s;
  next();
}

function requireAdmin(req, res, next) {
  const s = getSession(getToken(req));
  if (!s) return res.status(401).json({ error: 'Unauthorized' });
  if (s.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
  req.session = s;
  next();
}

function requireAdminOrOwner(req, res, next) {
  const s = getSession(getToken(req));
  if (!s) return res.status(401).json({ error: 'Unauthorized' });
  if (s.role === 'admin') { req.session = s; return next(); }
  if (s.role === 'owner') {
    const reqSid = parseInt(req.params.sid || req.query.sid || req.body?.sid || 1);
    if (s.serverId && reqSid === s.serverId) { req.session = s; return next(); }
    return res.status(403).json({ error: 'You can only manage your own server' });
  }
  return res.status(403).json({ error: 'Insufficient permissions' });
}

app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'login.html')));
app.get('/app', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
app.use(express.static(path.join(__dirname, '..', 'public')));

// ─── Auth (public) ────────────────────────────────────────────────────────────
// ─── Auth (public) ────────────────────────────────────────────────────────────
app.post('/api/auth/login', (req, res) => {
  const { username, password } = req.body;
  const users = getUsers();
  const user = users.find(u => u.username === username && u.password === password);
  if (!user) return res.status(403).json({ error: 'Wrong username or password' });
  return res.json({ ok: true, token: createSession(user.username, user.role, user.serverId || null), role: user.role, username: user.username, serverId: user.serverId || null });
});

app.post('/api/auth/logout', (req, res) => {
  sessions.delete(getToken(req));
  res.json({ ok: true });
});

app.get('/api/auth/me', requireAuth, (req, res) => {
  res.json({ username: req.session.username, role: req.session.role, serverId: req.session.serverId || null });
});

// Public API endpoints (no auth required) — must be defined BEFORE requireAuth middleware
app.get('/api/car-categories', (req, res) => res.json(loadCategories()));

app.use('/api', requireAuth);

// ─── Default settings for a server ───────────────────────────────────────────
function defaultSettings(sid) {
  return {
    serverName: `EVO Server ${sid}`,
    maxPlayers: 8,
    maxPlayersLimit: 40,
    tcpPort: 9700 + (sid - 1) * 100,
    udpPort: 9700 + (sid - 1) * 100,
    httpPort: 8080 + (sid - 1),
    isCycleEnabled: true,
    driverPassword: '',
    spectatorPassword: '',
    welcomeMessage: '',
    adminPassword: '',
    entryListPath: '',
    resultsPath: RESULTS_DIR,
    selectedServerTypeValue: 'MultiplayerServerListSessionType_RANKED',
    Event: {
      selectedSessionTypeValue: 'GameModeType_PRACTICE',
      selectedTrackValue: '',
      selectedWeatherTypeValue: 'GameModeSelectionWeatherType_CLEAR',
      selectedWeatherBehaviorValue: 'GameModeSelectionWeatherBehaviour_STATIC',
      selectedInitialGripValue: 'InitialGrip_GREEN'
    },
    selectedCars: [], // array of car names selected for this server
    Sessions: {
      PracticeSession:   { name:'Practice',   isVisible:true,  duration:0, length:1200, hour:16, minute:0, timeMultiplierIndex:0, maxWaitToBox:10, overtimeWaitingNextSession:10, minWaitingForPlayers:10, maxWaitingForPlayers:30 },
      QualifyingSession: { name:'Qualifying',  isVisible:false, duration:0, length:600,  hour:16, minute:0, timeMultiplierIndex:0, maxWaitToBox:10, overtimeWaitingNextSession:10, minWaitingForPlayers:10, maxWaitingForPlayers:30 },
      WarmupSession:     { name:'Warmup',      isVisible:false, duration:0, length:300,  hour:16, minute:0, timeMultiplierIndex:0, maxWaitToBox:10, overtimeWaitingNextSession:10, minWaitingForPlayers:10, maxWaitingForPlayers:30 },
      RaceSession:       { name:'Race',        isVisible:false, duration:0, length:20,   hour:16, minute:0, timeMultiplierIndex:0, maxWaitToBox:10, overtimeWaitingNextSession:10, minWaitingForPlayers:10, maxWaitingForPlayers:30 }
    }
  };
}

function loadSettings(sid) {
  ensureDirs();
  const fp = settingsPath(sid);
  if (fs.existsSync(fp)) {
    try {
      const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
      const def = defaultSettings(sid);
      // Migrate old homedir-based resultsPath to new default
      if (IS_WINDOWS && raw.resultsPath) {
        const oldDefault = path.join(os.homedir(), 'results');
        if (path.resolve(raw.resultsPath) === path.resolve(oldDefault)) {
          raw.resultsPath = RESULTS_DIR;
          try { fs.writeFileSync(fp, JSON.stringify({ ...raw }, null, 2), 'utf8'); } catch {}
        }
      }
      return {
        ...def, ...raw,
        Event:    { ...def.Event,    ...raw.Event },
        Sessions: {
          PracticeSession:   { ...def.Sessions.PracticeSession,   ...raw.Sessions?.PracticeSession },
          QualifyingSession: { ...def.Sessions.QualifyingSession, ...raw.Sessions?.QualifyingSession },
          WarmupSession:     { ...def.Sessions.WarmupSession,     ...raw.Sessions?.WarmupSession },
          RaceSession:       { ...def.Sessions.RaceSession,       ...raw.Sessions?.RaceSession }
        }
      };
    } catch (e) { console.error(`Failed to load settings for server ${sid}:`, e.message); }
  }
  return defaultSettings(sid);
}

function saveSettings(sid, s) {
  ensureDirs();
  fs.writeFileSync(settingsPath(sid), JSON.stringify(s, null, 2), 'utf8');
}

// ─── Compression ──────────────────────────────────────────────────────────────
function jsonToCompressedBase64(jsonStr) {
  const inputBuf = Buffer.from(jsonStr, 'utf8');
  const compressed = zlib.deflateSync(inputBuf, { level: zlib.constants.Z_BEST_COMPRESSION });
  const lenBuf = Buffer.alloc(4);
  lenBuf.writeInt32BE(inputBuf.length, 0);
  return Buffer.concat([lenBuf, compressed]).toString('base64');
}

// ─── Config/Season builders ───────────────────────────────────────────────────
function buildConfigJson(settings) {
  const exePath = EXE_PATH();
  let allowedCars = [];
  if (exePath) {
    try {
      const allCars = JSON.parse(fs.readFileSync(path.join(path.dirname(exePath), 'cars.json'), 'utf8')).cars || [];
      const selectedSet = new Set(settings.selectedCars || []);
      // If server has explicit selection use it, otherwise fall back to is_selected in cars.json
      const filtered = selectedSet.size > 0
        ? allCars.filter(c => selectedSet.has(c.name))
        : allCars.filter(c => c.is_selected);
      allowedCars = filtered.map(c => ({
        car_name: c.name,
        ballast: Math.round(c.ballast || 0),
        restrictor: parseFloat(c.restrictor || 0)
      }));
    } catch {}
  }
  return {
    server_tcp_listener_port: settings.tcpPort,
    server_udp_listener_port: settings.udpPort,
    server_tcp_internal_port: 0,
    server_udp_internal_port: 0,
    server_http_port:         settings.httpPort,
    server_name:              SERVER_SUFFIX() ? `${settings.serverName} - ${SERVER_SUFFIX()}` : settings.serverName,
    max_players:              settings.maxPlayers,
    cycle:                    settings.isCycleEnabled,
    allowed_cars_list_full:   allowedCars,
    driver_password:          settings.driverPassword,
    spectator_password:       settings.spectatorPassword,
    admin_password:           settings.adminPassword,
    type:                     settings.selectedServerTypeValue,
    entry_list_path:          settings.entryListPath,
    results_path:             settings.resultsPath
  };
}

const TM_VALUES = [1, 2, 4, 8, 16, 32, 64, 128];

function buildSeasonJson(settings) {
  const e  = settings.Event;
  const ss = settings.Sessions;
  const tp = (e.selectedTrackValue || '').split('|');
  if (tp.length < 4) return null;
  const year  = e.eventYear  || 2025;
  const month = e.eventMonth || 6;
  const day   = e.eventDay   || 15;
  const tod = s => ({ year, month, day, hour: s.hour, minute: s.minute, second: 0, time_multiplier: TM_VALUES[s.timeMultiplierIndex] || 1 });
  const gc = {};
  const p = ss.PracticeSession, q = ss.QualifyingSession, w = ss.WarmupSession, r = ss.RaceSession;
  if (p.isVisible) { gc.practice_duration=p.length; gc.practice_time_of_day=tod(p); gc.practice_overtime_waiting_next_session=p.overtimeWaitingNextSession; gc.practice_max_wait_to_box=p.maxWaitToBox; }
  if (q.isVisible) { gc.qualify_duration=q.length;  gc.qualify_time_of_day=tod(q);  gc.qualify_overtime_waiting_next_session=q.overtimeWaitingNextSession;  gc.qualify_max_wait_to_box=q.maxWaitToBox;  }
  if (w.isVisible) { gc.warmup_duration=w.length;   gc.warmup_time_of_day=tod(w);   gc.warmup_overtime_waiting_next_session=w.overtimeWaitingNextSession;   gc.warmup_max_wait_to_box=w.maxWaitToBox;   }
  if (r.isVisible) {
    gc.race_duration=r.length;
    gc.race_duration_type = r.duration===1 ? 'GameModeSelectionDuration_LAPS' : 'GameModeSelectionDuration_TIME';
    gc.race_time_of_day=tod(r); gc.race_overtime_waiting_next_session=r.overtimeWaitingNextSession; gc.race_max_wait_to_box=r.maxWaitToBox;
    gc.min_waiting_for_players=r.minWaitingForPlayers; gc.max_waiting_for_players=r.maxWaitingForPlayers;
  }
  return {
    game_type: e.selectedSessionTypeValue,
    event: { track:tp[0], layout:tp[1], event_name:tp[2], track_length:parseInt(tp[3],10) },
    export_json: false, game_config: gc,
    weather_type: e.selectedWeatherTypeValue,
    weather_behaviour: e.selectedWeatherBehaviorValue,
    initial_grip: e.selectedInitialGripValue
  };
}

// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseSid(req, res) {
  const sid = parseInt(req.params.sid || req.body?.sid || 1);
  const count = SERVER_COUNT();
  if (isNaN(sid) || sid < 1 || sid > count) {
    res.status(400).json({ error: `Invalid server id. Must be 1-${count}.` });
    return null;
  }
  return sid;
}

// ─── API ──────────────────────────────────────────────────────────────────────

// Launcher info
app.get('/api/launcher-info', (req, res) => {
  const cfg = loadLauncherConfig();
  const count = Math.max(1, parseInt(cfg.serverCount) || 1);
  const allServers = [];
  for (let i = 1; i <= count; i++) {
    const s = loadSettings(i);
    allServers.push({ id: i, name: s.serverName, running: serverProcesses.has(i), pid: serverProcesses.get(i)?.pid ?? null });
  }
  // Owners only see their own server
  const sess = getSession(getToken(req));
  const servers = (sess?.role === 'owner' && sess.serverId)
    ? allServers.filter(s => s.id === sess.serverId)
    : allServers;
  res.json({ executablePath: cfg.executablePath, exeName: cfg.executablePath ? path.basename(cfg.executablePath) : null, language: cfg.language || 'en', serverCount: count, serverSuffix: cfg.serverSuffix || '', sslCert: cfg.sslCert || '', sslKey: cfg.sslKey || '', servers });
});

// Save SSL config
app.post('/api/launcher-ssl', requireAdmin, (req, res) => {
  const cfg = loadLauncherConfig();
  const { sslCert, sslKey } = req.body;
  if (sslCert !== undefined) cfg.sslCert = sslCert.trim();
  if (sslKey  !== undefined) cfg.sslKey  = sslKey.trim();
  // Remove keys entirely if empty
  if (!cfg.sslCert) delete cfg.sslCert;
  if (!cfg.sslKey)  delete cfg.sslKey;
  saveLauncherConfig(cfg);
  res.json({ ok: true, note: 'Restart the launcher for SSL changes to take effect.' });
});

// Change own password
app.post('/api/auth/change-password', (req, res) => {
  const { oldPassword, newPassword } = req.body;
  const users = getUsers();
  const me = users.find(u => u.username === req.session.username);
  if (!me || oldPassword !== me.password) return res.status(403).json({ error: 'Old password is wrong' });
  if (!newPassword || newPassword.length < 4) return res.status(400).json({ error: 'New password too short (min 4 chars)' });
  me.password = newPassword;
  saveUsers(users);
  sessions.clear();
  res.json({ ok: true });
});

// ─── User management (admin only) ─────────────────────────────────────────────
app.get('/api/users', requireAdmin, (req, res) => {
  res.json(getUsers().map(u => ({ username: u.username, role: u.role, serverId: u.serverId || null })));
});

app.post('/api/users', requireAdmin, (req, res) => {
  const { username, password, role, serverId } = req.body;
  if (!username || !password) return res.status(400).json({ error: 'username and password required' });
  if (!['admin', 'owner', 'user'].includes(role)) return res.status(400).json({ error: 'role must be admin, owner or user' });
  if (role === 'owner' && !serverId) return res.status(400).json({ error: 'owner requires a serverId' });
  const users = getUsers();
  if (users.find(u => u.username === username)) return res.status(409).json({ error: 'Username already exists' });
  const entry = { username, password, role };
  if (role === 'owner') entry.serverId = parseInt(serverId);
  users.push(entry);
  saveUsers(users);
  res.json({ ok: true });
});

app.delete('/api/users/:username', requireAdmin, (req, res) => {
  const target = req.params.username;
  if (target === req.session.username) return res.status(400).json({ error: 'Cannot delete yourself' });
  const users = getUsers().filter(u => u.username !== target);
  if (users.filter(u => u.role === 'admin').length === 0)
    return res.status(400).json({ error: 'Cannot delete the last admin' });
  saveUsers(users);
  res.json({ ok: true });
});

app.patch('/api/users/:username', requireAdmin, (req, res) => {
  const target = req.params.username;
  const { password, role, serverId } = req.body;
  const users = getUsers();
  const user = users.find(u => u.username === target);
  if (!user) return res.status(404).json({ error: 'User not found' });
  if (role && !['admin', 'owner', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
  if (password) { if (password.length < 4) return res.status(400).json({ error: 'Password too short' }); user.password = password; }
  if (role) { user.role = role; if (role === 'owner' && serverId) user.serverId = parseInt(serverId); else if (role !== 'owner') delete user.serverId; }
  if (serverId !== undefined && user.role === 'owner') user.serverId = parseInt(serverId);
  if (users.filter(u => u.role === 'admin').length === 0)
    return res.status(400).json({ error: 'Cannot remove the last admin' });
  saveUsers(users);
  res.json({ ok: true });
});

// Protect write/start/stop endpoints to admin only

// Settings per server
app.get('/api/servers/:sid/settings', (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  res.json(loadSettings(sid));
});

app.post('/api/servers/:sid/settings', requireAdminOrOwner, (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  saveSettings(sid, req.body);
  res.json({ ok: true });
});

// Assets (shared across all servers)
app.get('/api/assets', (req, res) => {
  const exePath = EXE_PATH();
  if (!exePath) return res.json({ cars: [], tracks_practice: [], tracks_race: [] });
  const dir = path.dirname(exePath);
  let cars = [], tp = [], tr = [];
  try { cars = JSON.parse(fs.readFileSync(path.join(dir, 'cars.json'), 'utf8')).cars || []; } catch {}
  try { tp   = JSON.parse(fs.readFileSync(path.join(dir, 'events_practice.json'), 'utf8')).events || []; } catch {}
  try { tr   = JSON.parse(fs.readFileSync(path.join(dir, 'events_race_weekend.json'), 'utf8')).events || []; } catch {}

  // Apply per-server car selection from query param ?sid=N
  const sid = parseInt(req.query.sid || '1');
  if (!isNaN(sid) && sid >= 1) {
    const srvSettings = loadSettings(sid);
    const selected = new Set(srvSettings.selectedCars || []);
    if (selected.size > 0) {
      cars = cars.map(c => ({ ...c, is_selected: selected.has(c.name) }));
    } else {
      // No selection saved yet — clear all is_selected flags
      cars = cars.map(c => ({ ...c, is_selected: false }));
    }
  }

  res.json({ cars, tracks_practice: tp, tracks_race: tr });
});

app.post('/api/assets/cars', requireAdminOrOwner, (req, res) => {
  // Save selected car names into the server's own settings (not cars.json)
  const sid = parseInt(req.query.sid || req.body.sid || '1');
  if (isNaN(sid) || sid < 1) return res.status(400).json({ error: 'Invalid sid' });
  try {
    const cars = req.body.cars || [];
    const selectedCars = cars.filter(c => c.is_selected).map(c => c.name);
    const settings = loadSettings(sid);
    settings.selectedCars = selectedCars;
    saveSettings(sid, settings);
    res.json({ ok: true });
  } catch (e) { res.status(500).json({ error: e.message }); }
});

// Preview
app.get('/api/servers/:sid/preview', (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  const settings = loadSettings(sid);
  const config = buildConfigJson(settings);
  const season = buildSeasonJson(settings);
  const exePath = EXE_PATH();
  let cmd = null;
  if (season && exePath) {
    cmd = `"${exePath}" -serverconfig ${jsonToCompressedBase64(JSON.stringify(config))} -seasondefinition ${jsonToCompressedBase64(JSON.stringify(season))}`;
  }
  res.json({ config: JSON.stringify(config, null, 2), season: season ? JSON.stringify(season, null, 2) : null, cmd });
});

// Start server
app.post('/api/servers/:sid/start', requireAdminOrOwner, (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  if (serverProcesses.has(sid)) return res.status(409).json({ error: 'Server already running' });
  const exePath = EXE_PATH();
  if (!exePath || !fs.existsSync(exePath)) return res.status(400).json({ error: 'executablePath in config.json is not set or not found.' });

  const settings = loadSettings(sid);
  const configJson = buildConfigJson(settings);
  const seasonJson = buildSeasonJson(settings);
  if (!seasonJson) return res.status(400).json({ error: 'No track selected.' });

  const args = ['-serverconfig', jsonToCompressedBase64(JSON.stringify(configJson)), '-seasondefinition', jsonToCompressedBase64(JSON.stringify(seasonJson))];

  addLog(sid, `Starting server ${sid}: ${exePath}`, 'info');
  addLog(sid, `TCP=${settings.tcpPort} UDP=${settings.udpPort} HTTP=${settings.httpPort}`, 'info');

  const isWindows = process.platform === 'win32';
  const spawnCmd  = isWindows ? exePath : 'wine';
  const spawnArgs = isWindows ? args : [exePath, ...args];

  const proc = spawn(spawnCmd, spawnArgs, { cwd: path.dirname(exePath), detached: false, ...(isWindows && { windowsHide: false }) });
  serverProcesses.set(sid, proc);
  serverStartTimes.set(sid, Date.now());

  proc.stdout.on('data', d => d.toString().split('\n').filter(Boolean).forEach(l => addLog(sid, l, 'stdout')));
  proc.stderr.on('data', d => d.toString().split('\n').filter(Boolean).forEach(l => addLog(sid, l, 'stderr')));
  proc.on('exit', (code, signal) => { addLog(sid, `Exited (code=${code}, signal=${signal})`, 'info'); discordServerStop(sid, code); discordAnnouncedPlayers.delete(sid); serverProcesses.delete(sid); serverStartTimes.delete(sid); connectedPlayers.delete(sid); liveSession.delete(sid); });
  proc.on('error', err => { addLog(sid, `Failed to start: ${err.message}`, 'error'); serverProcesses.delete(sid); serverStartTimes.delete(sid); connectedPlayers.delete(sid); liveSession.delete(sid); });

  res.json({ ok: true, pid: proc.pid });
  discordServerStart(sid);
});

// Stop server
app.post('/api/servers/:sid/stop', requireAdminOrOwner, (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  const proc = serverProcesses.get(sid);
  if (!proc) return res.status(404).json({ error: 'Server not running' });
  proc.kill('SIGTERM');
  setTimeout(() => { if (serverProcesses.has(sid)) serverProcesses.get(sid)?.kill(); }, 3000);
  res.json({ ok: true });
});

// System info
app.get('/api/system-info', (req, res) => {
  const { execSync } = require('child_process');
  const isWin = process.platform === 'win32';
  const info = { platform: process.platform, node: process.version, wine: null, winePrefix: null };
  if (!isWin) {
    try { info.wine = execSync('wine --version 2>/dev/null', { timeout: 3000 }).toString().trim(); } catch {}
    const prefix = process.env.WINEPREFIX || path.join(os.homedir(), '.wine');
    info.winePrefix = fs.existsSync(prefix) ? prefix : null;
  }
  res.json(info);
});

// Status all servers
app.get('/api/servers/status', (req, res) => {
  const count = SERVER_COUNT();
  const result = [];
  for (let i = 1; i <= count; i++) {
    result.push({ id: i, running: serverProcesses.has(i), pid: serverProcesses.get(i)?.pid ?? null, startedAt: serverStartTimes.get(i) ?? null });
  }
  res.json(result);
});

// ─── GeoLite2 Country lookup ──────────────────────────────────────────────────


// ─── GeoLite2 Country lookup ──────────────────────────────────────────────────
const maxmind = (() => { try { return require('maxmind'); } catch { return null; } })();

const GEOLITE_PATHS = [
  path.join(__dirname, '..', 'GeoLite2-Country.mmdb'),
  path.join(__dirname, '..', 'data', 'GeoLite2-Country.mmdb'),
  '/etc/GeoLite2-Country.mmdb',
];

let geoReader = null;

async function initGeo() {
  if (!maxmind) { console.warn('[GEO] maxmind package not installed — country lookup disabled'); return; }
  for (const p of GEOLITE_PATHS) {
    if (fs.existsSync(p)) {
      try {
        geoReader = await maxmind.open(p);
        console.log('[GEO] GeoLite2 loaded: ' + p);
        return;
      } catch (e) { console.warn('[GEO] Failed to open ' + p + ':', e.message); }
    }
  }
  console.warn('[GEO] GeoLite2-Country.mmdb not found. Place it in the launcher root folder.');
}

function lookupCountry(ip) {
  if (!geoReader || !ip || ip === '0.0.0.0') return 'Unknown';
  try {
    const result = geoReader.get(ip);
    return result?.country?.names?.en || result?.registered_country?.names?.en || 'Unknown';
  } catch { return 'Unknown'; }
}

initGeo();
ensureDirs();

// ─── Public status JSON (no auth required) ────────────────────────────────────
// GET /status/server_<sid>.json  →  simracers.online-compatible status file
// Also writes the file to disk so it can be served statically if needed.
const STATUS_DIR = path.join(__dirname, '..', 'public', 'status');

function getPublicIp() {
  // Returns cached IP or attempts a quick lookup via ipify
  if (getPublicIp._cache && Date.now() - getPublicIp._ts < 5 * 60 * 1000) return getPublicIp._cache;
  try {
    const { execSync } = require('child_process');
    const cmd = process.platform === 'win32'
      ? 'powershell -NoProfile -Command "(Invoke-WebRequest -Uri https://api.ipify.org -UseBasicParsing -TimeoutSec 3).Content"'
      : 'curl -sf --max-time 3 https://api.ipify.org';
    const raw = execSync(cmd, { timeout: 6000 }).toString().trim();
    getPublicIp._cache = raw || null;
    getPublicIp._ts = Date.now();
  } catch { getPublicIp._cache = null; getPublicIp._ts = Date.now(); }
  return getPublicIp._cache;
}

function weatherLabel(key) {
  if (!key) return 'Unknown';
  return key
    .replace('GameModeSelectionWeatherType_', '')
    .replace(/_/g, ' ')
    .toLowerCase()
    .replace(/\b\w/g, c => c.toUpperCase());
}

function weatherTemp(key) {
  if (!key) return null;
  const k = key.toUpperCase();
  if (k.includes('CLEAR'))     return 28;
  if (k.includes('SCATTERED')) return 22;
  if (k.includes('BROKEN'))    return 18;
  if (k.includes('OVERCAST'))  return 15;
  if (k.includes('DRIZZLE'))   return 14;
  if (k.includes('HEAVY_RAIN'))return 12;
  if (k.includes('RAIN'))      return 13;
  if (k.includes('DAMP'))      return 16;
  return 20;
}

function weatherIcon(key) {
  if (!key) return '🌤';
  const k = key.toUpperCase();
  if (k.includes('CLEAR'))          return '☀️';
  if (k.includes('FEW_CLOUDS') || k.includes('PARTLY')) return '🌤';
  if (k.includes('SCATTERED') || k.includes('BROKEN')) return '⛅';
  if (k.includes('OVERCAST') || k.includes('CLOUDY')) return '☁️';
  if (k.includes('LIGHT_RAIN') || k.includes('DRIZZLE')) return '🌦';
  if (k.includes('RAIN') || k.includes('SHOWER')) return '🌧';
  if (k.includes('THUNDER') || k.includes('STORM')) return '⛈';
  if (k.includes('SNOW') || k.includes('BLIZZARD')) return '🌨';
  if (k.includes('FOG') || k.includes('MIST'))     return '🌫';
  return '🌤';
}

function fmtSeconds(s) {
  if (!s || s <= 0) return null;
  const h = Math.floor(s / 3600);
  const m = Math.floor((s % 3600) / 60);
  const sec = s % 60;
  if (h > 0) return `${h}h ${m > 0 ? m + 'm' : ''}`.trim();
  if (m > 0) return `${m}m${sec > 0 ? ' ' + sec + 's' : ''}`.trim();
  return `${sec}s`;
}

function buildStatusJson(sid) {
  const settings  = loadSettings(sid);
  const running   = serverProcesses.has(sid);

  const trackParts = (settings.Event?.selectedTrackValue || '').split('|');
  const trackName  = trackParts[0] ? [trackParts[0], trackParts[1]].filter(Boolean).join(' ') : 'N/A';

  const currentPlayers = running ? Array.from(getPlayers(sid).keys()).filter(k => typeof k === 'number').length : 0;

  const driverList = running
    ? Array.from(getPlayers(sid).entries())
        .filter(([k]) => typeof k === 'number')
        .map(([carNum, p]) => {
          const lastLapSectors = p.laps?.length ? p.laps[p.laps.length - 1].sectors : null;
          return {
            name: p.name || '',
            car:  p.car  || '',
            nation: p.nation || '',
            car_number: carNum,
            status: p.status || 'pit',
            lastLap: p.lastLap || null,
            bestLap: p.bestLap || null,
            laps: p.laps?.length || 0,
            lastLapSectors: lastLapSectors ? lastLapSectors.map(s => typeof s === 'number' ? s : null) : null,
            currentSectors: (p.sectors || []).map(s => typeof s === 'number' ? s : null),
          };
        })
    : [];

  const weatherType = settings.Event?.selectedWeatherTypeValue || '';
  const weatherBehaviour = settings.Event?.selectedWeatherBehaviorValue || '';
  const grip = settings.Event?.selectedInitialGripValue || '';

  const publicIp = getPublicIp() || '0.0.0.0';
  return {
    ip:          publicIp,
    port:        settings.tcpPort,
    name:        settings.serverName,
    map:         running ? trackName : 'N/A',
    game:        'Assetto Corsa EVO',
    version:     'N/A',
    players:     running ? currentPlayers : 0,
    max_players: running ? settings.maxPlayers : 0,
    secure:      false,
    country:          lookupCountry(publicIp),
    password_protected: !!(settings.driverPassword && settings.driverPassword.trim().length > 0),
    status:           running ? 'online' : 'offline',
    session:          running ? (liveSession.get(sid)?.type || null) : null,
    driver_list:      driverList,
    car_list:         (() => {
      const selected = settings.selectedCars || [];
      if (!selected.length) return [];
      try {
        const exePath = loadLauncherConfig().executablePath;
        if (!exePath) return selected.sort();
        const allCars = JSON.parse(fs.readFileSync(path.join(path.dirname(exePath), 'cars.json'), 'utf8')).cars || [];
        const nameMap = {};
        allCars.forEach(c => { nameMap[c.name] = c.display_name || c.name; });
        return selected.map(n => nameMap[n] || n).sort();
      } catch { return selected.sort(); }
    })(),
    players_list:     running ? [...getPlayers(sid).entries()]
      .filter(([k]) => typeof k === 'number')
      .map(([, v]) => ({ name: v.name, car: v.car || '' })) : [],
    sessions: (() => {
      const sess = settings.Sessions || {};
      const fmt = (s) => {
        if (!s || !s.isVisible) return null;
        const byLaps = s.duration === 1;
        const label = byLaps
          ? (s.length > 0 ? `${s.length} laps` : null)
          : fmtSeconds(s.length);
        if (!label) return null;
        return { enabled: true, type: byLaps ? 'laps' : 'time', value: s.length, label };
      };
      return {
        practice:  fmt(sess.PracticeSession),
        qualifying: fmt(sess.QualifyingSession),
        warmup:    fmt(sess.WarmupSession),
        race:      fmt(sess.RaceSession),
      };
    })(),
    weather: {
      type:      weatherType,
      label:     weatherLabel(weatherType),
      icon:      weatherIcon(weatherType),
      behaviour: weatherBehaviour
        .replace('GameModeSelectionWeatherBehaviour_', '')
        .toLowerCase()
        .replace(/\b\w/g, c => c.toUpperCase()),
      grip:      grip
        .replace('InitialGrip_', '')
        .toLowerCase()
        .replace(/\b\w/g, c => c.toUpperCase()),
      temp_c:    liveTemp.has(sid) ? liveTemp.get(sid) : weatherTemp(weatherType),
    },
  };
}

async function writeStatusFile(sid) {
  try {
    if (!fs.existsSync(STATUS_DIR)) fs.mkdirSync(STATUS_DIR, { recursive: true });
    const filePath = path.join(STATUS_DIR, `server_${sid}_status.json`);
    fs.writeFileSync(filePath, JSON.stringify(buildStatusJson(sid), null, 2), 'utf8');
  } catch (e) { console.error(`[STATUS] Failed to write status file for server ${sid}:`, e.message); }
}

// Serve status JSON publicly (no auth)
app.get('/status/server_:sid_status.json', async (req, res) => {
  const sid = parseInt(req.params.sid_status);
  if (isNaN(sid) || sid < 1 || sid > SERVER_COUNT()) return res.status(404).json({ error: 'Not found' });
  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET');
  res.json(buildStatusJson(sid));
});

// Patch start/stop to also refresh the status file on disk
function patchStartStop() {
  const origStart = app._router.stack.find(l => l.route?.path === '/api/servers/:sid/start' && l.route?.methods?.post);
  // We hook via process-level events instead — simpler: update file every 10 s when any server is running
}

setInterval(() => {
  const count = SERVER_COUNT();
  for (let i = 1; i <= count; i++) writeStatusFile(i);
}, 10_000);

// ─── Player tracking via log parsing ─────────────────────────────────────────

function getPlayers(sid) {
  if (!connectedPlayers.has(sid)) connectedPlayers.set(sid, new Map());
  return connectedPlayers.get(sid);
}

// ─── Compiled regex patterns (from ACE EVO launcher JAR) ─────────────────────
// Exact patterns extracted from ServerLiveTimingService & AceServerLogAnalysisService
const RE = {
  logLine:      /^\[(?<timestamp>[^\]]+)] \[(?<category>[^\]]+)] \[(?<level>[^\]]+)] (?<message>.*)$/,
  carDriver:    /Car \[(?<carId>[^\]]+)] #(?<number>\d+) for driver (?<driver>.*?) \[(?<steamId>\d+)]/,
  connected:    /(?<steamId>\d+) connected \((?<connection>\d+|true|false)\) on car (?<car>[^,]+), with new carId (?<carId>\S+)/,
  pitSlot:      /Car def found comp (?<competitorId>\S+) pit (?<pit>\d+)/,
  disconnected: /Disconnected carId (?<carId>\S+)/,
  despawning:   /despawning (?<name>.+?) \((?<steamId>\d+)\) on car \((?<carId>[^)]+)\)/,
  serverUpdated:/Server updated: (?<players>\d+) players/,
  purge:        /purge players: (?<alive>\d+) vs \d+/,
  split:        /On Split start \S+ end \S+ id (?<split>\d+) splittime (?<time>\d+)/,
  newLap:       /New lap carId (?<carId>[^:]+): (?<lap>\d{2}:\d{2}\.\d{3})/,
  outplap:      /Outplap split/i,
  pitState:     /\[SERVER\]\[(?<carId>[^\]]+)\] Received pit_state/,
  collision:    /Collision between (?<carId>[0-9a-f-]+) and (?<target>.*?), damage (?<damage>\w+)/,
  sessionStart: /(?<type>[A-Za-z]+Remote\s+(?<kind>[^\s]+)) created/,
  serverStart:  /Start Server (?<name>.+)/,
  trackLimit:   /out_of_track/,
  ambientTemp:  /"ambient_temperature_c":\s*([\d.]+)/,
};

// Parse ACE log lines for player connect/disconnect events
function parsePlayerEvent(sid, text) {
  const players = getPlayers(sid);
  // carId -> carNumber lookup for disconnect
  const carIdToNum = new Map();
  for (const [k, v] of players) {
    if (typeof k === 'number' && v.carId) carIdToNum.set(v.carId, k);
  }

  let m;

  // ── Session type detection ─────────────────────────────────────────────────
  m = RE.sessionStart.exec(text);
  if (m) {
    const kind = m.groups.kind || m.groups.type || '';
    const sessionType = kind.includes('TimeAttack') ? 'Qualifying'
      : kind.includes('InstantRace') || kind.includes('Race') ? 'Race'
      : kind.trim();
    const cur = liveSession.get(sid) || {};
    liveSession.set(sid, { ...cur, type: sessionType, startedAt: Date.now() });
    sessionFastestLap.delete(sid); // reset fastest lap for new session
    discordSessionChange(sid, sessionType);
    return;
  }

  // ── Car + driver name + car number ────────────────────────────────────────
  // "Car [<carId>] #N for driver <Name> [steamId]"
  m = RE.carDriver.exec(text);
  if (m) {
    const { carId, number, driver, steamId } = m.groups;
    const carNum = parseInt(number);
    const pendingKey = 'steam_' + steamId;
    const pending = players.get(pendingKey) || {};
    players.delete(pendingKey);
    players.delete('pending_' + driver);
    // Look up nation from results-db nation cache (non-blocking)
    const nation = resultsDbNationCache.get(steamId) || '';
    players.set(carNum, {
      name: driver,
      car: pending.car || '',
      steamId,
      carId,
      nation,
      connectedAt: pending.connectedAt || Date.now(),
      status: 'pit',
      lastLap: null,
      bestLap: null,
    });
    discordPlayerJoin(sid, driver, pending.car || '', nation);
    return;
  }

  // ── Steam connect + car model ─────────────────────────────────────────────
  // "<steamId> connected (true) on car <model>, with new carId <carId>"
  m = RE.connected.exec(text);
  if (m) {
    const { steamId, car, carId } = m.groups;
    const carModel = car.trim()
      .replace(/^ks_/, '').replace(/_/g, ' ')
      .replace(/\b\w/g, c => c.toUpperCase());
    players.set('steam_' + steamId, { steamId, car: carModel, carId, connectedAt: Date.now(), status: 'pit' });
    return;
  }

  // ── Disconnect by carId (JAR-verified pattern) ────────────────────────────
  // "Disconnected carId <carId>"
  m = RE.disconnected.exec(text);
  if (m) {
    const carId = m.groups.carId.replace(/-/g, '');  // normalize dashes
    for (const [k, v] of players) {
      if (typeof k === 'number') {
        const vId = (v.carId || '').replace(/-/g, '');
        if (vId === carId || vId.includes(carId) || carId.includes(vId)) {
          if (v.name) discordPlayerLeave(sid, v.name);
          players.delete(k); return;
        }
      }
    }
    return;
  }

  // ── Despawn by name + steamId ─────────────────────────────────────────────
  m = RE.despawning.exec(text);
  if (m) {
    const { name, steamId } = m.groups;
    for (const [k, v] of players) {
      if (typeof k === 'number' && (v.name === name || v.steamId === steamId)) {
        discordPlayerLeave(sid, v.name || name);
        players.delete(k); return;
      }
    }
    return;
  }

  // ── Ambient temperature from weather data ─────────────────────────────────
  const tempM = RE.ambientTemp.exec(text);
  if (tempM) {
    liveTemp.set(sid, Math.round(parseFloat(tempM[1]) * 10) / 10);
    return;
  }

  // ── Pit status ────────────────────────────────────────────────────────────
  m = RE.pitState.exec(text);
  if (m) {
    const carId = m.groups.carId.replace(/-/g, '');
    for (const [k, v] of players) {
      if (typeof k === 'number') {
        const vId = (v.carId || '').replace(/-/g, '');
        if (vId === carId || vId.includes(carId) || carId.includes(vId)) {
          players.get(k).status = 'pit';
          players.get(k).sectors = [null, null, null]; // reset sectors on pit
          return;
        }
      }
    }
    return;
  }

  // ── Teleporting = going to pit ────────────────────────────────────────────
  if (/Teleporting .+ pos \d/i.test(text) || /create pitzone from spawn/i.test(text)) {
    // Extract carId from Teleporting line if present
    const tm = /Teleporting ([\w-]+) pos/.exec(text);
    if (tm) {
      const carId = tm[1].replace(/-/g,'');
      for (const [k, v] of players) {
        if (typeof k === 'number') {
          const vId = (v.carId||'').replace(/-/g,'');
          if (vId === carId || vId.includes(carId) || carId.includes(vId)) {
            players.get(k).status = 'pit'; return;
          }
        }
      }
    }
    return;
  }

  // ── Track status (outplap = left pit, now on track) ───────────────────────
  if (RE.outplap.test(text)) {
    // ACE does not include carId in this line — mark a pit-status player as on track
    const real = [...players.entries()].filter(([k]) => typeof k === 'number');
    const inPit = real.filter(([, v]) => v.status === 'pit');
    let target = null;
    if (inPit.length === 1) {
      target = inPit[0][0];
    } else if (inPit.length > 1) {
      // Multiple in pit — pick the most recently connected one
      let latest = 0;
      for (const [k, v] of inPit) {
        if ((v.connectedAt || 0) > latest) { latest = v.connectedAt || 0; target = k; }
      }
    } else if (real.length === 1) {
      target = real[0][0];
    }
    if (target !== null) {
      const p = players.get(target);
      p.status = 'track';
      // Don't reset sectors here — they may already have S1 from the outplap split line
    }
    return;
  }

  // ── Live lap time ──────────────────────────────────────────────────────────
  m = RE.newLap.exec(text);
  if (m) {
    const carId = m.groups.carId.trim().replace(/-/g, '');
    const lap = m.groups.lap;
    for (const [k, v] of players) {
      if (typeof k === 'number') {
        const vId = (v.carId || '').replace(/-/g, '');
        if (vId === carId || vId.includes(carId) || carId.includes(vId)) {
          const p = players.get(k);
          const lapIncident = !!p.hasIncident;
          p.hasIncident = false; // reset for next lap
          const isNonRace = (liveSession.get(sid)?.type || '').toLowerCase() !== 'race';
          p.lastLap = lap;
          p.status = 'track';
          if (!lapIncident || !isNonRace) {
            if (!p.bestLap || lapToMs(lap) < lapToMs(p.bestLap)) p.bestLap = lap;
          }
          if (!p.laps) p.laps = [];
          const lapSectors = [...(p.sectors || [null, null, null])];
          p.laps.push({ lap: p.laps.length + 1, time: lap, sectors: lapSectors, incident: lapIncident });
          p.sectors = [null, null, null];

          // Check if this is a new session fastest lap — skip incident laps in practice/qualifying
          if (!lapIncident || !isNonRace) {
            try {
              const srvSettings = loadSettings(sid);
              const trackParts = (srvSettings.Event?.selectedTrackValue || '').split('|');
              const trackName = trackParts[1] ? `${trackParts[0]} ${trackParts[1]}` : trackParts[0] || 'Unknown';
              const lapMs = lapToMs(lap);
              const cur = sessionFastestLap.get(sid);
              if (!cur || lapMs < cur.timeMs) {
                // Look up previous overall track record from stats cache before updating
                let previousRecord = null;
                try {
                  const statsCache = buildPublicStats();
                  // buildPublicStats formats trackName as "Track Layout" or "Track"
                  const trackRecords = statsCache?.fastestLaps?.[trackName];
                  if (trackRecords && trackRecords.length > 0) {
                    const prev = trackRecords[0];
                    previousRecord = { name: prev.name, time: prev.time, car: prev.car, nation: prev.nation || '', date: prev.date || '' };
                  }
                } catch (e2) { /* stats lookup failure is non-fatal */ }

                sessionFastestLap.set(sid, { name: p.name, track: trackName, timeMs: lapMs, lap, car: p.car });
                discordFastestLap(sid, p.name || 'Unknown', lap, trackName, p.car || 'Unknown', p.nation || '', previousRecord);
              }
            } catch (e) { /* don't break lap tracking */ }
          }

          // Record live lap to statistics
          try {
            const srvSettings = loadSettings(sid);
            const trackParts = (srvSettings.Event?.selectedTrackValue || '').split('|');
            const trackName = trackParts[1] ? `${trackParts[0]} ${trackParts[1]}` : trackParts[0] || 'Unknown';
            recordLiveLap({
              steamId: p.steamId || '',
              name: p.name || 'Unknown',
              nation: p.nation || '',
              car: p.car || '',
              track: trackParts[0] || 'Unknown',
              trackLayout: trackParts[1] || '',
              timeMs: lapToMs(lap),
              sectors: lapSectors.map(s => typeof s === 'number' ? s : null),
              server: srvSettings.serverName || '',
              incident: lapIncident,
              sessionType: liveSession.get(sid)?.type || '',
            });
          } catch (e) { /* don't break lap tracking on stats error */ }
          return;
        }
      }
    }
    return;
  }

  // ── Collision detection — mark driver's current lap as incident ──────────
  m = RE.collision.exec(text);
  if (m) {
    const carId = m.groups.carId.replace(/-/g, '');
    for (const [k, v] of players) {
      if (typeof k === 'number') {
        const vId = (v.carId || '').replace(/-/g, '');
        if (vId === carId || vId.includes(carId) || carId.includes(vId)) {
          players.get(k).hasIncident = true;
          return;
        }
      }
    }
    return;
  }

  // ── Sector split ──────────────────────────────────────────────────────────
  // "On Split start F end F id N splittime T"
  // ACE does not include carId in this line — assign to best candidate
  m = RE.split.exec(text);
  if (m) {
    const splitId = parseInt(m.groups.split);
    const splitTime = parseInt(m.groups.time);

    // Priority: on-track player → most recently active → any single player
    const real = [...players.entries()].filter(([k]) => typeof k === 'number');
    let target = null;

    const onTrack = real.filter(([, v]) => v.status === 'track');
    if (onTrack.length === 1) {
      target = onTrack[0][0];
    } else if (onTrack.length > 1) {
      // Most recently received a sector update
      let latest = -1;
      for (const [k, v] of onTrack) {
        if ((v.lastSectorAt || 0) > latest) { latest = v.lastSectorAt || 0; target = k; }
      }
    } else if (real.length === 1) {
      // Nobody marked as track yet — use the only player
      target = real[0][0];
    }

    if (target !== null) {
      const p = players.get(target);
      if (!p.sectors) p.sectors = [null, null, null];
      p.sectors[splitId] = splitTime;
      p.lastSectorAt = Date.now();
      p.status = 'track';
    }
    return;
  }

  // ── Purge reconciliation ──────────────────────────────────────────────────
  m = RE.purge.exec(text);
  if (m) {
    const alive = parseInt(m.groups.alive);
    const real = [...players.keys()].filter(k => typeof k === 'number');
    if (real.length > alive) {
      const sorted = real.sort((a, b) => (players.get(a)?.connectedAt || 0) - (players.get(b)?.connectedAt || 0));
      for (let i = 0; i < real.length - alive; i++) players.delete(sorted[i]);
    }
    return;
  }

  // ── Server updated: 0 players ─────────────────────────────────────────────
  m = RE.serverUpdated.exec(text);
  if (m && parseInt(m.groups.players) === 0) {
    for (const k of [...players.keys()]) { if (typeof k === 'number') players.delete(k); }
  }
}

function lapToMs(lapStr) {
  if (!lapStr) return Infinity;
  const [min, rest] = lapStr.split(':');
  const [sec, ms] = rest.split('.');
  return parseInt(min) * 60000 + parseInt(sec) * 1000 + parseInt(ms);
}



// Enhanced addLog: parses player events + pushes to SSE
function addLog(sid, text, type = 'info') {
  const logs = getServerLogs(sid);
  const entry = { time: new Date().toISOString(), text, type };
  logs.push(entry);
  if (logs.length > MAX_LOGS) logs.shift();
  if (type === 'stdout' || type === 'stderr') {
    // Snapshot everything that matters — including sectors and laps
    const snapshot = () => JSON.stringify(
      [...getPlayers(sid).entries()]
        .filter(([k]) => typeof k === 'number')
        .map(([k, v]) => [k, v.status, v.lastLap, v.bestLap, v.sectors, v.laps?.length])
    );
    const sessionBefore = JSON.stringify(liveSession.get(sid) || null);
    const before = snapshot();
    parsePlayerEvent(sid, text);
    const after = snapshot();
    const sessionAfter = JSON.stringify(liveSession.get(sid) || null);
    if (before !== after || sessionBefore !== sessionAfter) {
      const players = [...getPlayers(sid).entries()]
        .filter(([k]) => typeof k === 'number')
        .map(([k, v]) => ({
          car_number: k, name: v.name, car: v.car, nation: v.nation || '',
          status: v.status || 'pit', connectedAt: v.connectedAt,
          lastLap: v.lastLap || null, bestLap: v.bestLap || null,
          sectors: v.sectors || [null, null, null],
          laps: v.laps || [],
        }));
      const session = liveSession.get(sid) || null;
      const playerEvent = `data: ${JSON.stringify({ type: 'players', players, session })}\n\n`;
      getSseClients(sid).forEach(res => { try { res.write(playerEvent); } catch {} });
    }
  }
  // Push log line via SSE
  const clients = getSseClients(sid);
  if (clients.size > 0) {
    const data = `data: ${JSON.stringify(entry)}\n\n`;
    clients.forEach(res => { try { res.write(data); } catch {} });
  }
}

// Extend proc exit handler — players cleared in start server section

// GET /api/servers/:sid/players — list connected players
app.get('/api/servers/:sid/players', requireAuth, async (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  if (!serverProcesses.has(sid)) return res.json({ players: [], session: null, note: 'Server offline' });

  const tracked = getPlayers(sid);
  const session = liveSession.get(sid) || null;
  const recentLogs = getServerLogs(sid).slice(-30).map(l => l.text);

  if (tracked.size === 0) {
    return res.json({ players: [], session, note: 'No players detected in logs yet', recentLogs });
  }

  const players = Array.from(tracked.entries())
    .filter(([k]) => typeof k === 'number')
    .map(([carNum, p]) => ({
      car_number: carNum,
      name: p.name,
      car: p.car,
      nation: p.nation || '',
      steamId: p.steamId || '',
      connected: true,
      connectedAt: p.connectedAt,
      status: p.status || 'pit',
      lastLap: p.lastLap || null,
      bestLap: p.bestLap || null,
      sectors: p.sectors || [null, null, null],
      laps: p.laps || [],
    }));

  res.json({ players, session, source: 'log', recentLogs });
});

// ── ACE HTTP Admin API helper ─────────────────────────────────────────────────
async function aceAdminApi(sid, path, body = null) {
  const settings = loadSettings(sid);
  const port = settings.httpPort || 8080;
  const adminPwd = settings.adminPassword || '';
  const url = `http://localhost:${port}${path}`;
  const opts = {
    method: body ? 'POST' : 'GET',
    headers: {
      'Content-Type': 'application/json',
      'X-Admin-Password': adminPwd,
    },
  };
  if (body) opts.body = JSON.stringify(body);
  const http = require('http');
  return new Promise((resolve, reject) => {
    const req = http.request(url, opts, res => {
      let data = '';
      res.on('data', d => data += d);
      res.on('end', () => {
        try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
        catch { resolve({ status: res.statusCode, body: data }); }
      });
    });
    req.on('error', reject);
    req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); });
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

// POST /api/servers/:sid/command — send admin command via ACE HTTP API
app.post('/api/servers/:sid/command', requireAdminOrOwner, async (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  const { command } = req.body;
  if (!command || typeof command !== 'string') return res.status(400).json({ error: 'command required' });

  const proc = serverProcesses.get(sid);
  if (!proc) return res.status(404).json({ error: 'Server not running' });

  const cmd = command.trim();
  addLog(sid, `[ADMIN CMD] ${cmd}`, 'info');

  try {
    // Try ACE HTTP admin API first
    const result = await aceAdminApi(sid, '/api/v1/admin/command', { command: cmd });
    res.json({ ok: true, sent: cmd, result: result.body });
  } catch (e) {
    // Fallback: stdin (may not work with Wine but try anyway)
    try {
      if (proc.stdin && !proc.stdin.destroyed) {
        proc.stdin.write(cmd + '\n');
        res.json({ ok: true, sent: cmd, method: 'stdin' });
      } else {
        res.status(500).json({ error: 'ACE HTTP API unreachable and stdin unavailable: ' + e.message });
      }
    } catch (e2) {
      res.status(500).json({ error: e.message });
    }
  }
});

// POST /api/servers/:sid/chat — broadcast message to all players
app.post('/api/servers/:sid/chat', requireAdminOrOwner, async (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  const { message } = req.body;
  if (!message || typeof message !== 'string') return res.status(400).json({ error: 'message required' });

  const proc = serverProcesses.get(sid);
  if (!proc) return res.status(404).json({ error: 'Server not running' });

  addLog(sid, `[BROADCAST] ${message}`, 'info');

  try {
    const result = await aceAdminApi(sid, '/api/v1/admin/broadcast', { message: message.trim() });
    res.json({ ok: true, message: message.trim(), result: result.body });
  } catch (e) {
    res.status(500).json({ error: 'Could not reach ACE HTTP API: ' + e.message });
  }
});

app.get('/api/servers/:sid/logs', (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  const since = parseInt(req.query.since || '0', 10);
  res.json(getServerLogs(sid).slice(since));
});

// ─── Discord Webhook API ─────────────────────────────────────────────────────
app.get('/api/discord/settings', requireAdmin, (req, res) => {
  res.json(loadDiscordSettings());
});

app.post('/api/discord/settings', requireAdmin, (req, res) => {
  const s = loadDiscordSettings();
  const body = req.body;
  if (body.webhookUrl !== undefined) s.webhookUrl = String(body.webhookUrl).trim();
  if (body.botName !== undefined)    s.botName = String(body.botName).trim() || 'ACE Manager';
  if (body.playerJoin !== undefined)    s.playerJoin = !!body.playerJoin;
  if (body.playerLeave !== undefined)   s.playerLeave = !!body.playerLeave;
  if (body.sessionChange !== undefined) s.sessionChange = !!body.sessionChange;
  if (body.raceResults !== undefined)   s.raceResults = !!body.raceResults;
  if (body.serverStart !== undefined)   s.serverStart = !!body.serverStart;
  if (body.serverStop !== undefined)    s.serverStop = !!body.serverStop;
  if (body.fastestLap !== undefined)    s.fastestLap = !!body.fastestLap;
  // Per-event webhook URLs
  if (body.webhooks && typeof body.webhooks === 'object') {
    if (!s.webhooks) s.webhooks = {};
    for (const key of ['playerJoin', 'playerLeave', 'sessionChange', 'raceResults', 'serverStart', 'serverStop', 'fastestLap']) {
      if (body.webhooks[key] !== undefined) s.webhooks[key] = String(body.webhooks[key]).trim();
    }
  }
  saveDiscordSettingsFile(s);
  res.json({ ok: true });
});

app.post('/api/discord/test', requireAdmin, async (req, res) => {
  const dc = loadDiscordSettings();
  if (!dc.webhookUrl) return res.status(400).json({ error: 'No webhook URL configured' });
  await sendDiscordWebhook({
    color: 0x00a8ff,
    title: '🧪 Test Notification',
    description: 'ACE Manager webhook is working!',
    timestamp: new Date().toISOString(),
  });
  res.json({ ok: true, log: discordLog[0] || null });
});

app.get('/api/discord/log', requireAdmin, (req, res) => {
  res.json(discordLog);
});

// SSE — real-time log stream
app.get('/api/servers/:sid/logs/stream', (req, res) => {
  const sid = parseSid(req, res); if (!sid) return;
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  // Send existing logs immediately so the box isn't empty on connect
  const existing = getServerLogs(sid);
  if (existing.length) {
    res.write(`data: ${JSON.stringify({ type: 'history', logs: existing })}\n\n`);
  }
  // Also send current player state + session
  const currentPlayers = [...getPlayers(sid).entries()]
    .filter(([k]) => typeof k === 'number')
    .map(([k, v]) => ({ car_number: k, name: v.name, car: v.car, status: v.status || 'pit', connectedAt: v.connectedAt, lastLap: v.lastLap || null, bestLap: v.bestLap || null }));
  const session = liveSession.get(sid) || null;
  if (currentPlayers.length || session) {
    res.write(`data: ${JSON.stringify({ type: 'players', players: currentPlayers, session })}\n\n`);
  }

  // Keep-alive ping every 20s
  const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20000);

  const clients = getSseClients(sid);
  clients.add(res);

  req.on('close', () => {
    clearInterval(ping);
    clients.delete(res);
  });
});

// ─── Presets (shared Event + Sessions configs) ────────────────────────────────

// List all presets
app.get('/api/presets', (req, res) => res.json(loadPresets()));

// Save a new preset (or overwrite by name)
app.post('/api/presets', requireAdminOrOwner, (req, res) => {
  const { name, event, sessions, maxPlayers, maxPlayersLimit, selectedCars } = req.body;
  if (!name || !event || !sessions) return res.status(400).json({ error: 'name, event and sessions are required' });
  const presets = loadPresets();
  const idx = presets.findIndex(p => p.name === name);
  const preset = {
    name, event, sessions,
    maxPlayers:      maxPlayers      ?? 8,
    maxPlayersLimit: maxPlayersLimit ?? 40,
    selectedCars:    selectedCars    || [],
    savedAt: new Date().toISOString()
  };
  if (idx >= 0) presets[idx] = preset;
  else presets.push(preset);
  savePresets(presets);
  res.json({ ok: true });
});

// Delete a preset by name
app.delete('/api/presets/:name', requireAdminOrOwner, (req, res) => {
  const name = decodeURIComponent(req.params.name);
  const presets = loadPresets().filter(p => p.name !== name);
  savePresets(presets);
  res.json({ ok: true });
});

// ─── Car Categories ───────────────────────────────────────────────────────────

// GET /api/car-categories — handled before requireAuth (public endpoint)

// POST /api/car-categories — create or update a category (admin/owner)
app.post('/api/car-categories', requireAdminOrOwner, (req, res) => {
  const { name, cars } = req.body;
  if (!name || typeof name !== 'string' || !name.trim())
    return res.status(400).json({ error: 'name is required' });
  const cats = loadCategories();
  const idx = cats.findIndex(c => c.name === name.trim());
  const entry = { name: name.trim(), cars: Array.isArray(cars) ? cars : [], updatedAt: new Date().toISOString() };
  if (idx >= 0) cats[idx] = entry; else cats.push(entry);
  saveCategories(cats);
  res.json({ ok: true });
});

// DELETE /api/car-categories/:name — remove a category (admin/owner)
app.delete('/api/car-categories/:name', requireAdminOrOwner, (req, res) => {
  const name = decodeURIComponent(req.params.name);
  const cats = loadCategories().filter(c => c.name !== name);
  saveCategories(cats);
  res.json({ ok: true });
});

// ─── Public stats endpoints ───────────────────────────────────────────────────

// GET /stats — full stats (public, no auth)
app.get('/stats', (req, res) => res.json(buildPublicStats()));

// GET /stats/drivers — driver rankings only
app.get('/stats/drivers', (req, res) => {
  const s = buildPublicStats();
  res.json({ lastUpdated: s.lastUpdated, totalSessions: s.totalSessions, drivers: s.driverRankings });
});

// GET /stats/fastest — fastest laps per track
app.get('/stats/fastest', (req, res) => {
  const s = buildPublicStats();
  res.json({ lastUpdated: s.lastUpdated, fastestLaps: s.fastestLaps });
});

// GET /stats/safety — safety ratings
app.get('/stats/safety', (req, res) => {
  const s = buildPublicStats();
  res.json({ lastUpdated: s.lastUpdated, ...s.safetyRatings });
});

// DELETE /api/stats/fastest-lap — remove a fastest lap entry (admin only)
app.delete('/api/stats/fastest-lap', requireAdmin, (req, res) => {
  const { track, lapKey } = req.body || {};
  if (!track || !lapKey) return res.status(400).json({ error: 'track and lapKey required' });
  const ok = deleteFastestLap(track, lapKey);
  if (ok) {
    console.log(`[STATS] Admin deleted fastest lap: ${track} / ${lapKey}`);
    res.json({ ok: true });
  } else {
    res.status(404).json({ error: 'Entry not found' });
  }
});

// ─── Start results watcher ────────────────────────────────────────────────────

// ─── Start ────────────────────────────────────────────────────────────────────
const cfg0 = loadLauncherConfig();
const { execSync: execSync0 } = require('child_process');

function printStartupInfo(protocol) {
  const cfg = loadLauncherConfig();
  const isWindows = process.platform === 'win32';
  const platformLabel = isWindows ? 'Windows' : 'Linux/Ubuntu';
  let wineVer = 'not found';
  if (!isWindows) { try { wineVer = execSync0('wine --version 2>/dev/null', { timeout: 2000 }).toString().trim(); } catch {} }
  const cnt = Math.max(1, parseInt(cfg.serverCount)||1);
  console.log(`\n --------------------------------------------`);
  console.log(`  ACE Manager - ${platformLabel}`);
  console.log(` --------------------------------------------`);
  console.log(`  Web UI      : ${protocol}://localhost:${PORT}`);
  console.log(`  Stats API   : ${protocol}://localhost:${PORT}/stats`);
  console.log(`  Executable  : ${cfg.executablePath || '(not set — configure in config.json)'}`);
  console.log(`  Servers     : ${cnt}`);
  console.log(`  Password    : ${'*'.repeat((cfg.adminPassword||'').length)}`);
  if (!isWindows) console.log(`  Wine        : ${wineVer}`);
  console.log(`  Node.js     : ${process.version}`);
  console.log(` --------------------------------------------`);
  for (let i = 1; i <= cnt; i++) console.log(`  Status JSON : ${protocol}://localhost:${PORT}/status/server_${i}_status.json`);
  console.log(`\n  Press Ctrl+C to stop.\n`);
  startWatcher();
  // Enable Discord notifications now that startup is complete
  setTimeout(() => { discordReady = true; console.log('[DISCORD] Webhook notifications enabled'); }, 2000);
}

const sslCert = cfg0.sslCert || '';
const sslKey  = cfg0.sslKey  || '';

if (sslCert && sslKey && fs.existsSync(sslCert) && fs.existsSync(sslKey)) {
  const https = require('https');
  const sslOptions = {
    cert: fs.readFileSync(sslCert),
    key:  fs.readFileSync(sslKey)
  };
  https.createServer(sslOptions, app).listen(PORT, '0.0.0.0', () => printStartupInfo('https'));
} else {
  if (sslCert || sslKey) {
    console.warn(`[WARN] SSL cert/key defined in config.json but file(s) not found — falling back to HTTP.`);
    if (sslCert) console.warn(`       sslCert: ${sslCert}`);
    if (sslKey)  console.warn(`       sslKey : ${sslKey}`);
  }
  app.listen(PORT, '0.0.0.0', () => printStartupInfo('http'));
}
