<?php

header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }

require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/chess.php';
require_once __DIR__ . '/../includes/tournament-engine.php';

$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';

$db = supabase($token);

switch ($action) {
    case 'start':
        handleStart($db, $userId, $input);
        break;
    case 'move':
        handleGameMove($db, $userId, $input);
        break;
    case 'resign':
        handleResign($db, $userId, $input);
        break;
    case 'draw':
        handleDraw($db, $userId, $input);
        break;
    case 'complete':
        handleComplete($db, $userId, $input);
        break;
    case 'get':
        handleGet($db, $userId, $input);
        break;
    case 'heartbeat':
        handleChessHeartbeat($userId, $input);
        break;
    case 'leave':
        handleChessLeave($userId, $input);
        break;
    case 'find-active-match':
        handleFindActiveMatch($userId, $input);
        break;
    default:
        jsonError('Invalid action');
}

function handleGet($db, string $userId, array $input): void {
    $matchId = $_GET['match_id'] ?? ($input['match_id'] ?? '');
    if (!$matchId) jsonError('match_id required');
    $sdb = supabaseService();
    $matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
    $match = is_array($matches) && !empty($matches) && !isset($matches['error']) ? $matches[0] : null;
    if (!$match) jsonError('Match not found', 404);

    // Only the two players see the live position. Anyone else would be able to
    // read a tournament game's FEN while it is still being played.
    $isPlayer = ($match['white_player_id'] === $userId || $match['black_player_id'] === $userId);
    if (!$isPlayer) jsonError('Not authorized for this match', 403);

    // `my_color` is the single authoritative answer to "which side am I?".
    // Every client reads this instead of guessing, which is what allowed two
    // players to both believe they were White.
    $match['my_color'] = ($match['white_player_id'] === $userId) ? 'w' : 'b';
    $match['opponent_id'] = ($match['white_player_id'] === $userId)
        ? $match['black_player_id']
        : $match['white_player_id'];

    if ($match['status'] === 'in_progress') {
        // Charge the clock of whoever is on move before reporting state, so a
        // player who closed the tab still runs out of time like anyone else.
        $match = chargeClockAndMaybeFlag($sdb, $match);
    }

    jsonResponse($match);
}

/**
 * Deduct elapsed wall-clock time from the side to move and, if their clock has
 * run out, end the match on time. The clock lives on the server so a client
 * cannot simply report that it still has time left.
 */
function chargeClockAndMaybeFlag($sdb, array $match): array {
    $sideToMove = chessSideToMove($match['current_fen'] ?? null);
    if ($sideToMove === null) return $match;

    $metadata = normaliseJsonObject($match['metadata'] ?? null);
    $elapsedMs = matchElapsedSinceLastMoveMs($match, $metadata);
    if ($elapsedMs <= 0) return $match;

    $col = $sideToMove === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
    $remaining = (int)($match[$col] ?? 0) - $elapsedMs;

    if ($remaining > 0) {
        // Not out of time yet. Report the live figure without writing — writing
        // here would reset updated_at and the player would never flag.
        $match[$col] = $remaining;
        return $match;
    }

    $derived = chessDeriveResult('timeout', $match['current_fen'] ?? null, $sideToMove);
    $ok = finaliseMatch($sdb, $match, $derived, $sideToMove === 'w' ? $match['white_player_id'] : $match['black_player_id']);
    if (!$ok) return $match;

    $match[$col] = 0;
    $match['status'] = 'completed';
    $match['result'] = $derived['result'];
    $match['completed_at'] = gmdate('c');
    return $match;
}

/**
 * The one place a match is marked finished. Writes the result, verifies the
 * write actually landed, then runs rewards and tournament reporting exactly once.
 *
 * Returns false when the match could not be closed (already closed, or the
 * database rejected the value) so callers can report a real error instead of a
 * silent success — the failure mode that hid this bug for months.
 */
