/**
 * End-to-end test of the real API over HTTP.
 *
 * Drives two independent "clients" through matchmaking, a complete chess game,
 * and a full tournament round — exercising api/game.php, api/matchmaking.php,
 * api/tournament-match.php, api/tournament-admin.php and api/cron.php exactly as
 * a browser would, against a local Postgres carrying the production schema.
 */
import fs from 'node:fs';
import { execSync } from 'node:child_process';

const API = 'http://127.0.0.1:8788/api';
const PSQL = `psql -h 127.0.0.1 -p 54329 -U postgres -tAc`;

const src = fs.readFileSync(new URL('../public/js/lib/chess.min.js', import.meta.url), 'utf8');
const mod = { exports: {} };
new Function('module', 'exports', 'globalThis', src)(mod, mod.exports, globalThis);
const Chess = globalThis.Chess || mod.exports.Chess || mod.exports;

const failures = [];
function check(label, cond, detail = '') {
  if (cond) { console.log(`  ok    ${label}`); return true; }
  failures.push(label);
  console.log(`  FAIL  ${label}${detail ? ' — ' + detail : ''}`);
  return false;
}

const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const token = (sub) => `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64({ sub })}.sig`;

async function call(endpoint, body, user) {
  const res = await fetch(`${API}/${endpoint}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token(user)}` },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  try { return { status: res.status, data: JSON.parse(text) }; }
  catch { return { status: res.status, data: { _raw: text.slice(0, 300) } }; }
}

async function get(endpoint, params, user) {
  const qs = new URLSearchParams(params).toString();
  const res = await fetch(`${API}/${endpoint}?${qs}`, {
    headers: { Authorization: `Bearer ${token(user)}` },
  });
  const text = await res.text();
  try { return { status: res.status, data: JSON.parse(text) }; }
  catch { return { status: res.status, data: { _raw: text.slice(0, 300) } }; }
}

const sql = (q) => execSync(`${PSQL} "${q.replace(/"/g, '\\"')}"`).toString().trim();

// ---------------------------------------------------------------------------
const P = [
  '11111111-1111-1111-1111-111111111111',
  '22222222-2222-2222-2222-222222222222',
  '33333333-3333-3333-3333-333333333333',
  '44444444-4444-4444-4444-444444444444',
];

console.log('=== setup ===');
sql(`DELETE FROM matches; DELETE FROM el3ab_tournament_rounds; DELETE FROM tournament_registrations; DELETE FROM el3ab_tournaments; DELETE FROM profiles;`);
sql(`CREATE TABLE IF NOT EXISTS matchmaking_queue (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, game_key text, time_control time_control, rating int, status text, matched_with uuid, match_id uuid, queued_at timestamptz DEFAULT now())`);
sql(`CREATE TABLE IF NOT EXISTS player_blocks (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, blocked_id uuid, type text)`);
sql(`CREATE TABLE IF NOT EXISTS admin_users (id uuid PRIMARY KEY)`);
sql(`CREATE TABLE IF NOT EXISTS achievements (id text PRIMARY KEY, "condition" jsonb, coins_reward int, xp_reward int)`);
sql(`CREATE TABLE IF NOT EXISTS player_achievements (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), player_id uuid, achievement_id text, progress int, completed bool, completed_at timestamptz)`);
sql(`DELETE FROM matchmaking_queue;`);
P.forEach((id, i) => sql(
  `INSERT INTO profiles (id, display_name, username, elo_rapid, elo_blitz, is_admin) VALUES ('${id}','Player ${i + 1}','p${i + 1}',${1900 - i * 60},${1850 - i * 50}, ${i === 0})`
));
console.log('  seeded 4 players');

// ---------------------------------------------------------------------------
console.log('\n=== 1. matchmaking hand-off ===');
let r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('player A is queued', r.data.queued === true, JSON.stringify(r.data));

r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1]);
const bMatch = r.data;
check('player B is matched immediately', !!bMatch.match_id, JSON.stringify(bMatch));
check('player B receives a colour', bMatch.color === 'w' || bMatch.color === 'b', bMatch.color);

// The critical regression: A re-queueing (the 60s retry) must NOT destroy the hand-off.
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('A re-queueing returns the SAME match instead of orphaning it',
  r.data.match_id === bMatch.match_id, `${r.data.match_id} vs ${bMatch.match_id}`);
