<?php

/**
 * Server-side chess helpers.
 *
 * Two jobs:
 *   1. Parse and sanity-check FEN so the server can reason about a match without
 *      trusting whatever the client claims happened.
 *   2. Turn "how the game ended" into a value the `public.match_result` enum
 *      actually accepts, deriving the winner from server-known facts rather than
 *      from the reporting client's word for it.
 *
 * The result enum is deliberately kept to the coarse set the clients already
 * understand — white_wins / black_wins / draw / aborted. The precise reason
 * (checkmate, timeout, resignation, ...) is recorded in matches.metadata.end_reason,
 * so we lose no detail while staying inside a vocabulary every reader agrees on.
 */

const CHESS_START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';

/** Values `public.match_result` will accept. Verified against the live schema. */
const MATCH_RESULT_ENUM = [
    'white_wins', 'black_wins', 'draw',
    'white_timeout', 'black_timeout',
    'white_resign', 'black_resign',
    'white_abandon', 'black_abandon',
    'stalemate', 'insufficient_material', 'threefold_repetition',
    'fifty_moves', 'mutual_draw', 'aborted',
];

/** The subset we actually write, so every client can decode it without a lookup table. */
const MATCH_RESULT_CANONICAL = ['white_wins', 'black_wins', 'draw', 'aborted'];

/** Reasons a game can end. Anything else is normalised to 'unknown'. */
const CHESS_END_REASONS = [
    'checkmate', 'resign', 'timeout', 'abandon', 'stalemate', 'agreement',
    'insufficient_material', 'threefold_repetition', 'fifty_moves', 'aborted', 'unknown',
];

/**
 * Split a FEN into its six fields. Returns null when the string is not a
 * structurally valid FEN — callers must treat null as "reject this input".
 */
function chessParseFen(?string $fen): ?array {
    if (!is_string($fen) || $fen === '') return null;
    $parts = preg_split('/\s+/', trim($fen));
    if (count($parts) < 4) return null;

    [$placement, $side, $castling, $enPassant] = $parts;
    $halfmove = isset($parts[4]) ? (int)$parts[4] : 0;
    $fullmove = isset($parts[5]) ? (int)$parts[5] : 1;

    if ($side !== 'w' && $side !== 'b') return null;
    if (!preg_match('/^[prnbqkPRNBQK1-8\/]+$/', $placement)) return null;

    $ranks = explode('/', $placement);
    if (count($ranks) !== 8) return null;

    $counts = [];
    foreach ($ranks as $rank) {
        $files = 0;
        for ($i = 0, $n = strlen($rank); $i < $n; $i++) {
            $c = $rank[$i];
            if ($c >= '1' && $c <= '8') {
                $files += (int)$c;
            } else {
                $files++;
                $counts[$c] = ($counts[$c] ?? 0) + 1;
            }
        }
        if ($files !== 8) return null;
    }

    // Exactly one king each, or the position is not a chess position at all.
    if (($counts['K'] ?? 0) !== 1 || ($counts['k'] ?? 0) !== 1) return null;

    return [
        'placement' => $placement,
        'side'      => $side,
        'castling'  => $castling,
        'en_passant'=> $enPassant,
        'halfmove'  => $halfmove,
        'fullmove'  => $fullmove,
        'has_counters' => isset($parts[5]),   // some clients omit the last two fields
        'counts'    => $counts,
        'material'  => array_sum($counts),
    ];
}

/** 'w' | 'b' | null — whose turn it is in this FEN. */
function chessSideToMove(?string $fen): ?string {
    $p = chessParseFen($fen);
    return $p ? $p['side'] : null;
}

/**
 * Guard against a client posting a position that could not have followed the
 * previous one. This is a cheap tamper check, not a legality proof: a full
 * move generator is deliberately out of scope, because a subtly wrong one
 * would reject legal moves and break live games — a far worse failure than the
 * one it prevents.
 *
 * Returns [ok, reason].
 */
function chessValidateTransition(?string $prevFen, string $nextFen, string $moverColor): array {
    $next = chessParseFen($nextFen);
    if (!$next) return [false, 'malformed_fen'];

    // After a move by $moverColor it must be the other side's turn.
    $expectedSide = $moverColor === 'w' ? 'b' : 'w';
    if ($next['side'] !== $expectedSide) return [false, 'side_to_move_mismatch'];

    if ($prevFen === null || $prevFen === '') return [true, 'ok'];

    $prev = chessParseFen($prevFen);
    if (!$prev) return [true, 'ok']; // Previous state was already bad; don't compound it.

    // The mover must actually have been on move in the previous position.
    if ($prev['side'] !== $moverColor) return [false, 'not_your_turn'];

    // A single move can capture at most one piece, and can never add material.
    $delta = $prev['material'] - $next['material'];
    if ($delta < 0 || $delta > 1) return [false, 'material_delta'];

    // Promotion aside, no piece type may increase in count.
    foreach ($next['counts'] as $piece => $count) {
        $before = $prev['counts'][$piece] ?? 0;
        if ($count > $before) {
            $isPromotionPiece = in_array($piece, ['Q', 'R', 'B', 'N', 'q', 'r', 'b', 'n'], true);
            if (!$isPromotionPiece || $count > $before + 1) return [false, 'illegal_piece_gain'];
        }
    }

    // Move counters must advance exactly as the rules require. This is what
    // catches a client replaying an earlier position: the numbers cannot go
    // backwards, and they cannot stand still across Black's move.
    if ($prev['has_counters'] && $next['has_counters']) {
        $expectedFullmove = $prev['fullmove'] + ($moverColor === 'b' ? 1 : 0);
        if ($next['fullmove'] !== $expectedFullmove) return [false, 'fullmove_mismatch'];

        // The halfmove clock resets to 0 on a pawn move or a capture, and
        // otherwise increments by one.
        $captured = ($prev['material'] - $next['material']) === 1;
        if ($next['halfmove'] !== 0 && $next['halfmove'] !== $prev['halfmove'] + 1) {
            return [false, 'halfmove_mismatch'];
        }
        if ($captured && $next['halfmove'] !== 0) return [false, 'halfmove_not_reset'];
    }

    return [true, 'ok'];
}