function finaliseMatch($sdb, array $match, array $derived, ?string $actorId = null, array $extra = []): bool {
    $matchId = $match['id'];

    if (!in_array($derived['result'], MATCH_RESULT_ENUM, true)) {
        error_log("[el3ab] refusing to write invalid match_result '{$derived['result']}' for match {$matchId}");
        return false;
    }

    $metadata = normaliseJsonObject($match['metadata'] ?? null);
    $metadata['end_reason'] = $derived['reason'];
    $metadata['winner'] = $derived['winner'];          // 'white' | 'black' | null
    if ($actorId) $metadata['ended_by'] = $actorId;

    $update = array_merge([
        'status'       => 'completed',
        'result'       => $derived['result'],
        'completed_at' => gmdate('c'),
        'updated_at'   => gmdate('c'),
        'metadata'     => $metadata,
    ], $extra);

    // Conditional on the match still being open: two players reporting the same
    // finish at the same moment must not both run the reward path.
    $written = $sdb->update('matches', $update, [
        'id'     => 'eq.' . $matchId,
        'status' => 'neq.completed',
    ]);

    if (isset($written['error'])) {
        error_log("[el3ab] match {$matchId} finalise failed: " . json_encode($written));
        return false;
    }
    if (empty($written)) {
        return false; // Somebody else closed it first; not an error, just not ours.
    }

    $winnerId = chessWinnerId($derived['winner'], $match['white_player_id'], $match['black_player_id']);
    $winners = $winnerId ? [$winnerId] : [];

    supabaseRpc('complete_match', [
        'p_game_key' => $match['game_key'] ?? 'chess',
        'p_match_id' => $matchId,
        'p_winners'  => $winners,
        'p_reason'   => $derived['reason'],
    ]);

    applyRatings($sdb, $match, $derived);

    mpLog($matchId, $match['game_key'] ?? 'chess', $actorId ?? ($match['white_player_id'] ?? ''), 'match_completed', [
        'result' => $derived['result'],
        'reason' => $derived['reason'],
    ]);

    // Tournament bookkeeping runs in-process. The previous implementation made
    // an HTTP call back into this same Apache instance, which is both a
    // deadlock risk and silently lost every result when it failed.
    if (!empty($match['tournament_id'])) {
        tournamentRecordMatchResult($sdb, $match, $derived);
    }

    return true;
}

/**
 * Milliseconds the side to move has been thinking.
 *
 * Anchored on metadata.last_move_at, which only a real move writes. Before the
 * first move nobody is charged, so a player who is slow to load their board
 * does not lose time for it.
 */
function matchElapsedSinceLastMoveMs(array $match, array $metadata): int {
    if ((int)($match['move_count'] ?? 0) < 1) return 0;
    $anchor = $metadata['last_move_at'] ?? $match['started_at'] ?? null;
    if (!$anchor) return 0;
    $ts = strtotime($anchor);
    if (!$ts) return 0;
    return max(0, (time() - $ts) * 1000);
}

/** PostgREST hands jsonb back as an array, a string, or null depending on how it was written. */
function normaliseJsonObject($raw): array {
    if (is_array($raw)) return $raw;
    if (is_string($raw) && $raw !== '') {
        $decoded = json_decode($raw, true);
        return is_array($decoded) ? $decoded : [];
    }
    return [];
}

function handleStart($db, string $userId, array $input): void {
    $gameKey = $input['game_key'] ?? 'chess';
    $mode = $input['mode'] ?? 'bot';
    // 'standard' and the classical_30_* values were never in the time_control
    // enum, so those matches failed to insert. Normalise against the real one.
    $timeControl = chessNormaliseTimeControl($input['time_control'] ?? null);
    $opponentId = $input['opponent_id'] ?? null;
    $botId = $input['bot_id'] ?? null;
    $clock = chessTimeControlMs($timeControl);

    $data = [
        'game_key' => $gameKey,
        'match_type' => $mode === 'bot' ? 'bot' : 'friendly',
        'white_player_id' => $userId,
        'black_player_id' => $opponentId,
        'status' => 'in_progress',
        'time_control' => $timeControl,
        'initial_time_ms' => $clock['initial'],
        'increment_ms' => $clock['increment'],
        'white_time_remaining_ms' => $clock['initial'],
        'black_time_remaining_ms' => $clock['initial'],
        'starting_fen' => CHESS_START_FEN,
        'current_fen' => CHESS_START_FEN,
        'moves' => [],
        'move_count' => 0,
        'bot_id' => $mode === 'bot' ? $botId : null,
        'started_at' => gmdate('c'),
        'metadata' => ['mode' => $mode, 'bot_id' => $botId],
    ];

    // Service role: $userId comes from the verified token, so nothing here is
    // caller-supplied, and RLS on `matches` must not silently drop the insert.
    $result = supabaseService()->insert('matches', $data);
    if (isset($result['error'])) jsonError($result['error']);
    $match = $result[0] ?? $result;
    $match['my_color'] = 'w';
    jsonResponse($match);
}