const aColor = r.data.color;
check('A receives a colour', aColor === 'w' || aColor === 'b', String(aColor));
check('the two players have OPPOSITE colours', aColor && bMatch.color && aColor !== bMatch.color,
  `A=${aColor} B=${bMatch.color}`);

const matchId = bMatch.match_id;
const white = aColor === 'w' ? P[0] : P[1];
const black = aColor === 'w' ? P[1] : P[0];

console.log('\n=== 2. server tells each client its own colour ===');
let g = await call('game.php', { action: 'get', match_id: matchId }, white);
check('white is told my_color=w', g.data.my_color === 'w', JSON.stringify(g.data.my_color));
g = await call('game.php', { action: 'get', match_id: matchId }, black);
check('black is told my_color=b', g.data.my_color === 'b', JSON.stringify(g.data.my_color));
check('opponent_id is supplied', g.data.opponent_id === white, g.data.opponent_id);

g = await call('game.php', { action: 'get', match_id: matchId }, P[2]);
check('a spectator can watch the live board', g.status === 200 && g.data.spectating === true, `status ${g.status}`);
check('a spectator sees the position and clocks', !!g.data.current_fen && g.data.white_time_remaining_ms != null);
check('a spectator is not told a colour of their own', g.data.my_color === undefined, String(g.data.my_color));
check('a spectator does not receive private game state', g.data.game_state === undefined);

// ---------------------------------------------------------------------------
console.log('\n=== 3. turn ownership ===');
const c = new Chess();
// Black tries to move while it is White's turn.
c.move('e4');
r = await call('game.php', {
  action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
  move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, black);
check('a move from the wrong player is rejected', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);

r = await call('game.php', {
  action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
  move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, white);
check('white\'s own move is accepted', r.data.success === true, JSON.stringify(r.data));

r = await call('game.php', {
  action: 'move', match_id: matchId, fen: c.fen(), move_count: 1,
  move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, white);
check('a replayed move_count is treated as a duplicate, not applied twice', r.data.duplicate === true, JSON.stringify(r.data));

// A tampered position (conjured queen) must be refused.
const cheat = 'rnbqkbnr/pppppppp/8/8/4P3/5Q2/PPPP1PPP/RNBQKBNR b KQkq - 0 1';
r = await call('game.php', { action: 'move', match_id: matchId, fen: cheat, move_count: 2 }, black);
check('a fabricated position is rejected', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);

// ---------------------------------------------------------------------------
console.log('\n=== 4. play the Opera Game to checkmate ===');
// Fresh match: section 3 already advanced the first board.
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
const gm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1])).data;
const gMatchId = gm.match_id;
const gInfo = (await call('game.php', { action: 'get', match_id: gMatchId }, P[0])).data;
const gWhite = gInfo.my_color === 'w' ? P[0] : P[1];
const gBlack = gWhite === P[0] ? P[1] : P[0];
check('fresh match for the full game', !!gMatchId, JSON.stringify(gm));
const OPERA = 'e4 e5 Nf3 d6 d4 Bg4 dxe5 Bxf3 Qxf3 dxe5 Bc4 Nf6 Qb3 Qe7 Nc3 c6 Bg5 b5 Nxb5 cxb5 Bxb5+ Nbd7 O-O-O Rd8 Rxd7 Rxd7 Rd1 Qe6 Bxd7+ Nxd7 Qb8+ Nxb8 Rd8#'.split(' ');
const game = new Chess();
const history = [];
let mc = 0;
let rejected = 0;
for (const san of OPERA) {
  const mover = game.turn();
  const m = game.move(san);
  history.push({ from: m.from, to: m.to, san: m.san });
  mc++;
  const who = mover === 'w' ? gWhite : gBlack;
  const res = await call('game.php', {
    action: 'move', match_id: gMatchId, fen: game.fen(), move_count: mc,
    move: JSON.stringify(history),
  }, who);
  if (res.data.success !== true) { rejected++; console.log(`      move ${mc} (${san}) -> ${JSON.stringify(res.data)}`); }
}
check(`all ${OPERA.length} legal moves accepted`, rejected === 0, `${rejected} rejected`);
check('the game is checkmate', game.isCheckmate ? game.isCheckmate() : game.in_checkmate());

