<?php

/**
 * EL3AB native Swiss tournament engine.
 *
 * Before this file existed, nothing in the platform could start a tournament,
 * generate pairings, or advance a round. Tournament data was fetched from an
 * external Swiss service that every request reached without an Authorization
 * header, so every call returned 401 and every getter silently returned an
 * empty array. That is why standings, pairings and brackets have always been
 * blank.
 *
 * This engine owns the whole lifecycle inside EL3AB's own tables:
 *
 *   el3ab_tournaments        the tournament (status, current_round, format)
 *   tournament_registrations who is playing
 *   el3ab_tournament_rounds  one row per round; `pairings` and `results` jsonb
 *   matches                  the actual games, linked by tournament_id/round/pairing
 *
 * Pairing follows the Dutch system in the shape that matters for online play:
 * players are sorted by score then rating, split into score groups, and paired
 * top-half against bottom-half within each group, subject to "never repeat an
 * opponent" and colour balance. It is not a byte-for-byte FIDE C.04.3
 * implementation — the transfer/float rules for odd score groups are simplified
 * — but it is deterministic, never repeats a pairing, keeps colours balanced,
 * and always produces a complete round, which is what an online event needs.
 */

require_once __DIR__ . '/supabase.php';
require_once __DIR__ . '/chess.php';

const TOURNAMENT_STATUS_DRAFT        = 'draft';
const TOURNAMENT_STATUS_REGISTRATION = 'registration';
const TOURNAMENT_STATUS_IN_PROGRESS  = 'in_progress';
const TOURNAMENT_STATUS_COMPLETED    = 'completed';
const TOURNAMENT_STATUS_CANCELLED    = 'cancelled';

/** A round is `pending` until paired, `in_progress` while played, then `completed`. */
const ROUND_STATUS_IN_PROGRESS = 'in_progress';
const ROUND_STATUS_COMPLETED   = 'completed';

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