function handleGameMove($db, string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId) jsonError('match_id required');

    $sdb = supabaseService();

    // Auth: verify caller is a player in this match
    $match = $sdb->get('matches', [
        'id' => 'eq.' . $matchId,
        'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
                  . 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,updated_at,started_at,'
                  . 'tournament_id,tournament_round,metadata,time_control,is_rated,match_type,bot_id',
        'limit' => 1,
    ]);
    $match = (is_array($match) && !empty($match) && !isset($match['error'])) ? $match[0] : null;
    if (!$match) jsonError('Match not found', 404);
    if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
        jsonError('Not authorized for this match', 403);
    }
    if ($match['status'] !== 'in_progress') jsonError('Match not in progress');

    $myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';

    $update = ['updated_at' => gmdate('c')];

    $isBoardMove = !empty($input['fen']) || isset($input['move_count']);

    if ($isBoardMove) {
        // ---- Idempotency first ----------------------------------------------
        // A move that already landed must be reported as a duplicate, not as an
        // error. Mobile clients retry on a dropped connection, and by then the
        // position has moved on — checking the turn first would answer "not your
        // turn" for a move the player legitimately made and that succeeded.
        $serverCount = (int)($match['move_count'] ?? 0);
        if (isset($input['move_count']) && (int)$input['move_count'] <= $serverCount) {
            jsonResponse(['success' => true, 'duplicate' => true, 'move_count' => $serverCount]);
        }

        // ---- Turn ownership -------------------------------------------------
        // Without this check either player can overwrite the position at any
        // time, which is exactly what makes two clients that both think they
        // are White appear to be playing against themselves.
        $sideToMove = chessSideToMove($match['current_fen'] ?? null);
        if ($sideToMove !== null && $sideToMove !== $myColor) {
            jsonError('Not your turn', 409);
        }

        // ---- Position sanity -------------------------------------------------
        $newFen = $input['fen'] ?? null;
        if ($newFen) {
            [$ok, $why] = chessValidateTransition($match['current_fen'] ?? null, $newFen, $myColor);
            if (!$ok) {
                mpLog($matchId, 'chess', $userId, 'move_rejected', ['reason' => $why]);
                jsonError('Illegal move rejected: ' . $why, 409);
            }
            $update['current_fen'] = $newFen;
        }

        $update['move_count'] = isset($input['move_count'])
            ? (int)$input['move_count']
            : $serverCount + 1;

        // ---- Server-authoritative clock --------------------------------------
        // The mover's remaining time is computed from wall clock, not taken from
        // the client. The increment is credited after the move, as in FIDE.
        // Timing is anchored on metadata.last_move_at rather than updated_at,
        // because unrelated writes (emotes, draw offers, heartbeats) touch
        // updated_at and would otherwise hand the player free time.
        $metadata = normaliseJsonObject($match['metadata'] ?? null);
        $elapsedMs = matchElapsedSinceLastMoveMs($match, $metadata);

        $myClockCol = $myColor === 'w' ? 'white_time_remaining_ms' : 'black_time_remaining_ms';
        $remaining = (int)($match[$myClockCol] ?? 0) - $elapsedMs + (int)($match['increment_ms'] ?? 0);

        if ($remaining <= 0) {
            $derived = chessDeriveResult('timeout', $match['current_fen'] ?? null, $myColor);
            finaliseMatch($sdb, $match, $derived, $userId, [$myClockCol => 0]);
            jsonResponse(['success' => false, 'flagged' => true, 'result' => $derived['result']]);
        }
        $update[$myClockCol] = $remaining;

        $metadata['last_move_at'] = gmdate('c');
        $update['metadata'] = $metadata;
    }

    if (!empty($input['move'])) {
        $moves = is_string($input['move']) ? json_decode($input['move'], true) : $input['move'];
        $update['moves'] = is_array($moves) ? $moves : [];
    }

    // game_state: use atomic merge via DB function (prevents race conditions with draw offers, emotes)
    $gameStatePatch = null;
    if (!empty($input['game_state'])) {
        $gameStatePatch = is_string($input['game_state']) ? (json_decode($input['game_state'], true) ?: []) : $input['game_state'];
    }

    // If we only have game_state (no board columns), use atomic merge only
    $hasBoardUpdate = isset($update['current_fen']) || isset($update['moves']) || isset($update['move_count']);

    if ($gameStatePatch && !$hasBoardUpdate) {
        // Pure game_state update (draw offer, emote, etc.) — use atomic merge
        supabaseRpc('merge_game_state', [
            'p_table' => 'matches',
            'p_match_id' => $matchId,
            'p_patch' => $gameStatePatch
        ]);
        mpLog($matchId, 'chess', $userId, 'game_state_merge', $gameStatePatch);
        jsonResponse(['success' => true]);
        return;
    }

    if ($gameStatePatch && $hasBoardUpdate) {
        // Move + game_state: atomic merge game_state first, then update board columns
        supabaseRpc('merge_game_state', [
            'p_table' => 'matches',
            'p_match_id' => $matchId,
            'p_patch' => $gameStatePatch
        ]);
    }

    $result = $sdb->update('matches', $update, ['id' => 'eq.' . $matchId]);

    if (isset($result['error'])) jsonError($result['error']);

    mpLog($matchId, 'chess', $userId, 'move', [
        'move_count' => $input['move_count'] ?? null,
        'fen_short' => substr($input['fen'] ?? '', 0, 30)
    ]);
    jsonResponse(['success' => true]);
}