console.log('\n=== 5. the server decides the winner, not the client ===');
// The LOSER reports first, falsely claiming a win.
const loser = game.turn() === 'w' ? gWhite : gBlack;   // side to move is mated
const winner = loser === gWhite ? gBlack : gWhite;
r = await call('game.php', {
  action: 'complete', match_id: gMatchId, reason: 'checkmate', result: 'win',
  fen: game.fen(), pgn: game.pgn(),
}, loser);
check('the loser\'s false win claim does not win them the game',
  r.data.winner === (winner === gWhite ? 'white' : 'black'),
  JSON.stringify(r.data));
check('result is a canonical enum value',
  ['white_wins', 'black_wins', 'draw', 'aborted'].includes(r.data.result), r.data.result);

const row = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, result, metadata FROM matches WHERE id='${gMatchId}') t`));
check('match is marked completed in the database', row.status === 'completed', row.status);
check('result persisted', row.result === (winner === gWhite ? 'white_wins' : 'black_wins'), row.result);
check('metadata records the winner', row.metadata.winner === (winner === gWhite ? 'white' : 'black'), JSON.stringify(row.metadata));
check('metadata records the reason', row.metadata.end_reason === 'checkmate', JSON.stringify(row.metadata));

r = await call('game.php', { action: 'complete', match_id: gMatchId, reason: 'checkmate' }, winner);
check('the second report is idempotent', r.data.already_completed === true, JSON.stringify(r.data));

// ---------------------------------------------------------------------------
console.log('\n=== 6. resignation ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[2]);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[3]);
const m2 = r.data.match_id;
check('second match created', !!m2, JSON.stringify(r.data));
const g2 = await call('game.php', { action: 'get', match_id: m2 }, P[2]);
const p2Color = g2.data.my_color;
r = await call('game.php', { action: 'resign', match_id: m2 }, P[2]);
check('resigning hands the win to the opponent',
  r.data.result === (p2Color === 'w' ? 'black_wins' : 'white_wins'), JSON.stringify(r.data));

// ---------------------------------------------------------------------------
console.log('\n=== 7. draw offers need agreement ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[0]);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[1]);
const m3 = r.data.match_id;
r = await call('game.php', { action: 'draw', match_id: m3 }, P[0]);
check('a draw cannot be taken without an offer standing', r.status === 409, `status ${r.status} ${JSON.stringify(r.data)}`);

sql(`UPDATE matches SET game_state = '{"draw_offer":"${P[1]}"}'::jsonb WHERE id='${m3}'`);
r = await call('game.php', { action: 'draw', match_id: m3 }, P[0]);
check('a draw offered by the opponent can be accepted', r.data.result === 'draw', JSON.stringify(r.data));

// ---------------------------------------------------------------------------
console.log('\n=== 8. tournament: create, start, pair ===');
r = await call('tournament-admin.php', {
  action: 'create', name: 'E2E Cup', format: 'swiss', time_control: 'blitz_3_0',
  rounds: 3, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const tid = r.data.tournament?.id;
check('tournament created', !!tid, JSON.stringify(r.data));

for (const p of P) {
  const rr = await call('tournaments.php', { action: 'register', tournament_id: tid }, p);
  if (!rr.data.success) check(`register ${p.slice(0, 8)}`, false, JSON.stringify(rr.data));
}
r = await call('tournaments.php', { action: 'register', tournament_id: tid }, P[0]);
check('registering twice is idempotent', r.data.already_registered === true, JSON.stringify(r.data));

r = await call('tournament-admin.php', { action: 'start', tournament_id: tid }, P[0]);
check('tournament starts', r.data.ok === true, JSON.stringify(r.data));
check('round 1 has 2 boards for 4 players', r.data.round?.pairings?.length === 2, JSON.stringify(r.data.round?.pairings?.length));

r = await call('tournament-admin.php', { action: 'start', tournament_id: tid }, P[1]);
check('a non-admin cannot start a tournament', r.status === 403, `status ${r.status}`);

console.log('\n=== 9. both paired players open the SAME board ===');
const pairing = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, P[0])).data.pairings[0];
const [pw, pb] = [pairing.white_id, pairing.black_id];
const [ra, rb] = await Promise.all([
  call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, pw),
  call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, pb),
]);
check('both clients receive a match', !!ra.data.match_id && !!rb.data.match_id, JSON.stringify([ra.data, rb.data]));
check('both are on the SAME match', ra.data.match_id === rb.data.match_id, `${ra.data.match_id} vs ${rb.data.match_id}`);
check('their colours are opposite', ra.data.color !== rb.data.color, `${ra.data.color} / ${rb.data.color}`);
check('white is white', ra.data.color === 'w', ra.data.color);
const nMatches = sql(`SELECT count(*) FROM matches WHERE tournament_id='${tid}'`);
check('exactly one match row was created for that pairing', nMatches === '1', `${nMatches} rows`);

console.log('\n=== 10. results advance the round ===');
const pairings = (await get('swiss.php', { action: 'pairings', tournament_id: tid }, P[0])).data.pairings;
for (const p of pairings) {
  const j = await call('tournament-match.php', { action: 'create-or-join', tournament_id: tid }, p.white_id);
  await call('game.php', { action: 'resign', match_id: j.data.match_id }, p.black_id);
}
const t2 = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
check('the round advanced automatically once all games finished', t2.current_round === 2, JSON.stringify(t2));

const st = (await get('swiss.php', { action: 'standings', tournament_id: tid }, P[0])).data.standings;
check('standings are populated', st.length === 4, `${st.length} rows`);
check('the two winners lead on 1 point', st[0].points === 1 && st[1].points === 1, JSON.stringify(st.map(s => s.points)));
check('standings carry tiebreaks', typeof st[0].tiebreaks?.buchholz_cut_1 === 'number');

console.log('\n=== 11. no-show forfeits keep the round moving ===');
const before = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
sql(`UPDATE el3ab_tournament_rounds SET started_at = now() - interval '1 hour' WHERE tournament_id='${tid}' AND round_number=${before.current_round}`);
const cron = await fetch('http://127.0.0.1:8788/api/cron.php?secret=testsecret');
const cronData = await cron.json();
check('cron ran', cron.status === 200, JSON.stringify(cronData).slice(0, 200));
check('cron forfeited the unplayed games', (cronData.forfeited || []).length > 0, JSON.stringify(cronData.forfeited));
const after = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid}') t`));
check('the tournament moved on rather than stalling',
  after.current_round > before.current_round || after.status === 'completed', JSON.stringify(after));