/**
 * Derive the authoritative result from facts the server knows, never from the
 * reporting client's claim about who won.
 *
 * @param string      $reason      One of CHESS_END_REASONS.
 * @param string|null $finalFen    Position at the end of the game.
 * @param string|null $actorColor  'w'|'b' — who resigned / flagged / left, when relevant.
 * @return array{result:string, reason:string, winner:?string}  winner: 'white'|'black'|null
 */
function chessDeriveResult(string $reason, ?string $finalFen, ?string $actorColor): array {
    $reason = in_array($reason, CHESS_END_REASONS, true) ? $reason : 'unknown';
    $sideToMove = chessSideToMove($finalFen);

    $win = function (string $colour) use ($reason) {
        return [
            'result' => $colour === 'w' ? 'white_wins' : 'black_wins',
            'reason' => $reason,
            'winner' => $colour === 'w' ? 'white' : 'black',
        ];
    };
    $drawn = ['result' => 'draw', 'reason' => $reason, 'winner' => null];

    switch ($reason) {
        case 'checkmate':
            // The mated side is the side to move. Fully server-derived — the
            // client cannot claim a win it did not deliver.
            if ($sideToMove === null) return $drawn;
            return $win($sideToMove === 'w' ? 'b' : 'w');

        case 'resign':
        case 'abandon':
            // The actor is the one who quit, so the actor loses.
            if ($actorColor !== 'w' && $actorColor !== 'b') return $drawn;
            return $win($actorColor === 'w' ? 'b' : 'w');

        case 'timeout':
            // The flagged side is the actor when we know it, otherwise the side
            // whose clock was running (the side to move).
            $loser = ($actorColor === 'w' || $actorColor === 'b') ? $actorColor : $sideToMove;
            if ($loser === null) return $drawn;
            return $win($loser === 'w' ? 'b' : 'w');

        case 'stalemate':
        case 'agreement':
        case 'insufficient_material':
        case 'threefold_repetition':
        case 'fifty_moves':
            return $drawn;

        case 'aborted':
            return ['result' => 'aborted', 'reason' => 'aborted', 'winner' => null];

        default:
            return $drawn;
    }
}

/** Winner uuid for a derived result, or null for a draw. */
function chessWinnerId(?string $winner, ?string $whiteId, ?string $blackId): ?string {
    if ($winner === 'white') return $whiteId;
    if ($winner === 'black') return $blackId;
    return null;
}

/** Points for the Swiss table: [whitePoints, blackPoints]. */
function chessResultPoints(string $result): array {
    if ($result === 'white_wins') return [1.0, 0.0];
    if ($result === 'black_wins') return [0.0, 1.0];
    if ($result === 'aborted')    return [0.0, 0.0];
    return [0.5, 0.5];
}

/** Milliseconds on the clock for a `public.time_control` value. */
function chessTimeControlMs(string $timeControl): array {
    if ($timeControl === 'custom') return ['initial' => 600000, 'increment' => 0];
    $parts = explode('_', $timeControl);
    $minutes   = isset($parts[1]) ? (int)$parts[1] : 10;
    $increment = isset($parts[2]) ? (int)$parts[2] : 0;
    return ['initial' => $minutes * 60 * 1000, 'increment' => $increment * 1000];
}

/** Values `public.time_control` will accept. Verified against the live schema. */
const TIME_CONTROL_ENUM = [
    'bullet_1_0', 'bullet_1_1', 'bullet_2_1',
    'blitz_3_0', 'blitz_3_2', 'blitz_5_0', 'blitz_5_3',
    'rapid_10_0', 'rapid_10_5', 'rapid_15_10', 'rapid_30_0',
    'classical_60_0', 'classical_90_30', 'custom',
];

function chessNormaliseTimeControl(?string $tc, string $fallback = 'rapid_10_0'): string {
    return (is_string($tc) && in_array($tc, TIME_CONTROL_ENUM, true)) ? $tc : $fallback;
}