function handleResign($db, string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId) jsonError('match_id required');

    $sdb = supabaseService();
    $match = loadMatchForPlayer($sdb, $matchId, $userId);

    $myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';
    $derived = chessDeriveResult('resign', $match['current_fen'] ?? null, $myColor);

    if (!finaliseMatch($sdb, $match, $derived, $userId)) {
        jsonError('Could not record resignation — match may already be finished', 409);
    }

    jsonResponse(['result' => $derived['result'], 'reason' => 'resign', 'winner' => $derived['winner']]);
}

/**
 * Fetch a match and assert the caller is one of its two players.
 * Exits with a JSON error when it is not — every finishing action needs this,
 * and each one previously hand-rolled it slightly differently.
 */
function loadMatchForPlayer($sdb, string $matchId, string $userId, bool $mustBeOpen = true): array {
    $matches = $sdb->get('matches', [
        'id' => 'eq.' . $matchId,
        'select' => 'id,game_key,white_player_id,black_player_id,status,current_fen,move_count,'
                  . 'white_time_remaining_ms,black_time_remaining_ms,increment_ms,started_at,updated_at,'
                  . 'tournament_id,tournament_round,metadata,time_control,game_state,result,'
                  . 'is_rated,match_type,bot_id',
        'limit' => 1,
    ]);
    $match = (is_array($matches) && !empty($matches) && !isset($matches['error'])) ? $matches[0] : null;
    if (!$match) jsonError('Match not found', 404);
    if ($match['white_player_id'] !== $userId && $match['black_player_id'] !== $userId) {
        jsonError('Not authorized for this match', 403);
    }
    if ($mustBeOpen && $match['status'] === 'completed') jsonError('Match already ended', 409);
    return $match;
}

function handleDraw($db, string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId) jsonError('match_id required');

    $sdb = supabaseService();
    $match = loadMatchForPlayer($sdb, $matchId, $userId);

    // A draw needs both players to have agreed. The offer lives in game_state;
    // accepting without a standing offer from the *other* player used to end the
    // game unilaterally, which is a way to escape a lost position.
    $gameState = normaliseJsonObject($match['game_state'] ?? null);
    $offeredBy = $gameState['draw_offer'] ?? null;
    $force = !empty($input['auto']); // engine-detected draws (stalemate, 50-move, ...)

    if (!$force && (!$offeredBy || $offeredBy === $userId)) {
        jsonError('No draw offer from your opponent to accept', 409);
    }

    supabaseRpc('merge_game_state', [
        'p_table' => 'matches',
        'p_match_id' => $matchId,
        'p_patch' => ['draw_accepted' => true, 'draw_offer' => null],
    ]);

    $reason = $force ? ($input['reason'] ?? 'agreement') : 'agreement';
    $derived = chessDeriveResult($reason, $match['current_fen'] ?? null, null);
    // Anything reached through this endpoint is a draw by definition.
    $derived['result'] = 'draw';
    $derived['winner'] = null;

    if (!finaliseMatch($sdb, $match, $derived, $userId)) {
        jsonError('Could not record draw — match may already be finished', 409);
    }

    jsonResponse(['result' => 'draw', 'reason' => $derived['reason']]);
}

/**
 * End a match.
 *
 * The client reports *how* the game ended, never *who won*. The server derives
 * the winner from the final position, from who resigned, or from whose clock
 * expired. That closes the hole where a losing player could report themselves
 * the winner, and it removes the vocabulary mismatch that made this endpoint
 * reject every decisive game with "Invalid result value".
 */