console.log('\n=== 12. cron auto-starts a due tournament ===');
r = await call('tournament-admin.php', {
  action: 'create', name: 'Auto Start Cup', format: 'swiss', time_control: 'blitz_3_0',
  rounds: 2, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const tid2 = r.data.tournament.id;
for (const p of P) await call('tournaments.php', { action: 'register', tournament_id: tid2 }, p);
sql(`UPDATE el3ab_tournaments SET starts_at = now() - interval '1 minute' WHERE id='${tid2}'`);
const cron2 = await (await fetch('http://127.0.0.1:8788/api/cron.php?secret=testsecret')).json();
check('cron started the due tournament', (cron2.started || []).some(s => s.id === tid2), JSON.stringify(cron2.started));
const t3 = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${tid2}') t`));
check('it is now in progress on round 1', t3.status === 'in_progress' && t3.current_round === 1, JSON.stringify(t3));

const noSecret = await fetch('http://127.0.0.1:8788/api/cron.php');
check('cron refuses an unauthenticated call', noSecret.status === 403, `status ${noSecret.status}`);

console.log('\n=== 13. the server clock flags an absent player ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[2]);
const cm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_3_0' }, P[3])).data;
const cMatch = cm.match_id;
const cInfo = (await call('game.php', { action: 'get', match_id: cMatch }, P[2])).data;
const cWhite = cInfo.my_color === 'w' ? P[2] : P[3];
const cBlack = cWhite === P[2] ? P[3] : P[2];

// One move played, then White walks away with 5 seconds left and an hour passes.
const cg = new Chess();
cg.move('e4');
await call('game.php', {
  action: 'move', match_id: cMatch, fen: cg.fen(), move_count: 1,
  move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4' }]),
}, cWhite);
cg.move('e5');
await call('game.php', {
  action: 'move', match_id: cMatch, fen: cg.fen(), move_count: 2,
  move: JSON.stringify([{ from: 'e7', to: 'e5', san: 'e5' }]),
}, cBlack);

sql(`UPDATE matches SET white_time_remaining_ms = 3000, metadata = jsonb_set(metadata, '{last_move_at}', to_jsonb((now() - interval '1 hour')::text)) WHERE id='${cMatch}'`);

const flagged = (await call('game.php', { action: 'get', match_id: cMatch }, cBlack)).data;
check('the absent player is flagged on time', flagged.status === 'completed', JSON.stringify({ s: flagged.status, r: flagged.result }));
check('the player who ran out of time loses',
  flagged.result === (cWhite === P[2] ? (cInfo.my_color === 'w' ? 'black_wins' : 'black_wins') : 'black_wins'),
  flagged.result);
const cRow = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT result, metadata FROM matches WHERE id='${cMatch}') t`));
check('the end reason is recorded as a timeout', cRow.metadata.end_reason === 'timeout', JSON.stringify(cRow.metadata));
check('white flagged, so black wins', cRow.result === 'black_wins', cRow.result);

console.log('\n=== 14. a player still on the clock is NOT flagged ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
const om = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1])).data;
const oMatch = om.match_id;
const oInfo = (await call('game.php', { action: 'get', match_id: oMatch }, P[0])).data;
const oWhite = oInfo.my_color === 'w' ? P[0] : P[1];
const og = new Chess();
og.move('d4');
await call('game.php', {
  action: 'move', match_id: oMatch, fen: og.fen(), move_count: 1,
  move: JSON.stringify([{ from: 'd2', to: 'd4', san: 'd4' }]),
}, oWhite);
sql(`UPDATE matches SET metadata = jsonb_set(metadata, '{last_move_at}', to_jsonb((now() - interval '30 seconds')::text)) WHERE id='${oMatch}'`);
const notFlagged = (await call('game.php', { action: 'get', match_id: oMatch }, oWhite)).data;
check('a thinking player is not flagged', notFlagged.status === 'in_progress', notFlagged.status);
check('their remaining time is reported, reduced', notFlagged.black_time_remaining_ms < 600000 && notFlagged.black_time_remaining_ms > 500000,
  String(notFlagged.black_time_remaining_ms));

console.log('\n=== 15. heartbeats let each client see the other is alive ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[2]);
const hm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[3])).data;
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[2]);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('both players\' heartbeats are recorded', !!hs[`hb_${P[2]}`] && !!hs[`hb_${P[3]}`], JSON.stringify(hs));
check('one heartbeat does not overwrite the other', Object.keys(hs).filter(k => k.startsWith('hb_')).length === 2, JSON.stringify(Object.keys(hs)));

// A draw offer must survive a heartbeat landing on the same row.
sql(`UPDATE matches SET game_state = game_state || '{"draw_offer":"${P[2]}"}'::jsonb WHERE id='${hm.match_id}'`);
await call('game.php', { action: 'heartbeat', match_id: hm.match_id }, P[3]);
const hs2 = JSON.parse(sql(`SELECT game_state FROM matches WHERE id='${hm.match_id}'`));
check('a heartbeat does not clobber a standing draw offer', hs2.draw_offer === P[2], JSON.stringify(hs2));

console.log('\n=== 16. tournaments progress without any external scheduler ===');
// Everything below runs purely off ordinary player/API traffic — no cron.php.
r = await call('tournament-admin.php', {
  action: 'create', name: 'No Cron Cup', format: 'swiss', time_control: 'blitz_3_0',
  rounds: 2, min_players: 4, max_players: 8, auto_start: true,
}, P[0]);
const ncid = r.data.tournament.id;
for (const p of P) await call('tournaments.php', { action: 'register', tournament_id: ncid }, p);
sql(`UPDATE el3ab_tournaments SET starts_at = now() - interval '1 minute' WHERE id='${ncid}'`);

// Merely listing tournaments (what the lobby does) must start a due one.
await get('tournaments.php', { action: 'list' }, P[0]);
let ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('browsing the tournament list started the due tournament',
  ncT.status === 'in_progress' && ncT.current_round === 1, JSON.stringify(ncT));

// One board plays; the other pair never shows. The round must still close.
const ncPairs = (await get('swiss.php', { action: 'pairings', tournament_id: ncid }, P[0])).data.pairings;
const played = ncPairs[0];
const j = await call('tournament-match.php', { action: 'create-or-join', tournament_id: ncid }, played.white_id);
await call('game.php', { action: 'resign', match_id: j.data.match_id }, played.black_id);

ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('the round does not advance while a game is genuinely outstanding',
  ncT.current_round === 1, JSON.stringify(ncT));

// Age the round past the no-show grace period, then have a player check in.
sql(`UPDATE el3ab_tournament_rounds SET started_at = now() - interval '2 hours' WHERE tournament_id='${ncid}' AND round_number=1`);
await get('tournament-match.php', { action: 'my-pending' }, played.white_id);

ncT = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT status, current_round FROM el3ab_tournaments WHERE id='${ncid}') t`));
check('a player checking in forfeited the no-show and advanced the round',
  ncT.current_round === 2 || ncT.status === 'completed', JSON.stringify(ncT));