function tournamentLoad($sdb, string $tournamentId): ?array {
    $rows = $sdb->get('el3ab_tournaments', ['id' => 'eq.' . $tournamentId, 'select' => '*', 'limit' => 1]);
    return (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;
}

function tournamentRounds($sdb, string $tournamentId): array {
    $rows = $sdb->get('el3ab_tournament_rounds', [
        'tournament_id' => 'eq.' . $tournamentId,
        'select' => 'id,round_number,status,pairings,results,started_at,completed_at',
        'order' => 'round_number.asc',
    ]);
    if (!is_array($rows) || isset($rows['error'])) return [];
    foreach ($rows as &$r) {
        $r['pairings'] = tournamentDecodeJson($r['pairings'] ?? null);
        $r['results']  = tournamentDecodeJson($r['results'] ?? null);
    }
    return $rows;
}

function tournamentRound($sdb, string $tournamentId, int $roundNumber): ?array {
    foreach (tournamentRounds($sdb, $tournamentId) as $r) {
        if ((int)$r['round_number'] === $roundNumber) return $r;
    }
    return null;
}

/**
 * jsonb comes back as an array when written correctly and as a JSON *string*
 * when an earlier code path json_encode()d it before writing. Some existing
 * rows are double-encoded, so unwrap until we reach a list.
 */
function tournamentDecodeJson($raw): array {
    for ($i = 0; $i < 3; $i++) {
        if (is_array($raw)) return $raw;
        if (!is_string($raw) || $raw === '') return [];
        $raw = json_decode($raw, true);
    }
    return is_array($raw) ? $raw : [];
}

/**
 * Everyone registered, with their rating, in a stable seeding order.
 * Registration is the authoritative roster — `tournament_players` belongs to
 * the separate over-the-board Swiss schema and is not used here.
 */
function tournamentPlayers($sdb, string $tournamentId, string $timeControl = 'rapid_10_0'): array {
    $regs = $sdb->get('tournament_registrations', [
        'tournament_id' => 'eq.' . $tournamentId,
        'status' => 'eq.registered',
        'select' => 'player_id,seed,is_bot,registered_at',
        'order' => 'registered_at.asc',
    ]);
    if (!is_array($regs) || isset($regs['error']) || empty($regs)) return [];

    $ids = array_values(array_unique(array_filter(array_column($regs, 'player_id'))));
    if (empty($ids)) return [];

    $ratingCol = tournamentRatingColumn($timeControl);
    $profiles = $sdb->get('profiles', [
        'id' => 'in.(' . implode(',', $ids) . ')',
        'select' => "id,display_name,username,avatar_url,{$ratingCol}",
    ]);
    $byId = [];
    if (is_array($profiles) && !isset($profiles['error'])) {
        foreach ($profiles as $p) $byId[$p['id']] = $p;
    }

    $players = [];
    foreach ($regs as $r) {
        $pid = $r['player_id'];
        if (!$pid) continue;
        $p = $byId[$pid] ?? [];
        $players[] = [
            'id'      => $pid,
            'name'    => $p['display_name'] ?? $p['username'] ?? 'Player',
            'avatar'  => $p['avatar_url'] ?? null,
            'rating'  => (int)($p[$ratingCol] ?? 1200),
            'is_bot'  => (bool)($r['is_bot'] ?? false),
            'seed'    => $r['seed'] ?? null,
        ];
    }

    // Seed order: explicit seed first, then rating, then name — deterministic.
    usort($players, function ($a, $b) {
        if ($a['seed'] !== null && $b['seed'] !== null && $a['seed'] !== $b['seed']) {
            return $a['seed'] <=> $b['seed'];
        }
        if ($a['rating'] !== $b['rating']) return $b['rating'] <=> $a['rating'];
        return strcmp($a['id'], $b['id']);
    });

    return $players;
}

function tournamentRatingColumn(string $timeControl): string {
    if (str_starts_with($timeControl, 'bullet')) return 'elo_bullet';
    if (str_starts_with($timeControl, 'blitz')) return 'elo_blitz';
    if (str_starts_with($timeControl, 'classical')) return 'elo_classical';
    return 'elo_rapid';
}

// ---------------------------------------------------------------------------
// Standings
// ---------------------------------------------------------------------------

/**
 * Build the cross-table from every completed round.
 *
 * Returns, per player: points, opponents faced, colour history, byes, and the
 * tiebreak values (Buchholz cut-1, Buchholz, Sonneborn-Berger) the tournament
 * config asks for.
 */
function tournamentStandings($sdb, array $tournament, ?array $rounds = null, ?array $players = null): array {
    $tid = $tournament['id'];
    $timeControl = $tournament['time_control'] ?? 'rapid_10_0';
    $players = $players ?? tournamentPlayers($sdb, $tid, $timeControl);
    $rounds  = $rounds  ?? tournamentRounds($sdb, $tid);

    $state = [];
    foreach ($players as $p) {
        $state[$p['id']] = [
            'player_id' => $p['id'],
            'name'      => $p['name'],
            'avatar'    => $p['avatar'],
            'rating'    => $p['rating'],
            'is_bot'    => $p['is_bot'],
            'points'    => 0.0,
            'wins' => 0, 'draws' => 0, 'losses' => 0, 'byes' => 0,
            'games_played' => 0,
            'opponents' => [],       // player_id => points scored against them
            'colors'    => [],       // 'w' | 'b' per round actually played
            'played'    => [],       // set of opponent ids, for the no-rematch rule
            'had_bye'   => false,
            'results_by_round' => [],
        ];
    }

    foreach ($rounds as $round) {
        foreach ($round['pairings'] as $pairing) {
            $white = $pairing['white_id'] ?? null;
            $black = $pairing['black_id'] ?? null;
            $result = $pairing['result'] ?? null;   // white_wins | black_wins | draw | null

            // Bye: one seat empty. Scored at the tournament's bye value.
            if ($pairing['is_bye'] ?? false) {
                $who = $white ?: $black;
                if ($who && isset($state[$who])) {
                    $byeValue = (float)($tournament['bye_value'] ?? 1.0);
                    $state[$who]['points'] += $byeValue;
                    $state[$who]['byes']++;
                    $state[$who]['had_bye'] = true;
                    $state[$who]['results_by_round'][(int)$round['round_number']] = 'bye';
                }
                continue;
            }

            if (!$white || !$black || !isset($state[$white], $state[$black])) continue;

            // Record the encounter even before a result, so the next round never
            // pairs the same two players again.
            $state[$white]['played'][$black] = true;
            $state[$black]['played'][$white] = true;

            if ($result === null) continue; // still being played

            $state[$white]['colors'][] = 'w';
            $state[$black]['colors'][] = 'b';
            $state[$white]['games_played']++;
            $state[$black]['games_played']++;

            [$wp, $bp] = chessResultPoints($result);
            $state[$white]['points'] += $wp;
            $state[$black]['points'] += $bp;
            $state[$white]['opponents'][$black] = ($state[$white]['opponents'][$black] ?? 0) + $wp;
            $state[$black]['opponents'][$white] = ($state[$black]['opponents'][$white] ?? 0) + $bp;

            if ($wp === 1.0)      { $state[$white]['wins']++;   $state[$black]['losses']++; }
            elseif ($bp === 1.0)  { $state[$black]['wins']++;   $state[$white]['losses']++; }
            else                  { $state[$white]['draws']++;  $state[$black]['draws']++; }

            $rn = (int)$round['round_number'];
            $state[$white]['results_by_round'][$rn] = $wp === 1.0 ? 'win' : ($wp === 0.5 ? 'draw' : 'loss');
            $state[$black]['results_by_round'][$rn] = $bp === 1.0 ? 'win' : ($bp === 0.5 ? 'draw' : 'loss');
        }
    }

    // Tiebreaks need every player's final score, so they run in a second pass.
    foreach ($state as $id => &$s) {
        $oppScores = [];
        $sb = 0.0;
        foreach ($s['opponents'] as $oppId => $scoredAgainst) {
            $oppPoints = $state[$oppId]['points'] ?? 0.0;
            $oppScores[] = $oppPoints;
            $sb += $oppPoints * $scoredAgainst;      // Sonneborn-Berger
        }
        sort($oppScores);
        $buchholz = array_sum($oppScores);
        $buchholzCut1 = count($oppScores) > 1 ? $buchholz - $oppScores[0] : $buchholz;

        $s['tiebreaks'] = [
            'buchholz_cut_1'    => round($buchholzCut1, 2),
            'buchholz'          => round($buchholz, 2),
            'sonneborn_berger'  => round($sb, 2),
            'wins'              => $s['wins'],
        ];
        $s['points'] = round($s['points'], 2);
        unset($s['opponents']);
    }
    unset($s);

    $order = $tournament['tiebreak_rules'] ?? ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];
    $order = tournamentDecodeJson($order) ?: ['buchholz_cut_1', 'buchholz', 'sonneborn_berger'];

    $list = array_values($state);
    usort($list, function ($a, $b) use ($order) {
        if ($a['points'] !== $b['points']) return $b['points'] <=> $a['points'];
        foreach ($order as $rule) {
            $av = $a['tiebreaks'][$rule] ?? 0;
            $bv = $b['tiebreaks'][$rule] ?? 0;
            if ($av !== $bv) return $bv <=> $av;
        }
        if ($a['rating'] !== $b['rating']) return $b['rating'] <=> $a['rating'];
        return strcmp($a['player_id'], $b['player_id']);
    });

    $rank = 0;
    foreach ($list as $i => &$row) {
        $row['rank'] = ++$rank;
    }
    unset($row);

    return $list;
}

// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------

/** Colour difference: whites minus blacks played so far. */
function tournamentColorDiff(array $colors): int {
    $d = 0;
    foreach ($colors as $c) { $d += ($c === 'w') ? 1 : -1; }
    return $d;
}

/**
 * Colour a player is due, from their history.
 * Returns 'w', 'b', or null when they have no history at all.
 */
function tournamentDueColor(array $colors): ?string {
    $d = tournamentColorDiff($colors);
    if ($d > 0) return 'b';
    if ($d < 0) return 'w';
    $last = end($colors);
    if ($last === 'w') return 'b';
    if ($last === 'b') return 'w';
    return null;
}

/**
 * Decide who plays White, following the FIDE colour-allocation order:
 *   1. give both players their due colour when they differ;
 *   2. otherwise the player with the larger colour imbalance gets their due;
 *   3. otherwise look back for the most recent round in which they had
 *      different colours and alternate from it;
 *   4. otherwise the higher-ranked player gets their due colour.
 *
 * `$boardIndex` only breaks the round-1 case, where nobody has any history:
 * alternating down the boards keeps the field balanced from the start.
 *
 * @return array{0: array, 1: array} [white, black]
 */
function tournamentAssignColors(array $a, array $b, int $boardIndex): array {
    $dueA = tournamentDueColor($a['colors']);
    $dueB = tournamentDueColor($b['colors']);

    // 1. Compatible preferences — everybody gets what they are owed.
    if ($dueA === 'w' && $dueB === 'b') return [$a, $b];
    if ($dueA === 'b' && $dueB === 'w') return [$b, $a];

    // One side has no history at all: satisfy the side that does.
    if ($dueA === null && $dueB !== null) return $dueB === 'w' ? [$b, $a] : [$a, $b];
    if ($dueB === null && $dueA !== null) return $dueA === 'w' ? [$a, $b] : [$b, $a];

    // 4b. Round one — no history on either side. Alternate down the boards so
    // the top seeds do not all receive the same colour.
    if ($dueA === null && $dueB === null) {
        return ($boardIndex % 2 === 0) ? [$a, $b] : [$b, $a];
    }

    // Both are due the same colour.
    $diffA = tournamentColorDiff($a['colors']);
    $diffB = tournamentColorDiff($b['colors']);

    // 2. The more imbalanced player gets their due colour.
    if (abs($diffA) !== abs($diffB)) {
        $winner = abs($diffA) > abs($diffB) ? $a : $b;
        $other  = $winner === $a ? $b : $a;
        $due    = $winner === $a ? $dueA : $dueB;
        return $due === 'w' ? [$winner, $other] : [$other, $winner];
    }

    // 3. Alternate from the most recent round where their colours differed.
    $ca = array_reverse($a['colors']);
    $cb = array_reverse($b['colors']);
    for ($i = 0; $i < min(count($ca), count($cb)); $i++) {
        if ($ca[$i] === $cb[$i]) continue;
        // Whoever had Black in that round takes White now.
        return ($ca[$i] === 'b') ? [$a, $b] : [$b, $a];
    }

    // 4. Fall back to rank, with board alternation so it does not always favour
    // the same seeds round after round.
    $higher = ($a['rating'] >= $b['rating']) ? $a : $b;
    $lower  = ($higher === $a) ? $b : $a;
    return ($boardIndex % 2 === 0) ? [$higher, $lower] : [$lower, $higher];
}