function handleComplete($db, string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId || $matchId === 'local') {
        // Offline / local-only games have nothing to persist.
        jsonResponse(['success' => true, 'local' => true]);
    }

    $fen = $input['fen'] ?? '';
    $pgn = $input['pgn'] ?? '';

    $sdb = supabaseService();
    // Not loadMatchForPlayer(): a finished match is a normal outcome here, not an
    // error. Both players report the end of the same game, and the second one to
    // arrive must be told what the result was rather than getting a 409.
    $match = loadMatchForPlayer($sdb, $matchId, $userId, false);
    $myColor = ($match['white_player_id'] === $userId) ? 'w' : 'b';

    if ($match['status'] === 'completed') {
        $meta = normaliseJsonObject($match['metadata'] ?? null);
        jsonResponse([
            'success' => true,
            'already_completed' => true,
            'result' => $match['result'],
            'reason' => $meta['end_reason'] ?? null,
            'winner' => $meta['winner'] ?? null,
            'my_color' => $myColor,
        ]);
    }

    $reason = normaliseEndReason($input['reason'] ?? null, $input['result'] ?? null);
    $finalFen = $fen ?: ($match['current_fen'] ?? null);

    // For resignation and timeout the "actor" is the player who suffered it.
    // A client only ever reports its own loss this way; a win claim carries no
    // actor and is derived from the position instead.
    $actorColor = null;
    $claimedOutcome = $input['result'] ?? null;   // 'win' | 'loss' | 'draw' (legacy clients)
    if (in_array($reason, ['resign', 'timeout', 'abandon'], true)) {
        $actorColor = ($claimedOutcome === 'win')
            ? ($myColor === 'w' ? 'b' : 'w')   // opponent suffered it
            : $myColor;                        // I suffered it
    }

    $derived = chessDeriveResult($reason, $finalFen, $actorColor);

    $extra = [];
    if ($fen) $extra['current_fen'] = $fen;
    if ($pgn) $extra['pgn'] = $pgn;

    if (!finaliseMatch($sdb, $match, $derived, $userId, $extra)) {
        // Someone else already closed it — return the settled result rather than
        // an error, so both clients agree on the outcome.
        $fresh = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'result,status,metadata', 'limit' => 1]);
        $fresh = (is_array($fresh) && !empty($fresh) && !isset($fresh['error'])) ? $fresh[0] : null;
        if ($fresh && $fresh['status'] === 'completed') {
            $meta = normaliseJsonObject($fresh['metadata'] ?? null);
            jsonResponse([
                'success' => true,
                'already_completed' => true,
                'result' => $fresh['result'],
                'winner' => $meta['winner'] ?? null,
            ]);
        }
        jsonError('Could not complete match', 409);
    }

    // Achievements are best-effort and must never block the result.
    $profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'games_played,total_wins,win_streak,elo_rapid', 'limit' => 1]);
    $profile = is_array($profiles) && !empty($profiles) && !isset($profiles['error']) ? $profiles[0] : null;
    if ($profile) {
        checkGameAchievements($sdb, $userId, $profile, $profile['elo_rapid'] ?? 1200);
    }

    $fresh = $sdb->get('matches', [
        'id' => 'eq.' . $matchId,
        'select' => 'result,white_rating_after,black_rating_after,rating_change_white,rating_change_black',
        'limit' => 1,
    ]);
    $fresh = (is_array($fresh) && !empty($fresh) && !isset($fresh['error'])) ? $fresh[0] : [];

    $ratingChange = $myColor === 'w'
        ? ($fresh['rating_change_white'] ?? null)
        : ($fresh['rating_change_black'] ?? null);
    $ratingAfter = $myColor === 'w'
        ? ($fresh['white_rating_after'] ?? null)
        : ($fresh['black_rating_after'] ?? null);

    jsonResponse([
        'success'       => true,
        'result'        => $derived['result'],
        'reason'        => $derived['reason'],
        'winner'        => $derived['winner'],
        'my_color'      => $myColor,
        'rating_change' => $ratingChange,
        'rating_after'  => $ratingAfter,
    ]);
}

/**
 * Accept both the new vocabulary (an explicit reason) and what older clients
 * send (result: 'win'|'loss'|'draw' with a free-text reason), and land on one
 * of CHESS_END_REASONS.
 */