const ncRound1 = JSON.parse(sql(`SELECT pairings FROM el3ab_tournament_rounds WHERE tournament_id='${ncid}' AND round_number=1`));
check('every round-1 board has a result', ncRound1.every(p => p.is_bye || p.result !== null),
  JSON.stringify(ncRound1.map(p => p.result)));
check('the forfeited board is marked as such',
  ncRound1.some(p => p.reason === 'forfeit'), JSON.stringify(ncRound1.map(p => p.reason)));

console.log('\n=== 17. a stale queue claim never drops a player into a dead match ===');
sql(`DELETE FROM matchmaking_queue;`);
// Exactly the shape production carries: rows marked `matched` months ago,
// pointing at games that finished long since.
const deadMatch = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT id FROM matches WHERE status='completed' LIMIT 1) t`)).id;
sql(`INSERT INTO matchmaking_queue (player_id, game_key, time_control, status, match_id, queued_at)
     VALUES ('${P[0]}','chess','rapid_10_0','matched','${deadMatch}', now() - interval '90 days')`);

r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[0]);
check('status does not hand back a finished match', r.data.waiting === true, JSON.stringify(r.data));
const leftover = sql(`SELECT count(*) FROM matchmaking_queue WHERE player_id='${P[0]}'`);
check('the stale row is cleared rather than left to mislead', leftover === '0', `${leftover} rows`);

// And queueing again works normally instead of returning the dead match.
sql(`INSERT INTO matchmaking_queue (player_id, game_key, time_control, status, match_id, queued_at)
     VALUES ('${P[0]}','chess','rapid_10_0','matched','${deadMatch}', now() - interval '90 days')`);
r = await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
check('queueing past a stale claim queues normally', r.data.queued === true, JSON.stringify(r.data));

// A genuinely live claim must still be honoured.
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[2]);
const liveM = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_0' }, P[3])).data;
r = await call('matchmaking.php', { action: 'status', game_key: 'chess' }, P[2]);
check('a live claim is still delivered', r.data.match_id === liveM.match_id, JSON.stringify(r.data));
check('and it comes with the opposite colour', r.data.color !== liveM.color, `${r.data.color} / ${liveM.color}`);

console.log('\n=== 18. rated games actually move ratings ===');
sql(`DELETE FROM matchmaking_queue; DELETE FROM rating_history;`);
sql(`UPDATE profiles SET elo_blitz = 1600 WHERE id='${P[0]}'`);
sql(`UPDATE profiles SET elo_blitz = 1400 WHERE id='${P[1]}'`);

// Snapshot the rapid rating: a blitz game must not touch it.
const rapidBefore = Number(sql(`SELECT elo_rapid FROM profiles WHERE id='${P[0]}'`));
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_3' }, P[0]);
const rm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'blitz_5_3' }, P[1])).data;
const rInfo = (await call('game.php', { action: 'get', match_id: rm.match_id }, P[0])).data;
const rWhite = rInfo.my_color === 'w' ? P[0] : P[1];
const rBlack = rWhite === P[0] ? P[1] : P[0];

// The lower-rated player resigns, so the favourite wins and gains a little.
await call('game.php', { action: 'resign', match_id: rm.match_id }, P[1]);

const rRow = JSON.parse(sql(`SELECT row_to_json(t) FROM (SELECT white_rating_before, black_rating_before, white_rating_after, black_rating_after, rating_change_white, rating_change_black FROM matches WHERE id='${rm.match_id}') t`));
check('the match records both ratings before', rRow.white_rating_before !== null && rRow.black_rating_before !== null, JSON.stringify(rRow));
check('the match records both ratings after', rRow.white_rating_after !== null && rRow.black_rating_after !== null, JSON.stringify(rRow));
check('the rating changes are equal and opposite in sign',
  Math.sign(rRow.rating_change_white) === -Math.sign(rRow.rating_change_black), JSON.stringify(rRow));

const winnerElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[0]}'`));
const loserElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[1]}'`));
check('the winner gained rating', winnerElo > 1600, `${winnerElo}`);
check('the loser lost rating', loserElo < 1400, `${loserElo}`);
check('the favourite gains less than 16 for beating a weaker player', winnerElo - 1600 < 16, `+${winnerElo - 1600}`);
check('a blitz game moves only the blitz rating',
  Number(sql(`SELECT elo_rapid FROM profiles WHERE id='${P[0]}'`)) === rapidBefore,
  `elo_rapid changed from ${rapidBefore}`);