/**
 * Generate the pairings for the next round.
 *
 * @return array{pairings: array, error: ?string}
 */
function tournamentGeneratePairings($sdb, array $tournament, int $roundNumber): array {
    $tid = $tournament['id'];
    $timeControl = $tournament['time_control'] ?? 'rapid_10_0';
    $players = tournamentPlayers($sdb, $tid, $timeControl);

    if (count($players) < 2) {
        return ['pairings' => [], 'error' => 'Need at least 2 registered players'];
    }

    $rounds = tournamentRounds($sdb, $tid);
    $standings = tournamentStandings($sdb, $tournament, $rounds, $players);

    // Round 1 has no scores, so the standings sort degenerates to seed order,
    // which is exactly what round 1 pairing wants.
    $pool = [];
    foreach ($standings as $s) {
        $pool[] = [
            'id'      => $s['player_id'],
            'points'  => $s['points'],
            'rating'  => $s['rating'],
            'colors'  => $s['colors'],
            'played'  => $s['played'] ?? [],
            'had_bye' => $s['had_bye'],
        ];
    }

    // A bye goes to the lowest-ranked player who has not had one yet.
    $bye = null;
    if (count($pool) % 2 === 1) {
        for ($i = count($pool) - 1; $i >= 0; $i--) {
            if (!$pool[$i]['had_bye']) { $bye = $pool[$i]; array_splice($pool, $i, 1); break; }
        }
        if ($bye === null) {          // everyone already had one; give it to the last
            $bye = array_pop($pool);
        }
    }

    // $pool is already in pairing order: score desc, then rating desc.
    $pairs = tournamentMatchPool($pool);
    if ($pairs === null) {
        return ['pairings' => [], 'error' => 'Could not find a valid pairing for this round'];
    }

    $pairings = [];
    $board = 1;
    foreach ($pairs as [$a, $b]) {
        [$white, $black] = tournamentAssignColors($a, $b, $board - 1);

        $pairings[] = [
            'board'      => $board,
            'white_id'   => $white['id'],
            'black_id'   => $black['id'],
            'is_bye'     => false,
            'result'     => null,
            'match_id'   => null,
            'pairing_id' => tournamentPairingId($tid, $roundNumber, $board),
        ];
        $board++;
    }

    if ($bye !== null) {
        $pairings[] = [
            'board'      => $board,
            'white_id'   => $bye['id'],
            'black_id'   => null,
            'is_bye'     => true,
            'result'     => 'bye',
            'match_id'   => null,
            'pairing_id' => tournamentPairingId($tid, $roundNumber, $board),
        ];
    }

    return ['pairings' => $pairings, 'error' => null];
}

/**
 * Pair the whole field.
 *
 * A greedy top-half/bottom-half split cannot honour "never play the same
 * opponent twice" — it paints itself into a corner in later rounds and the only
 * escape is a rematch. This is a backtracking search instead: take the highest
 * unpaired player, try opponents in preference order, and undo the choice if the
 * remainder turns out to be unpairable.
 *
 * $pool must already be in pairing order (score desc, then rating desc).
 *
 * @return array|null List of [playerA, playerB], or null if no pairing exists.
 */
function tournamentMatchPool(array $pool): ?array {
    $n = count($pool);
    if ($n === 0) return [];
    if ($n % 2 !== 0) return null;   // caller must have removed the bye already

    $used = array_fill(0, $n, false);
    $result = [];
    // A hard cap keeps a pathological field from spinning; the greedy first
    // choice is almost always accepted, so this is never approached in practice.
    $budget = 200000;

    $solve = function (int $from) use (&$solve, &$used, &$result, $pool, $n, &$budget): bool {
        if ($budget-- <= 0) return false;

        // Highest-ranked unpaired player.
        $i = $from;
        while ($i < $n && $used[$i]) $i++;
        if ($i >= $n) return true;   // everyone is paired

        $used[$i] = true;
        foreach (tournamentCandidateOrder($pool, $used, $i, $n) as $j) {
            $used[$j] = true;
            $result[] = [$pool[$i], $pool[$j]];
            if ($solve($i + 1)) return true;
            array_pop($result);
            $used[$j] = false;
        }
        $used[$i] = false;
        return false;
    };

    if ($solve(0)) return $result;

    // No rematch-free pairing exists. Rather than fail the round, allow
    // rematches — a repeat game is far better than a tournament that stalls.
    $used = array_fill(0, $n, false);
    $result = [];
    for ($i = 0; $i < $n; $i++) {
        if ($used[$i]) continue;
        $used[$i] = true;
        for ($j = $i + 1; $j < $n; $j++) {
            if ($used[$j]) continue;
            $used[$j] = true;
            $result[] = [$pool[$i], $pool[$j]];
            break;
        }
    }
    return $result;
}