function normaliseEndReason(?string $reason, ?string $legacyResult): string {
    $map = [
        'checkmate' => 'checkmate', 'mate' => 'checkmate',
        'resign' => 'resign', 'resignation' => 'resign',
        'timeout' => 'timeout', 'time' => 'timeout', 'flag' => 'timeout',
        'abandon' => 'abandon', 'abandoned' => 'abandon', 'disconnect' => 'abandon',
        'stalemate' => 'stalemate',
        'agreement' => 'agreement', 'draw' => 'agreement', 'mutual' => 'agreement',
        'insufficient' => 'insufficient_material',
        'insufficient_material' => 'insufficient_material',
        'threefold' => 'threefold_repetition',
        'threefold_repetition' => 'threefold_repetition',
        'repetition' => 'threefold_repetition',
        'fifty' => 'fifty_moves', 'fifty_moves' => 'fifty_moves',
        'aborted' => 'aborted', 'abort' => 'aborted',
    ];

    $key = strtolower(trim((string)$reason));
    if (isset($map[$key])) return $map[$key];

    if ($legacyResult === 'draw') return 'agreement';
    if ($legacyResult === 'win' || $legacyResult === 'loss') return 'checkmate';
    return 'unknown';
}

/**
 * Apply Elo for a finished rated game.
 *
 * The complete_match database function does not touch the rating columns — no
 * completed match in production has ever had white_rating_after or
 * rating_change_white written, and 272 of 290 players were still sitting on the
 * default 1200. Ratings are the basis of Swiss seeding, so they are computed
 * here where the behaviour is visible and testable.
 *
 * Called only from finaliseMatch(), after its conditional write has succeeded,
 * so a game can never be rated twice.
 */
function applyRatings($sdb, array $match, array $derived): void {
    if (empty($match['is_rated'])) return;
    if (($match['match_type'] ?? '') === 'bot' || !empty($match['bot_id'])) return;

    $whiteId = $match['white_player_id'] ?? null;
    $blackId = $match['black_player_id'] ?? null;
    if (!$whiteId || !$blackId || $whiteId === $blackId) return;

    $timeControl = $match['time_control'] ?? 'rapid_10_0';
    $column = getRatingColumn($timeControl);
    $type = getTimeControlType($timeControl);

    $profiles = $sdb->get('profiles', [
        'id' => 'in.(' . $whiteId . ',' . $blackId . ')',
        'select' => "id,{$column},games_played,total_games_played,total_wins,total_losses,total_draws,win_streak,best_win_streak",
    ]);
    if (!is_array($profiles) || isset($profiles['error']) || count($profiles) < 2) return;

    $byId = [];
    foreach ($profiles as $p) $byId[$p['id']] = $p;
    if (!isset($byId[$whiteId], $byId[$blackId])) return;

    $whiteBefore = (int)($byId[$whiteId][$column] ?? 1200);
    $blackBefore = (int)($byId[$blackId][$column] ?? 1200);

    // 1 / 0.5 / 0 from White's point of view.
    $whiteScore = $derived['winner'] === 'white' ? 1.0 : ($derived['winner'] === 'black' ? 0.0 : 0.5);
    if ($derived['result'] === 'aborted') return;   // an aborted game is not rated

    $whiteAfter = calculateElo($whiteBefore, $blackBefore, $whiteScore);
    $blackAfter = calculateElo($blackBefore, $whiteBefore, 1.0 - $whiteScore);

    $sdb->update('matches', [
        'white_rating_before' => $whiteBefore,
        'black_rating_before' => $blackBefore,
        'white_rating_after'  => $whiteAfter,
        'black_rating_after'  => $blackAfter,
        'rating_change_white' => $whiteAfter - $whiteBefore,
        'rating_change_black' => $blackAfter - $blackBefore,
    ], ['id' => 'eq.' . $match['id']]);

    foreach ([[$whiteId, $whiteBefore, $whiteAfter, $whiteScore, $blackId, $blackBefore],
              [$blackId, $blackBefore, $blackAfter, 1.0 - $whiteScore, $whiteId, $whiteBefore]] as
             [$pid, $before, $after, $score, $oppId, $oppRating]) {
        $prof = $byId[$pid];
        $won  = $score === 1.0;
        $lost = $score === 0.0;

        $sdb->update('profiles', [
            $column               => $after,
            'games_played'        => (int)($prof['games_played'] ?? 0) + 1,
            'total_games_played'  => (int)($prof['total_games_played'] ?? 0) + 1,
            'total_wins'          => (int)($prof['total_wins'] ?? 0) + ($won ? 1 : 0),
            'total_losses'        => (int)($prof['total_losses'] ?? 0) + ($lost ? 1 : 0),
            'total_draws'         => (int)($prof['total_draws'] ?? 0) + (!$won && !$lost ? 1 : 0),
            'win_streak'          => $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0,
            'best_win_streak'     => max((int)($prof['best_win_streak'] ?? 0), $won ? (int)($prof['win_streak'] ?? 0) + 1 : 0),
        ], ['id' => 'eq.' . $pid]);

        $sdb->insert('rating_history', [
            'player_id'         => $pid,
            'game_key'          => $match['game_key'] ?? 'chess',
            'time_control_type' => $type,
            'rating_before'     => $before,
            'rating_after'      => $after,
            'rating_change'     => $after - $before,
            'match_id'          => $match['id'],
            'opponent_id'       => $oppId,
            'opponent_rating'   => $oppRating,
            'result'            => $won ? 'win' : ($lost ? 'loss' : 'draw'),
            'k_factor'          => eloKFactor($before),
        ]);
    }
}