const rh = Number(sql(`SELECT count(*) FROM rating_history WHERE match_id='${rm.match_id}'`));
check('rating history written for both players', rh === 2, `${rh} rows`);
const wl = sql(`SELECT total_wins FROM profiles WHERE id='${P[0]}'`);
check('the win is counted on the profile', Number(wl) >= 1, wl);

console.log('\n=== 19. bot games are never rated ===');
const botRes = await call('game.php', { action: 'start', game_key: 'chess', mode: 'bot', bot_id: 'amina', time_control: 'blitz_5_0' }, P[2]);
const botMatch = botRes.data.id;
check('a bot match is created', !!botMatch, JSON.stringify(botRes.data).slice(0, 150));
const beforeElo = Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[2]}'`));
sql(`UPDATE matches SET is_rated = true WHERE id='${botMatch}'`);   // even if flagged rated
await call('game.php', { action: 'resign', match_id: botMatch }, P[2]);
check('a bot game does not change rating',
  Number(sql(`SELECT elo_blitz FROM profiles WHERE id='${P[2]}'`)) === beforeElo, 'rating moved');

console.log('\n=== 20. spectating, and finding whose game to watch ===');
sql(`DELETE FROM matchmaking_queue;`);
await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[0]);
const sm = (await call('matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' }, P[1])).data;