/**
 * Opponents for $pool[$i], best first.
 *
 * Preference is dominated by score proximity — Swiss wants players on the same
 * score to meet — and then by the ideal "top half meets bottom half" distance
 * inside that score group. Anyone already played is excluded outright.
 */
function tournamentCandidateOrder(array $pool, array $used, int $i, int $n): array {
    $me = $pool[$i];

    // Where the same-score group ends, so we can aim at its far half.
    $groupEnd = $i;
    while ($groupEnd + 1 < $n && $pool[$groupEnd + 1]['points'] === $me['points']) $groupEnd++;
    $groupStart = $i;
    while ($groupStart > 0 && $pool[$groupStart - 1]['points'] === $me['points']) $groupStart--;
    $groupSize = $groupEnd - $groupStart + 1;
    $idealOffset = intdiv($groupSize, 2);          // top half meets bottom half
    $ideal = $groupStart + (($i - $groupStart) + $idealOffset);

    $candidates = [];
    for ($j = $i + 1; $j < $n; $j++) {
        if ($used[$j]) continue;
        if (isset($me['played'][$pool[$j]['id']])) continue;   // never a rematch

        $scoreGap = abs($me['points'] - $pool[$j]['points']);
        $cost = $scoreGap * 1000 + abs($j - $ideal);

        // Nudge away from pairs where both players want the same colour, so the
        // colour allocator is rarely forced to break someone's due colour.
        $dueA = tournamentDueColor($me['colors']);
        $dueB = tournamentDueColor($pool[$j]['colors']);
        if ($dueA !== null && $dueA === $dueB) $cost += 3;

        $candidates[] = ['j' => $j, 'cost' => $cost];
    }

    usort($candidates, fn($x, $y) => $x['cost'] <=> $y['cost'] ?: $x['j'] <=> $y['j']);
    return array_column($candidates, 'j');
}

/**
 * A stable identifier for one pairing.
 *
 * This is what makes "create the match for my pairing" idempotent: both players
 * derive the same id, so the second one to arrive finds the first one's match
 * instead of creating a duplicate. Duplicate matches for a single pairing are
 * what left each player alone on their own board inside tournaments.
 */
function tournamentPairingId(string $tournamentId, int $roundNumber, int $board): string {
    return tournamentUuidFrom($tournamentId . ':' . $roundNumber . ':' . $board);
}

/**
 * The match id for a pairing, derived from the same inputs on both clients.
 *
 * This is the mechanism that makes match creation race-free without a schema
 * change: both players compute the same id and INSERT it, so the primary key on
 * `matches` guarantees exactly one of them wins and the other reads back the
 * existing row. Previously both inserted, each got their own match, and each sat
 * alone on a board waiting for an opponent who was in the other game.
 */
function tournamentMatchIdFor(string $tournamentId, int $roundNumber, int $board): string {
    return tournamentUuidFrom($tournamentId . ':' . $roundNumber . ':' . $board . ':match');
}

/** Deterministic uuid-shaped string from arbitrary input. */
function tournamentUuidFrom(string $seed): string {
    $h = md5($seed);
    return substr($h, 0, 8) . '-' . substr($h, 8, 4) . '-' . substr($h, 12, 4)
         . '-' . substr($h, 16, 4) . '-' . substr($h, 20, 12);
}

// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------

/**
 * Move a tournament from registration into round 1.
 * Idempotent: calling it twice will not create a second round 1.
 *
 * @return array{ok: bool, error: ?string, round: ?array}
 */
function tournamentStart($sdb, string $tournamentId): array {
    $t = tournamentLoad($sdb, $tournamentId);
    if (!$t) return ['ok' => false, 'error' => 'Tournament not found', 'round' => null];

    if ($t['status'] === TOURNAMENT_STATUS_IN_PROGRESS) {
        $existing = tournamentRound($sdb, $tournamentId, max(1, (int)$t['current_round']));
        return ['ok' => true, 'error' => null, 'round' => $existing];
    }
    if (!in_array($t['status'], [TOURNAMENT_STATUS_DRAFT, TOURNAMENT_STATUS_REGISTRATION], true)) {
        return ['ok' => false, 'error' => 'Tournament is ' . $t['status'], 'round' => null];
    }

    $players = tournamentPlayers($sdb, $tournamentId, $t['time_control'] ?? 'rapid_10_0');
    $minPlayers = max(2, (int)($t['min_players'] ?? 2));
    if (count($players) < $minPlayers) {
        return ['ok' => false, 'error' => "Not enough players (" . count($players) . "/{$minPlayers})", 'round' => null];
    }

    // Claim the start conditionally so two concurrent triggers cannot both pair
    // round 1. Only the request that flips the status proceeds.
    $claim = $sdb->update('el3ab_tournaments', [
        'status' => TOURNAMENT_STATUS_IN_PROGRESS,
        'current_round' => 0,
        'updated_at' => gmdate('c'),
    ], [
        'id' => 'eq.' . $tournamentId,
        'status' => 'in.(' . TOURNAMENT_STATUS_DRAFT . ',' . TOURNAMENT_STATUS_REGISTRATION . ')',
    ]);
    if (isset($claim['error']) || empty($claim)) {
        $existing = tournamentRound($sdb, $tournamentId, 1);
        return ['ok' => (bool)$existing, 'error' => $existing ? null : 'Could not start', 'round' => $existing];
    }

    $t['status'] = TOURNAMENT_STATUS_IN_PROGRESS;
    return tournamentOpenRound($sdb, $t, 1);
}