/** FIDE-style K: smaller as a player's rating rises. */
function eloKFactor(int $rating): int {
    if ($rating > 2400) return 16;
    if ($rating > 2000) return 24;
    return 32;
}

function calculateElo(int $playerRating, int $opponentRating, float $score): int {
    $k = eloKFactor($playerRating);
    $expected = 1.0 / (1.0 + pow(10, ($opponentRating - $playerRating) / 400.0));
    $newRating = (int)round($playerRating + $k * ($score - $expected));
    return max(100, $newRating);
}

function getRatingColumn(string $timeControl): string {
    if (strpos($timeControl, 'bullet') !== false) return 'elo_bullet';
    if (strpos($timeControl, 'blitz') !== false) return 'elo_blitz';
    if (strpos($timeControl, 'classical') !== false) return 'elo_classical';
    return 'elo_rapid';
}

function getTimeControlType(string $timeControl): string {
    if (strpos($timeControl, 'bullet') !== false) return 'bullet';
    if (strpos($timeControl, 'blitz') !== false) return 'blitz';
    if (strpos($timeControl, 'classical') !== false) return 'classical';
    return 'rapid';
}

// Tournament reporting used to happen here via an HTTP call back into this same
// Apache instance (@file_get_contents to /api/tournament-match.php). That could
// deadlock a worker and, when it failed, lost the result with no trace. It now
// runs in-process from finaliseMatch() via tournamentRecordMatchResult().

function checkGameAchievements($sdb, string $userId, array $profileUpdates, int $newRating): void {
    $achievements = $sdb->get('achievements', ['select' => 'id,condition,coins_reward,xp_reward']);
    if (!is_array($achievements) || isset($achievements['error'])) return;

    $stats = [
        'wins' => $profileUpdates['total_wins'] ?? 0,
        'games_played' => $profileUpdates['games_played'] ?? 0,
        'win_streak' => $profileUpdates['win_streak'] ?? 0,
        'elo' => $newRating,
    ];

    foreach ($achievements as $a) {
        $condition = $a['condition'];
        if (is_string($condition)) $condition = json_decode($condition, true);
        if (!$condition) continue;

        $type = $condition['type'] ?? '';
        $target = $condition['count'] ?? 1;

        if (!isset($stats[$type])) continue;
        if ($stats[$type] < $target) continue;

        // Check if already completed
        $existing = $sdb->get('player_achievements', [
            'player_id' => 'eq.' . $userId,
            'achievement_id' => 'eq.' . $a['id'],
            'limit' => 1
        ]);

        if (is_array($existing) && !isset($existing['error']) && !empty($existing)) {
            if ($existing[0]['completed'] ?? false) continue;
            $sdb->update('player_achievements', [
                'progress' => $stats[$type],
                'completed' => true,
                'completed_at' => gmdate('c')
            ], ['player_id' => 'eq.' . $userId, 'achievement_id' => 'eq.' . $a['id']]);
        } else {
            $sdb->insert('player_achievements', [
                'player_id' => $userId,
                'achievement_id' => $a['id'],
                'progress' => $stats[$type],
                'completed' => true,
                'completed_at' => gmdate('c')
            ]);
        }

        // Grant rewards
        $coins = intval($a['coins_reward'] ?? 0);
        if ($coins > 0) {
            $sdb->rpc('award_coins', ['p_player_id' => $userId, 'p_amount' => $coins, 'p_reason' => 'Achievement: ' . $a['id']]);
        }
        $xp = intval($a['xp_reward'] ?? 0);
        if ($xp > 0) {
            $profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'xp,level', 'limit' => 1]);
            if (is_array($profiles) && !empty($profiles)) {
                $currentXp = ($profiles[0]['xp'] ?? 0) + $xp;
                $newLevel = floor($currentXp / 500) + 1;
                $sdb->update('profiles', ['xp' => $currentXp, 'level' => $newLevel], ['id' => 'eq.' . $userId]);
            }
        }
    }
}