// A draw offer and a heartbeat must not leak to someone watching.
sql(`UPDATE matches SET game_state = '{"draw_offer":"${P[0]}","hb_${P[0]}":123}'::jsonb WHERE id='${sm.match_id}'`);
const watcher = await call('game.php', { action: 'get', match_id: sm.match_id }, P[2]);
check('a standing draw offer is not visible to a spectator',
  JSON.stringify(watcher.data).indexOf('draw_offer') === -1, JSON.stringify(watcher.data).slice(0, 160));

// find-active-match must answer about the player asked for, not the caller.
const found = await call('game.php', { action: 'find-active-match', player_id: P[0] }, P[2]);
check('watching another player finds THEIR game, not your own',
  found.data.match_id === sm.match_id, `${found.data.match_id} vs ${sm.match_id}`);

// A months-old stuck match must not be offered as something to watch. Age every
// live game this player has, since earlier sections left them in others.
sql(`UPDATE matches SET started_at = now() - interval '90 days'
     WHERE status='in_progress' AND (white_player_id='${P[0]}' OR black_player_id='${P[0]}')`);
const oldOne = await call('game.php', { action: 'find-active-match', player_id: P[0] }, P[2]);
check('a long-stale match is not offered for spectating', !oldOne.data.match_id, String(oldOne.data.match_id));

// …but a current one still is.
sql(`UPDATE matches SET started_at = now() WHERE id='${sm.match_id}'`);
const freshOne = await call('game.php', { action: 'find-active-match', player_id: P[0] }, P[2]);
check('a current match is still offered', freshOne.data.match_id === sm.match_id, String(freshOne.data.match_id));

// ---------------------------------------------------------------------------
console.log('');
if (failures.length) {
  console.log(`${failures.length} FAILURE(S):`);
  failures.forEach(f => console.log('  - ' + f));
  process.exit(1);
}
console.log('ALL END-TO-END CHECKS PASSED');