/**
 * Pair and open a specific round.
 *
 * @return array{ok: bool, error: ?string, round: ?array}
 */
function tournamentOpenRound($sdb, array $tournament, int $roundNumber): array {
    $tid = $tournament['id'];

    $existing = tournamentRound($sdb, $tid, $roundNumber);
    if ($existing) {
        return ['ok' => true, 'error' => null, 'round' => $existing];
    }

    $gen = tournamentGeneratePairings($sdb, $tournament, $roundNumber);
    if ($gen['error']) return ['ok' => false, 'error' => $gen['error'], 'round' => null];
    if (empty($gen['pairings'])) return ['ok' => false, 'error' => 'No pairings could be generated', 'round' => null];

    $inserted = $sdb->insert('el3ab_tournament_rounds', [
        'tournament_id' => $tid,
        'round_number'  => $roundNumber,
        'status'        => ROUND_STATUS_IN_PROGRESS,
        'started_at'    => gmdate('c'),
        'pairings'      => $gen['pairings'],   // jsonb: pass the array, never a JSON string
        'results'       => [],
    ]);
    if (isset($inserted['error'])) {
        // Most likely another request created it a moment ago.
        $existing = tournamentRound($sdb, $tid, $roundNumber);
        if ($existing) return ['ok' => true, 'error' => null, 'round' => $existing];
        return ['ok' => false, 'error' => 'Failed to create round: ' . $inserted['error'], 'round' => null];
    }

    $sdb->update('el3ab_tournaments', [
        'current_round' => $roundNumber,
        'updated_at' => gmdate('c'),
    ], ['id' => 'eq.' . $tid]);

    $round = tournamentRound($sdb, $tid, $roundNumber);
    return ['ok' => true, 'error' => null, 'round' => $round];
}

/**
 * Record a finished match against its pairing, then close and advance the round
 * if that was the last game outstanding.
 *
 * Called in-process from game.php's finaliseMatch(), so a result can never be
 * lost to a failed HTTP call back into our own web server.
 */
function tournamentRecordMatchResult($sdb, array $match, array $derived): void {
    $tid = $match['tournament_id'] ?? null;
    if (!$tid) return;

    $roundNumber = (int)($match['tournament_round'] ?? 0);
    if ($roundNumber < 1) return;

    $round = tournamentRound($sdb, $tid, $roundNumber);
    if (!$round) return;

    $pairings = $round['pairings'];
    $changed = false;

    foreach ($pairings as &$p) {
        if (!empty($p['is_bye'])) continue;
        $samePair = ($p['white_id'] === $match['white_player_id'] && $p['black_id'] === $match['black_player_id'])
                 || ($p['white_id'] === $match['black_player_id'] && $p['black_id'] === $match['white_player_id']);
        if (!$samePair) continue;
        if (($p['result'] ?? null) !== null) return;   // already recorded; stay idempotent

        // Store the result from the *pairing's* point of view, which may be the
        // reverse of the match's if the colours were swapped when the match was made.
        $result = $derived['result'];
        $colorsSwapped = ($p['white_id'] !== $match['white_player_id']);
        if ($colorsSwapped) {
            if ($result === 'white_wins') $result = 'black_wins';
            elseif ($result === 'black_wins') $result = 'white_wins';
        }

        $p['result'] = $result;
        $p['match_id'] = $match['id'];
        $p['reason'] = $derived['reason'];
        $p['completed_at'] = gmdate('c');
        $changed = true;
        break;
    }
    unset($p);

    if (!$changed) return;

    $sdb->update('el3ab_tournament_rounds', [
        'pairings' => $pairings,
    ], ['id' => 'eq.' . $round['id']]);

    tournamentMaybeAdvance($sdb, $tid);
}

/**
 * If every pairing in the current round has a result, close it and open the
 * next one — or finish the tournament when the last round is done.
 */