function handleChessHeartbeat(string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId) jsonError('match_id required');

    supabaseRpc('match_heartbeat', [
        'p_game_key' => 'chess',
        'p_match_id' => $matchId,
        'p_player_id' => $userId
    ]);

    // Also stamp the heartbeat into game_state, keyed by player.
    //
    // This is the signal the opponent's client uses to tell "they are still
    // here, just thinking" from "they have gone". Without it, disconnect
    // detection has nothing but move activity to go on and would accuse a player
    // of disconnecting every time they thought for more than thirty seconds.
    // Merged (not written) so it cannot clobber a draw offer or an emote.
    // The key is flat and per-player — merge_game_state is a shallow merge, so a
    // nested "heartbeats" object would have each player's beat overwrite the other's.
    supabaseRpc('merge_game_state', [
        'p_table' => 'matches',
        'p_match_id' => $matchId,
        'p_patch' => ['hb_' . $userId => round(microtime(true) * 1000)],
    ]);

    jsonResponse(['status' => 'ok']);
}

function handleChessLeave(string $userId, array $input): void {
    $matchId = $input['match_id'] ?? '';
    if (!$matchId) jsonError('match_id required');

    $sdb = supabaseService();
    $matches = $sdb->get('matches', ['id' => 'eq.' . $matchId, 'select' => 'white_player_id,black_player_id,game_state', 'limit' => 1]);
    if (empty($matches) || isset($matches['error'])) jsonError('Not found');

    $raw = $matches[0]['game_state'] ?? null;
    $gameState = is_string($raw) ? (json_decode($raw, true) ?: []) : (is_array($raw) && !array_is_list($raw) ? $raw : []);
    $gameState['bot_replaced'] = $gameState['bot_replaced'] ?? [];
    $gameState['bot_replaced'][] = [
        'player_id' => $userId,
        'replaced_at' => date('c'),
        'permanent' => false
    ];

    $sdb->update('matches', [
        'game_state' => $gameState,
        'updated_at' => date('c')
    ], ['id' => 'eq.' . $matchId]);

    jsonResponse(['success' => true, 'bot_replaced' => true]);
}

function handleFindActiveMatch(string $userId, array $input): void {
    // Only allow finding your own active matches (prevent info disclosure)
    $playerId = $userId;

    $sdb = supabaseService();

    // Check chess matches
    $chess = $sdb->get('matches', [
        'or' => "(white_player_id.eq.{$playerId},black_player_id.eq.{$playerId})",
        'status' => 'eq.in_progress',
        'select' => 'id,game_key,white_player_id,black_player_id',
        'order' => 'started_at.desc',
        'limit' => 1
    ]);
    if (is_array($chess) && !isset($chess['error']) && !empty($chess)) {
        jsonResponse([
            'match_id' => $chess[0]['id'],
            'game_key' => $chess[0]['game_key'] ?? 'chess',
            'white_player_id' => $chess[0]['white_player_id'],
            'black_player_id' => $chess[0]['black_player_id']
        ]);
    }

    // Check ludo matches
    $ludo = $sdb->get('ludo_matches', [
        'status' => 'eq.in_progress',
        'select' => 'id,players',
        'order' => 'started_at.desc',
        'limit' => 10
    ]);
    if (is_array($ludo) && !isset($ludo['error'])) {
        foreach ($ludo as $m) {
            $players = is_string($m['players']) ? json_decode($m['players'], true) : ($m['players'] ?? []);
            foreach ($players as $p) {
                if (($p['id'] ?? '') === $playerId) {
                    jsonResponse(['match_id' => $m['id'], 'game_key' => 'ludo']);
                }
            }
        }
    }

    // Check domino matches
    $domino = $sdb->get('domino_matches', [
        'or' => "(player1_id.eq.{$playerId},player2_id.eq.{$playerId})",
        'status' => 'eq.in_progress',
        'select' => 'id',
        'order' => 'created_at.desc',
        'limit' => 1
    ]);
    if (is_array($domino) && !isset($domino['error']) && !empty($domino)) {
        jsonResponse(['match_id' => $domino[0]['id'], 'game_key' => 'domino']);
    }

    jsonResponse(['match_id' => null]);
}

function mpLog(string $matchId, string $gameKey, string $playerId, string $event, $payload = []): void {
    // p_payload is jsonb: pass the array, not a JSON string, or it lands in the
    // log as an escaped string that nothing can query.
    supabaseRpc('log_mp_event', [
        'p_match_id' => $matchId,
        'p_game_key' => $gameKey,
        'p_player_id' => $playerId,
        'p_event' => $event,
        'p_payload' => is_array($payload) ? $payload : []
    ]);
}