function tournamentMaybeAdvance($sdb, string $tournamentId): array {
    $t = tournamentLoad($sdb, $tournamentId);
    if (!$t || $t['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) {
        return ['advanced' => false, 'reason' => 'not_in_progress'];
    }

    $current = (int)($t['current_round'] ?? 0);
    if ($current < 1) return ['advanced' => false, 'reason' => 'not_started'];

    $round = tournamentRound($sdb, $tournamentId, $current);
    if (!$round) return ['advanced' => false, 'reason' => 'no_round'];

    foreach ($round['pairings'] as $p) {
        if (!empty($p['is_bye'])) continue;
        if (($p['result'] ?? null) === null) {
            return ['advanced' => false, 'reason' => 'games_outstanding'];
        }
    }

    // Close the round exactly once.
    $closed = $sdb->update('el3ab_tournament_rounds', [
        'status' => ROUND_STATUS_COMPLETED,
        'completed_at' => gmdate('c'),
    ], ['id' => 'eq.' . $round['id'], 'status' => 'eq.' . ROUND_STATUS_IN_PROGRESS]);
    if (isset($closed['error']) || empty($closed)) {
        return ['advanced' => false, 'reason' => 'already_closed'];
    }

    $totalRounds = (int)($t['swiss_rounds'] ?? $t['rounds_total'] ?? 0);
    if ($totalRounds < 1) $totalRounds = tournamentSuggestedRounds($sdb, $tournamentId, $t);

    if ($current >= $totalRounds) {
        tournamentFinish($sdb, $t);
        return ['advanced' => false, 'reason' => 'tournament_complete', 'completed' => true];
    }

    $next = tournamentOpenRound($sdb, $t, $current + 1);
    return ['advanced' => (bool)$next['ok'], 'reason' => $next['error'] ?? 'ok', 'round' => $next['round']];
}

/** Standard Swiss length: enough rounds to separate the field. */
function tournamentSuggestedRounds($sdb, string $tournamentId, array $t): int {
    $n = count(tournamentPlayers($sdb, $tournamentId, $t['time_control'] ?? 'rapid_10_0'));
    if ($n < 2) return 1;
    return max(3, min(11, (int)ceil(log($n, 2)) + 1));
}

/** Mark the tournament complete and write the final placings. */
function tournamentFinish($sdb, array $tournament): void {
    $standings = tournamentStandings($sdb, $tournament);

    foreach ($standings as $row) {
        $sdb->update('tournament_registrations', [
            'final_standing' => $row['rank'],
        ], [
            'tournament_id' => 'eq.' . $tournament['id'],
            'player_id' => 'eq.' . $row['player_id'],
        ]);
    }

    $sdb->update('el3ab_tournaments', [
        'status' => TOURNAMENT_STATUS_COMPLETED,
        'updated_at' => gmdate('c'),
    ], ['id' => 'eq.' . $tournament['id'], 'status' => 'eq.' . TOURNAMENT_STATUS_IN_PROGRESS]);
}

/**
 * Award a walkover when a player has not shown up for their game.
 * Keeps a round from stalling forever because one player never appeared.
 */
function tournamentForfeitPairing($sdb, string $tournamentId, int $roundNumber, string $pairingId, string $absentPlayerId): bool {
    $round = tournamentRound($sdb, $tournamentId, $roundNumber);
    if (!$round) return false;

    $pairings = $round['pairings'];
    $changed = false;
    foreach ($pairings as &$p) {
        if (($p['pairing_id'] ?? null) !== $pairingId) continue;
        if (($p['result'] ?? null) !== null) return false;
        $p['result'] = ($p['white_id'] === $absentPlayerId) ? 'black_wins' : 'white_wins';
        $p['reason'] = 'forfeit';
        $p['forfeited_by'] = $absentPlayerId;
        $p['completed_at'] = gmdate('c');
        $changed = true;
        break;
    }
    unset($p);
    if (!$changed) return false;

    $sdb->update('el3ab_tournament_rounds', ['pairings' => $pairings], ['id' => 'eq.' . $round['id']]);
    tournamentMaybeAdvance($sdb, $tournamentId);
    return true;
}

// ---------------------------------------------------------------------------
// Self-healing ticks
//
// api/cron.php is the belt-and-braces scheduler, but a tournament must not
// depend on someone remembering to wire one up. These run opportunistically off
// ordinary traffic — the same pattern the matchmaking queue already uses for its
// stale sweep — so the event progresses as long as anybody is using the app.
// Both are idempotent and cheap, and both are throttled so they cost one query
// every few seconds rather than one per request.
// ---------------------------------------------------------------------------

/**
 * Read an integer from the environment.
 *
 * Note the explicit false/'' check: `getenv($k) ?: $default` silently discards a
 * configured value of "0", because "0" is falsy in PHP.
 */
function tournamentEnvInt(string $name, int $default): int {
    $v = getenv($name);
    if ($v === false || $v === '') return $default;
    return (int)$v;
}

/** How long a player has to appear for their game before losing it by no-show. */
function tournamentNoShowSeconds(): int {
    return max(60, tournamentEnvInt('TOURNAMENT_NO_SHOW_SECONDS', 600));
}

/**
 * Start any tournament whose start time has passed and which has enough players.
 * Throttled to at most once every $throttleSeconds across the whole process pool.
 */
function tournamentStartDueTournaments($sdb, ?int $throttleSeconds = null): array {
    $throttleSeconds = $throttleSeconds ?? tournamentEnvInt('TOURNAMENT_TICK_SECONDS', 20);
    if (!tournamentThrottle('start_due', $throttleSeconds)) return [];

    $due = $sdb->get('el3ab_tournaments', [
        'status' => 'in.(' . TOURNAMENT_STATUS_REGISTRATION . ',' . TOURNAMENT_STATUS_DRAFT . ')',
        'auto_start' => 'is.true',
        'select' => 'id,name,starts_at',
        'limit' => 25,
    ]);
    if (!is_array($due) || isset($due['error'])) return [];

    $started = [];
    foreach ($due as $t) {
        if (empty($t['starts_at']) || strtotime($t['starts_at']) > time()) continue;
        $res = tournamentStart($sdb, $t['id']);
        if ($res['ok']) $started[] = $t['id'];
    }
    return $started;
}

/**
 * Move one tournament forward: forfeit anyone who never turned up for their
 * game, then advance the round if that was the last one outstanding.
 *
 * Called when a player interacts with the tournament, so the player who *did*
 * show up is the one whose activity resolves their absent opponent.
 */
function tournamentTick($sdb, array $tournament, ?int $throttleSeconds = null): void {
    if ($tournament['status'] !== TOURNAMENT_STATUS_IN_PROGRESS) return;
    $throttleSeconds = $throttleSeconds ?? tournamentEnvInt('TOURNAMENT_TICK_SECONDS', 15);
    if (!tournamentThrottle('tick:' . $tournament['id'], $throttleSeconds)) return;

    $roundNumber = (int)($tournament['current_round'] ?? 0);
    if ($roundNumber < 1) return;

    $round = tournamentRound($sdb, $tournament['id'], $roundNumber);
    if (!$round || $round['status'] !== ROUND_STATUS_IN_PROGRESS) return;

    $roundStarted = strtotime($round['started_at'] ?? 'now');
    $grace = tournamentNoShowSeconds();

    if (time() - $roundStarted > $grace) {
        foreach ($round['pairings'] as $p) {
            if (!empty($p['is_bye']) || ($p['result'] ?? null) !== null) continue;

            $matchId = tournamentMatchIdFor($tournament['id'], $roundNumber, (int)($p['board'] ?? 1));
            $rows = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'id,status,move_count', 'limit' => 1]);
            $match = (is_array($rows) && !empty($rows) && !isset($rows['error'])) ? $rows[0] : null;

            // A game with moves in it is being played — leave it alone; the
            // server clock will settle it if someone walks away mid-game.
            if ($match && (int)($match['move_count'] ?? 0) > 0) continue;
            if ($match && $match['status'] === 'completed') continue;

            // Nobody opened the board, or it was opened and never played.
            tournamentForfeitPairing($sdb, $tournament['id'], $roundNumber, $p['pairing_id'] ?? '', $p['white_id']);
        }
    }

    tournamentMaybeAdvance($sdb, $tournament['id']);
}

/**
 * Returns true at most once per $seconds for a given key, across processes.
 * Uses APCu when available, otherwise a temp file — no extra services needed.
 */
function tournamentThrottle(string $key, int $seconds): bool {
    if ($seconds <= 0) return true;   // throttling disabled
    $name = 'el3ab_tt_' . hash('sha256', $key);

    if (function_exists('apcu_add')) {
        return apcu_add($name, time(), $seconds);
    }

    $file = sys_get_temp_dir() . '/' . $name;
    $last = is_file($file) ? (int)@file_get_contents($file) : 0;
    if (time() - $last < $seconds) return false;
    @file_put_contents($file, (string)time(), LOCK_EX);
    return true;
}

/**
 * The pairing a player owes a game in, for the current round.
 * Returns null when they have already finished, have a bye, or are not playing.
 */
function tournamentPendingPairingFor($sdb, array $tournament, string $playerId): ?array {
    $current = (int)($tournament['current_round'] ?? 0);
    if ($current < 1) return null;
    $round = tournamentRound($sdb, $tournament['id'], $current);
    if (!$round || $round['status'] !== ROUND_STATUS_IN_PROGRESS) return null;

    foreach ($round['pairings'] as $p) {
        $isMine = ($p['white_id'] ?? null) === $playerId || ($p['black_id'] ?? null) === $playerId;
        if (!$isMine) continue;
        if (($p['result'] ?? null) !== null) return null;
        return ['round' => $round, 'pairing' => $p];
    }
    return null;
}
