<?php
/**
 * FIDE TRF Export - Tournament Report File (TRF16 format)
 *
 * Conforme a:
 * - FIDE TRF16: https://fide.com/FIDE/handbook/C04Annex2_TRF16.pdf
 * - JaVaFo AUM: https://www.rrweb.org/javafo/aum/JaVaFo_AUM.html
 *
 * @package Chess_Podium
 */

if (!defined('ABSPATH')) {
    exit;
}

final class ChessPodium_TrfExport
{
    /**
     * Generate minimal valid TRF for pairing API test (4 players, round 1).
     * Uses same format as generate_trf_for_pairing so JaVaFo/bbpPairings accept it.
     *
     * @return string TRF content.
     */
    public static function generate_minimal_trf_for_api_test(): string
    {
        $name = self::pad_bytes('Test Tournament', 90);
        $city = self::pad_bytes('', 25);
        $fed = '   ';
        $startDate = date('Y/m/d');
        $endDate = $startDate;
        $arbiter = self::pad_bytes('', 40);
        $deputy = self::pad_bytes('', 40);
        $timeControl = self::pad_bytes('', 20);
        $header = '012 ' . $name . ' ' . $city . ' ' . $fed . ' ' . $startDate . ' ' . $endDate . ' ' .
            '   4 ' . '   4 ' . '    0 ' . '  ' . $arbiter . ' ' . $deputy . ' ' . $timeControl;

        $players = [
            ['rank' => 1, 'name' => 'Player One', 'rating' => 1500, 'fed' => 'ITA'],
            ['rank' => 2, 'name' => 'Player Two', 'rating' => 1400, 'fed' => 'ITA'],
            ['rank' => 3, 'name' => 'Player Three', 'rating' => 1300, 'fed' => 'ITA'],
            ['rank' => 4, 'name' => 'Player Four', 'rating' => 1200, 'fed' => 'ITA'],
        ];
        $roundsTotal = 4;
        $roundPart = str_repeat('          ', $roundsTotal);

        $lines = [$header, 'XXC white1'];
        foreach ($players as $i => $p) {
            $startRank = $i + 1;
            $name33 = self::format_trf_name_33bytes($p['name']);
            $line = '001 ' . sprintf('%4d', $startRank) . ' ' . ' ' . '   ' . ' ' . $name33 . ' ' .
                sprintf('%4d', $p['rating']) . ' ' . self::pad_bytes($p['fed'], 3) . ' ' . '          0' . ' ' . '          ' . ' ' .
                sprintf('%4.1f', 0.0) . ' ' . sprintf('%4d', $startRank) . '  ' . $roundPart;
            $lines[] = $line;
        }
        $lines[] = 'XXR ' . $roundsTotal;

        return implode("\r\n", $lines) . "\r\n";
    }

    /**
     * Generate TRF16 format content for tournament.
     *
     * @param int $tournamentId Tournament ID.
     * @return string TRF file content.
     */
    public static function generate_trf(int $tournamentId): string
    {
        $tournament = ChessPodium_TrfExport::get_tournament($tournamentId);
        if (!$tournament) {
            return '';
        }

        $players = ChessPodium_TrfExport::get_players($tournamentId);
        $standings = ChessPodium_TrfExport::compute_standings($tournamentId);
        $pairings = ChessPodium_TrfExport::get_all_pairings($tournamentId);
        $roundsTotal = (int) $tournament->current_round ?: (int) $tournament->rounds_total;

        $lines = [];

        $playerRankMap = [];
        foreach ($standings as $idx => $s) {
            $playerRankMap[(int) $s['player_id']] = $idx + 1;
        }

        $playerIdToStartRank = [];
        $startRank = 1;
        foreach ($players as $p) {
            $playerIdToStartRank[(int) $p->id] = $startRank++;
        }

        $roundResults = self::build_round_results($players, $pairings, $playerIdToStartRank);

        foreach ($standings as $idx => $s) {
            $pid = (int) $s['player_id'];
            $player = self::find_player($players, $pid);
            if (!$player) {
                continue;
            }
            $line = self::format_player_record(
                $player,
                $idx + 1,
                (float) $s['points'],
                $roundsTotal,
                $roundResults[$pid] ?? [],
                $playerIdToStartRank
            );
            $lines[] = $line;
        }

        $teamLines = [];
        $teamCount = 0;
        if (!empty($tournament->is_team_tournament) && class_exists('ChessPodium_TeamTournaments')) {
            $teams = ChessPodium_TeamTournaments::get_teams($tournamentId);
            $teamCount = count($teams);
            $teamLines = self::format_team_section_013($tournamentId, $teams, $players, $playerIdToStartRank);
        }

        $tournamentLine = self::format_tournament_record($tournament, count($players), $roundsTotal, $teamCount);
        array_unshift($lines, $tournamentLine);
        if ($teamLines !== []) {
            // TRF16: team section after header is accepted by FIDE; keep before 001.
            array_splice($lines, 1, 0, $teamLines);
        }

        return implode("\r\n", $lines) . "\r\n";
    }

    /**
     * Generate TRF(x) for bbpPairings/JaVaFo pairing input.
     * Current round: blank for playing, "0000 - -" for absent/withdrawn, "0000 - =" for bye.
     * XXR extension added for total rounds.
     *
     * @param int $tournamentId Tournament ID.
     * @param int $roundToPair Round number to pair (e.g. 2 for pairing round 2).
     * @param array<int> $absentForRound Player IDs not playing (withdrawn).
     * @param array<int> $byeForRound Player IDs with half-point bye.
     * @return array{trf: string, startRankToPlayerId: array<int,int>} TRF content and ID mapping.
     */
    public static function generate_trf_for_pairing(int $tournamentId, int $roundToPair, array $absentForRound = [], array $byeForRound = []): array
    {
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return ['trf' => '', 'startRankToPlayerId' => []];
        }

        $players = self::get_players($tournamentId);
        $standings = self::compute_standings($tournamentId);
        $pairings = self::get_all_pairings($tournamentId);
        $roundsTotal = (int) ($tournament->rounds_total ?: 9);

        // FIDE Dutch A.2: pairing order = score, then pairing number (Nr) or rating. JaVaFo uses pairing-id from 001.
        // Pairing-id = Nr from chess-results. Use start_number when set, else fallback to get_starting_ranks.
        // When round2_pairing_tiebreak = 'rating', we use XXC rank and order by rating (Swiss Manager style).
        // Baku Accelerated: add virtual points to GA (top half by Nr) in early rounds. FIDE C.04.7.
        $pointsByPid = [];
        $startRankByPid = [];
        $ratingByPid = [];
        foreach ($standings as $s) {
            $pointsByPid[(int) $s['player_id']] = (float) ($s['points'] ?? 0);
            $startRankByPid[(int) $s['player_id']] = (int) ($s['start_rank'] ?? 999);
        }
        foreach ($players as $p) {
            $pid = (int) $p->id;
            if (!isset($pointsByPid[$pid])) {
                $pointsByPid[$pid] = 0.0;
                $startRankByPid[$pid] = 999;
            }
            $ratingByPid[$pid] = (int) ($p->rating ?? 0);
        }

        // Baku Accelerated: virtual points for GA in accelerated rounds. GA = first half (rounded up to even).
        $useBaku = (property_exists($tournament, 'pairing_acceleration') && $tournament->pairing_acceleration === 'baku');
        $virtualPointsByPid = [];
        if ($useBaku) {
            // For chess-results / Swiss-Manager compatibility, "GA" must be computed only over players
            // that are actually participating in the round pairing (not withdrawn/absent).
            // Note: $absentForRound can include bye-requested players; we must exclude only true absent/withdrawn.
            $byeSet = [];
            foreach ($byeForRound as $pid) {
                $byeSet[(int) $pid] = true;
            }

            $absentOnlySet = [];
            foreach ($absentForRound as $pid) {
                $pid = (int) $pid;
                if (!isset($byeSet[$pid])) {
                    $absentOnlySet[$pid] = true;
                }
            }

            $activePlayers = array_values(array_filter($players, static function ($p) use ($absentOnlySet) {
                return !isset($absentOnlySet[(int) $p->id]);
            }));

            $playerCount = count($activePlayers);
            $gaSize = 2 * (int) ceil($playerCount / 4);
            $acceleratedRounds = (int) ceil($roundsTotal / 2);
            $firstHalfAccelerated = (int) ceil($acceleratedRounds / 2);
            $virtualPerGa = 0.0;
            if ($roundToPair <= $acceleratedRounds) {
                $virtualPerGa = $roundToPair <= $firstHalfAccelerated ? 1.0 : 0.5;
            }

            // GA membership (FIDE C.04.7): top GA players by "Nr" ordering among active players.
            // Here "Nr" means chess-results pairing/start number (start_number) when available,
            // otherwise fallback to the computed start_rank position.
            $activeNr = [];
            foreach ($activePlayers as $p) {
                $pid = (int) $p->id;
                $nr = isset($p->start_number) && (int) $p->start_number > 0
                    ? (int) $p->start_number
                    : ($startRankByPid[$pid] ?? 9999);
                $activeNr[] = ['pid' => $pid, 'nr' => $nr];
            }

            usort($activeNr, static function ($a, $b) {
                if ($a['nr'] !== $b['nr']) {
                    return $a['nr'] <=> $b['nr'];
                }
                return $a['pid'] <=> $b['pid'];
            });

            $gaPids = [];
            $gaLimit = min($gaSize, count($activeNr));
            for ($i = 0; $i < $gaLimit; $i++) {
                $gaPids[(int) $activeNr[$i]['pid']] = true;
            }

            foreach ($players as $p) {
                $pid = (int) $p->id;
                $isGa = isset($gaPids[$pid]);
                $virtualPointsByPid[$pid] = $isGa ? $virtualPerGa : 0.0;
                $pointsByPid[$pid] = ($pointsByPid[$pid] ?? 0.0) + ($isGa ? $virtualPerGa : 0.0);
            }
        } else {
            foreach ($players as $p) {
                $virtualPointsByPid[(int) $p->id] = 0.0;
            }
        }
        // Read round2_pairing_tiebreak: 'nr' (FIDE) or 'rating' (Swiss Manager). Use property_exists for robustness when column was added by migration.
        $tiebreak = (property_exists($tournament, 'round2_pairing_tiebreak') && $tournament->round2_pairing_tiebreak !== null && $tournament->round2_pairing_tiebreak !== '')
            ? (string) $tournament->round2_pairing_tiebreak
            : 'nr';
        if (!in_array($tiebreak, ['nr', 'rating'], true)) {
            $tiebreak = 'nr';
        }
        $tiebreak = (string) apply_filters('chess_podium_round2_pairing_tiebreak', $tiebreak, $tournamentId, $roundToPair);
        if (!in_array($tiebreak, ['nr', 'rating'], true)) {
            $tiebreak = 'nr';
        }
        $useRatingTiebreak = $roundToPair > 1 && $tiebreak === 'rating';
        if (defined('CHESS_PODIUM_DEBUG_PAIRING') && CHESS_PODIUM_DEBUG_PAIRING) {
            $bakuLog = $useBaku ? (' baku=1 gaSize=' . $gaSize . ' virtualPerGa=' . $virtualPerGa) : ' baku=0';
            error_log('[Chess Podium TRF] tid=' . $tournamentId . ' round=' . $roundToPair . ' round2_pairing_tiebreak=' . $tiebreak . ' useRatingTiebreak=' . ($useRatingTiebreak ? 'true' : 'false') . $bakuLog);
        }
        usort($players, static function ($a, $b) use ($pointsByPid, $startRankByPid, $ratingByPid, $useRatingTiebreak) {
            $pa = $pointsByPid[(int) $a->id] ?? 0.0;
            $pb = $pointsByPid[(int) $b->id] ?? 0.0;
            if (abs($pb - $pa) >= 0.001) {
                return $pb <=> $pa;
            }
            if ($useRatingTiebreak) {
                $ra = $ratingByPid[(int) $a->id] ?? 0;
                $rb = $ratingByPid[(int) $b->id] ?? 0;
                if ($rb !== $ra) {
                    return $rb <=> $ra;
                }
            }
            $ra = $startRankByPid[(int) $a->id] ?? 999;
            $rb = $startRankByPid[(int) $b->id] ?? 999;
            if ($ra !== $rb) {
                return $ra <=> $rb;
            }
            return ((int) $a->id) <=> ((int) $b->id);
        });

        // Pairing-id: use start_number (Nr from chess-results) when all players have unique Nr 1..N, else start_rank.
        // When useRatingTiebreak: use positional numbering (1,2,3...) so Pins match rating order. Works with JaVaFo XXC rank
        // and with engines that ignore XXC (e.g. bbpPairings fallback) since Pins are already in correct order.
        $candidateNr = [];
        foreach ($players as $p) {
            $pid = (int) $p->id;
            $nr = isset($p->start_number) && (int) $p->start_number > 0 ? (int) $p->start_number : null;
            $candidateNr[$pid] = $nr !== null && $nr <= 9999 ? $nr : null;
        }
        $uniqueCount = count(array_filter(array_unique(array_values(array_filter($candidateNr)))));
        $hasAllNr = count(array_filter($candidateNr)) === count($players);
        $useStartNumber = $hasAllNr && $uniqueCount === count($players);
        $playerIdToStartRank = [];
        if ($useRatingTiebreak) {
            foreach ($players as $i => $p) {
                $playerIdToStartRank[(int) $p->id] = $i + 1;
            }
        } else {
            foreach ($players as $p) {
                $pid = (int) $p->id;
                $playerIdToStartRank[$pid] = $useStartNumber && $candidateNr[$pid] !== null
                    ? $candidateNr[$pid]
                    : ($startRankByPid[$pid] ?? 999);
            }
        }

        $roundResults = self::build_round_results($players, $pairings, $playerIdToStartRank);

        foreach ($absentForRound as $pid) {
            $roundResults[$pid] = $roundResults[$pid] ?? [];
            // JaVaFo AUM: "0000 - -" = absent or retired player. Same for bbpPairings.
            $roundResults[$pid][$roundToPair] = ['opp' => 0, 'color' => '-', 'result' => '-'];
        }
        foreach ($byeForRound as $pid) {
            $roundResults[$pid] = $roundResults[$pid] ?? [];
            $roundResults[$pid][$roundToPair] = ['opp' => 0, 'color' => '-', 'result' => '='];
        }

        $standingsByPid = [];
        foreach ($standings as $idx => $s) {
            $standingsByPid[(int) $s['player_id']] = ['points' => (float) $s['points'], 'rank' => $idx + 1];
        }

        $pointsFromRounds = self::compute_points_from_round_results($roundResults, $tournamentId);
        $lines = [];
        foreach ($players as $i => $p) {
            $pid = (int) $p->id;
            $info = $standingsByPid[$pid] ?? ['points' => 0.0, 'rank' => $i + 1];
            $realPoints = $pointsFromRounds[$pid] ?? $info['points'];
            // 001 col 81-84: JaVaFo validates that score = sum of game results (AUM).
            // Keep REAL points only to avoid failing JaVaFo and falling back to built-in pairing.
            $points = $realPoints;
            $standingsRank = $info['rank'];
            $line = self::format_player_record(
                $p,
                $standingsRank,
                $points,
                $roundsTotal,
                $roundResults[$pid] ?? [],
                $playerIdToStartRank
            );
            $lines[] = $line;
        }

        $tournamentLine = self::format_tournament_record($tournament, count($players), $roundsTotal);
        array_unshift($lines, $tournamentLine);
        $extensions = [];
        if (!empty($absentForRound)) {
            $absentRanks = [];
            foreach ($absentForRound as $pid) {
                $nr = $playerIdToStartRank[$pid] ?? null;
                if ($nr !== null && $nr <= 9999) {
                    $absentRanks[] = $nr;
                }
            }
            if (!empty($absentRanks)) {
                $extensions[] = 'XXZ ' . implode(' ', $absentRanks);
            }
        }
        $xxcColor = self::detect_first_player_color($pairings, $playerIdToStartRank);
        // With "XXC rank ..." JaVaFo uses the order of 001 lines (file order) instead of the numeric points.
        // This is required for Baku Accelerated since we keep REAL points in 001 but reorder players using
        // virtual points for GA.
        $xxcPrefix = ($useRatingTiebreak || $useBaku) ? 'rank ' : '';
        $extensions[] = 'XXC ' . $xxcPrefix . $xxcColor . '1';
        $extensions[] = 'XXR ' . $roundsTotal;
        array_splice($lines, 1, 0, $extensions);

        $startRankToPlayerId = [];
        foreach ($playerIdToStartRank as $pid => $nr) {
            $startRankToPlayerId[$nr] = $pid;
        }

        if (defined('CHESS_PODIUM_DEBUG_TRF') && CHESS_PODIUM_DEBUG_TRF) {
            self::validate_trf_001_line_lengths($lines);
        }

        return ['trf' => implode("\r\n", $lines) . "\r\n", 'startRankToPlayerId' => $startRankToPlayerId];
    }

    /**
     * Determina il colore del TPN 1 (giocatore con startRank=1) al round 1.
     * JaVaFo usa XXC per sapere la convenzione colori iniziale e calcolare
     * il color balance di tutti i giocatori nei round successivi.
     * Se round 1 già giocato, leggiamo il colore reale dai pairings. Altrimenti: 'white' (convenzione FIDE).
     *
     * @param array $pairings Pairings da get_all_pairings.
     * @param array<int,int> $playerIdToStartRank playerId => startRank. startRank 1 = TPN 1.
     * @return string 'white' o 'black'
     */
    private static function detect_first_player_color(array $pairings, array $playerIdToStartRank): string
    {
        $tpn1Id = null;
        foreach ($playerIdToStartRank as $pid => $rank) {
            if ($rank === 1) {
                $tpn1Id = (int) $pid;
                break;
            }
        }
        if ($tpn1Id === null) {
            return 'white';
        }

        foreach ($pairings as $m) {
            if ((int) $m->round_no !== 1 || (int) $m->is_bye === 1) {
                continue;
            }
            if ((int) $m->white_player_id === $tpn1Id) {
                return 'white';
            }
            if ((int) $m->black_player_id === $tpn1Id) {
                return 'black';
            }
        }

        return 'white';
    }

    private static function get_tournament(int $id): ?object
    {
        if (!class_exists('ChessPodiumPlugin')) {
            return null;
        }
        return ChessPodiumPlugin::get_tournament($id);
    }

    private static function get_players(int $tournamentId): array
    {
        if (!class_exists('ChessPodiumPlugin')) {
            return [];
        }
        return ChessPodiumPlugin::get_players($tournamentId);
    }

    private static function compute_standings(int $tournamentId): array
    {
        if (!class_exists('ChessPodiumPlugin')) {
            return [];
        }
        return ChessPodiumPlugin::compute_standings($tournamentId);
    }

    private static function get_all_pairings(int $tournamentId): array
    {
        global $wpdb;
        $table = $wpdb->prefix . 'regina_pairings';
        return $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE tournament_id = %d ORDER BY round_no ASC, id ASC",
            $tournamentId
        ));
    }

    private static function find_player(array $players, int $id): ?object
    {
        foreach ($players as $p) {
            if ((int) $p->id === $id) {
                return $p;
            }
        }
        return null;
    }

    /**
     * Compute points from round results. JaVaFo requires cols 81-84 to match sum of round results.
     * FIDE: 1/+ = 1pt, 0/- = 0pt, =/H = 0.5pt.
     *
     * @param array<int, array<int, array{opp: int, color: string, result: string}>> $roundResults pid => round => result
     * @param int $tournamentId For bye_points default.
     * @return array<int, float> pid => points
     */
    private static function compute_points_from_round_results(array $roundResults, int $tournamentId): array
    {
        $out = [];
        foreach ($roundResults as $pid => $rounds) {
            $sum = 0.0;
            foreach ($rounds as $res) {
                $code = $res['result'] ?? '-';
                if (in_array($code, ['1', '+'], true)) {
                    $sum += 1.0;
                } elseif (in_array($code, ['=', 'H'], true)) {
                    $sum += 0.5;
                }
            }
            $out[$pid] = round($sum, 1);
        }
        return $out;
    }

    private static function build_round_results(array $players, array $pairings, array $playerIdToStartRank): array
    {
        $results = [];
        foreach ($players as $p) {
            $results[(int) $p->id] = [];
        }

        foreach ($pairings as $m) {
            $wId = (int) $m->white_player_id;
            $bId = (int) $m->black_player_id;
            $round = (int) $m->round_no;
            $isBye = (int) $m->is_bye === 1;
            $res = class_exists('ChessPodiumPlugin') ? ChessPodiumPlugin::normalize_result((string) ($m->result ?? '')) : trim((string) ($m->result ?? ''));

            if ($isBye) {
                // JaVaFo AUM: 0000 - + = pairing-allocated bye (1pt), 0000 - = = half-point, 0000 - - = zero/absent.
                $byePts = isset($m->bye_points) && $m->bye_points !== null ? (float) $m->bye_points : 1.0;
                $resCode = $byePts >= 1.0 ? '+' : ($byePts >= 0.5 ? '=' : '-');
                $results[$wId][$round] = ['opp' => 0, 'color' => '-', 'result' => $resCode];
                continue;
            }

            $wRank = $playerIdToStartRank[$wId] ?? 0;
            $bRank = $playerIdToStartRank[$bId] ?? 0;

            if (in_array($res, ['1-0', '1F'], true)) {
                $isForfeit = $res === '1F';
                $results[$wId][$round] = ['opp' => $bRank, 'color' => 'w', 'result' => $isForfeit ? '+' : '1'];
                $results[$bId][$round] = ['opp' => $wRank, 'color' => 'b', 'result' => $isForfeit ? '-' : '0'];
            } elseif (in_array($res, ['0-1', '0F'], true)) {
                $isForfeit = $res === '0F';
                $results[$wId][$round] = ['opp' => $bRank, 'color' => 'w', 'result' => $isForfeit ? '-' : '0'];
                $results[$bId][$round] = ['opp' => $wRank, 'color' => 'b', 'result' => $isForfeit ? '+' : '1'];
            } elseif ($res === '1/2-1/2') {
                $results[$wId][$round] = ['opp' => $bRank, 'color' => 'w', 'result' => '='];
                $results[$bId][$round] = ['opp' => $wRank, 'color' => 'b', 'result' => '='];
            } else {
                $results[$wId][$round] = ['opp' => $bRank, 'color' => 'w', 'result' => '-'];
                $results[$bId][$round] = ['opp' => $wRank, 'color' => 'b', 'result' => '-'];
            }
        }
        return $results;
    }

    /**
     * Format single round block for TRF. FIDE TRF16: exactly 10 ASCII chars per round.
     * Round blocks start at column 92; each block = 10 chars.
     * FIDE symbols: + = forfeit win, - = forfeit loss, H = forfeit draw / half-point bye.
     *
     * @param array|null $res ['opp'=>int,'color'=>'w'|'b'|'-','result'=>'1'|'0'|'='|'+'|'-'|'H']
     * @return string Exactly 10 ASCII characters.
     */
    private static function format_round_block(?array $res): string
    {
        $width = 10;
        if (!$res) {
            return str_repeat(' ', $width);
        }
        $opp = (int) ($res['opp'] ?? 0);
        $color = $res['color'] ?? '-';
        $result = $res['result'] ?? '-';

        if ($opp === 0) {
            $code = in_array($result, ['=', '+', '-', 'H'], true) ? $result : '-';
            return str_pad('0000 - ' . $code, $width, ' ', STR_PAD_RIGHT);
        }
        $oppStr = sprintf('%4d', $opp);
        $col = ($color === 'b') ? 'b' : 'w';
        return $oppStr . ' ' . $col . ' ' . $result . '  ';
    }

    /**
     * Format 001 player record. FIDE TRF16 fixed columns (1-based):
     * 1-3: 001 | 5-8: ID | 10: sex | 11-13: title | 15-47: name (33) | 49-52: rating |
     * 54-56: fed | 58-68: FIDE ID | 70-79: birth | 81-84: points | 86-89: rank |
     * 92+: round blocks (10 chars each).
     */
    private static function format_player_record(
        object $player,
        int $rank,
        float $points,
        int $roundsTotal,
        array $roundResults,
        array $playerIdToStartRank
    ): string {
        $pid = (int) $player->id;
        $startRank = $playerIdToStartRank[$pid] ?? $rank;

        $col1_3 = '001';
        $col5_8 = sprintf('%4d', min(9999, max(1, $startRank)));
        $col10 = isset($player->sex) && in_array((string) $player->sex, ['m', 'w'], true) ? (string) $player->sex : ' ';
        $col11_13 = isset($player->title) ? self::normalize_fide_title((string) $player->title) : '   ';
        $col15_47 = self::format_trf_name_33bytes((string) $player->name);
        $col49_52 = sprintf('%4d', min(9999, max(0, (int) ($player->rating ?? 0))));
        $col54_56 = self::pad_bytes(strtoupper(self::truncate_bytes((string) ($player->country ?? ''), 3)), 3);
        $fideIdRaw = trim((string) ($player->fide_id ?? ''));
        $col58_68 = $fideIdRaw === '' ? '          0' : self::pad_bytes($fideIdRaw, 11, ' ', STR_PAD_LEFT);
        $col70_79 = isset($player->birth_date) && (string) $player->birth_date !== ''
            ? self::format_birth_date((string) $player->birth_date)
            : '          ';
        $col81_84 = sprintf('%4.1f', min(99.9, max(0, $points)));
        $col86_89 = sprintf('%4d', min(9999, max(1, $rank)));

        $roundPart = '';
        for ($r = 1; $r <= $roundsTotal; $r++) {
            $roundPart .= self::format_round_block($roundResults[$r] ?? null);
        }

        $line = $col1_3 . ' ' . $col5_8 . ' ' . $col10 . $col11_13 . ' ' . $col15_47 . ' ' .
            $col49_52 . ' ' . $col54_56 . ' ' . $col58_68 . ' ' . $col70_79 . ' ' .
            $col81_84 . ' ' . $col86_89 . '  ' . $roundPart;

        return $line;
    }

    /**
     * FIDE TRF16 tournament header (012): fixed column positions.
     * Col 1-4: 012 + space; 5-94: name (90); 96-120: city (25); 122-124: fed (3);
     * 126-135: start (10); 137-146: end (10); 148-151: players (4); 153-156: rated (4);
     * 158-161: teams (4); 163-164: type (2); 166-205: arbiter (40); 207-246: deputy (40); 248-267: time (20).
     */
    private static function format_tournament_record(object $tournament, int $playerCount, int $roundsTotal, int $teamCount = 0): string
    {
        $name = self::pad_bytes(self::sanitize_trf_text((string) $tournament->name), 90);
        $city = self::pad_bytes(self::sanitize_trf_text((string) ($tournament->city ?? '')), 25);
        $fed = self::pad_bytes(strtoupper(self::truncate_bytes((string) ($tournament->federation ?? ''), 3)), 3);
        $startDate = isset($tournament->start_date) ? self::format_trf_date((string) $tournament->start_date) : date('Y/m/d');
        $endDate = isset($tournament->end_date) ? self::format_trf_date((string) $tournament->end_date) : $startDate;
        $arbiter = self::pad_bytes(self::sanitize_trf_text((string) ($tournament->chief_arbiter ?? '')), 40);
        $deputy = self::pad_bytes(self::sanitize_trf_text((string) ($tournament->deputy_arbiter ?? '')), 40);
        $timeControl = self::pad_bytes(self::sanitize_trf_text((string) ($tournament->time_control ?? '')), 20);

        $type = '  ';
        if (isset($tournament->tournament_type)) {
            $raw = strtolower((string) $tournament->tournament_type);
            if ($raw === 'round_robin' || $raw === 'double_round_robin') {
                $t = 'RR';
            } elseif ($raw === 'swiss') {
                $t = 'SW';
            } else {
                $t = self::truncate_bytes(strtoupper((string) $tournament->tournament_type), 2);
            }
            $type = self::pad_bytes($t, 2);
        }
        $teamCount = max(0, min(9999, $teamCount));
        return '012 ' . $name . ' ' . $city . ' ' . $fed . ' ' . $startDate . ' ' . $endDate . ' ' .
            sprintf('%4d', $playerCount) . ' ' . sprintf('%4d', $playerCount) . ' ' . sprintf('%4d', $teamCount) . ' ' .
            $type . '  ' . $arbiter . ' ' . $deputy . ' ' . $timeControl;
    }

    /**
     * TRF16 team section 013.
     *
     * Team Name: positions 5-36 (32 chars).
     * Player fields: positions 37-40, 42-45, 47-50... (each 4 chars, start rank from 001).
     *
     * @param list<object{id:int,name:string,federation:?string,start_number:?int}> $teams
     * @param array<int,int> $playerIdToStartRank
     * @return list<string>
     */
    private static function format_team_section_013(int $tournamentId, array $teams, array $players, array $playerIdToStartRank): array
    {
        $playersByTeam = [];
        foreach ($players as $p) {
            $teamId = isset($p->team_id) ? (int) $p->team_id : 0;
            if ($teamId <= 0) {
                continue;
            }
            $playersByTeam[$teamId] = $playersByTeam[$teamId] ?? [];
            $playersByTeam[$teamId][] = $p;
        }
        foreach ($playersByTeam as $teamId => &$list) {
            usort($list, static function ($a, $b) {
                $ba = (int) ($a->board_no ?? 0);
                $bb = (int) ($b->board_no ?? 0);
                if ($ba > 0 && $bb > 0 && $ba !== $bb) {
                    return $ba <=> $bb;
                }
                if ($ba > 0 && $bb <= 0) {
                    return -1;
                }
                if ($bb > 0 && $ba <= 0) {
                    return 1;
                }
                return strcmp((string) ($a->name ?? ''), (string) ($b->name ?? ''));
            });
        }
        unset($list);

        $lines = [];
        foreach ($teams as $t) {
            $teamId = (int) ($t->id ?? 0);
            $name32 = self::pad_bytes(self::sanitize_trf_text((string) ($t->name ?? '')), 32);
            $line = '013 ' . $name32;
            $members = $playersByTeam[$teamId] ?? [];
            $max = 14;
            for ($i = 0; $i < $max; $i++) {
                $rank = 0;
                if (isset($members[$i])) {
                    $pid = (int) ($members[$i]->id ?? 0);
                    $rank = $playerIdToStartRank[$pid] ?? 0;
                }
                $line .= ($rank > 0 ? sprintf('%4d', $rank) : '    ') . ' ';
            }
            $lines[] = rtrim($line);
        }

        return $lines;
    }

    /**
     * Sanitize text for TRF: transliterate, remove non-ISO-8859-1.
     */
    private static function sanitize_trf_text(string $s): string
    {
        $s = self::transliterate_to_ascii($s);
        $s = preg_replace('/[^\x20-\x7E]/', '', $s);
        $c = @mb_convert_encoding($s, 'ISO-8859-1', 'UTF-8');
        return $c !== false ? $c : $s;
    }

    /**
     * Validate TRF tournament header: number of players at column 148 (1-based).
     *
     * @param string $line   Header line.
     * @param int    $expect Expected player count.
     * @return bool True if valid.
     */
    public static function validate_trf_header(string $line, int $expect): bool
    {
        if (substr($line, 0, 3) !== '012') {
            return false;
        }
        $playersStr = trim(substr($line, 147, 4));
        $parsed = (int) $playersStr;
        return $parsed === $expect && $parsed >= 0 && $parsed <= 9999;
    }

    /**
     * Validate all 001 lines have identical length. Required for JaVaFo.
     * Call when CHESS_PODIUM_DEBUG_TRF is defined to log mismatches.
     *
     * @param array<string> $lines TRF lines.
     * @return bool True if all 001 lines have same length.
     */
    public static function validate_trf_001_line_lengths(array $lines): bool
    {
        $lengths = [];
        foreach ($lines as $line) {
            if (substr($line, 0, 3) === '001') {
                $lengths[] = strlen($line);
            }
        }
        if (count($lengths) < 2) {
            return true;
        }
        $first = $lengths[0];
        foreach ($lengths as $i => $len) {
            if ($len !== $first) {
                if (defined('CHESS_PODIUM_DEBUG_TRF') && CHESS_PODIUM_DEBUG_TRF) {
                    error_log('[Chess Podium TRF] 001 line length mismatch: line ' . ($i + 1) . ' has ' . $len . ' chars, expected ' . $first);
                }
                return false;
            }
        }
        return true;
    }

    /**
     * Name for TRF: Lastname, Firstname, transliterate, sanitize, ISO-8859-1, 33 bytes.
     * FIDE TRF16 col 15-47: exactly 33 chars. Multibyte UTF-8 breaks column alignment for JaVaFo.
     */
    private static function format_trf_name_33bytes(string $name): string
    {
        $name = self::format_trf_name($name);
        $name = self::transliterate_to_ascii($name);
        $name = preg_replace('/[^\x20-\x7E]/', '', $name);
        $converted = @mb_convert_encoding($name, 'ISO-8859-1', 'UTF-8');
        if ($converted !== false) {
            $name = $converted;
        }
        return self::pad_bytes($name, 33, ' ', STR_PAD_RIGHT);
    }

    /**
     * Pad/truncate string to exact byte length. TRF requires fixed byte positions.
     */
    private static function pad_bytes(string $s, int $byteLen, string $pad = ' ', int $padType = STR_PAD_RIGHT): string
    {
        $s = self::truncate_bytes($s, $byteLen);
        $currentBytes = strlen($s);
        if ($currentBytes >= $byteLen) {
            return substr($s, 0, $byteLen);
        }
        return $padType === STR_PAD_LEFT
            ? str_pad($s, $byteLen, $pad, STR_PAD_LEFT)
            : str_pad($s, $byteLen, $pad, STR_PAD_RIGHT);
    }

    /**
     * Truncate string to max byte length (not char length).
     */
    private static function truncate_bytes(string $s, int $maxBytes): string
    {
        if (strlen($s) <= $maxBytes) {
            return $s;
        }
        return substr($s, 0, $maxBytes);
    }

    /**
     * Transliterate accented chars to ASCII for TRF/JaVaFo compatibility.
     * JaVaFo and bbpPairings reject lines with non-ASCII (e.g. č in Kovačič).
     */
    private static function transliterate_to_ascii(string $s): string
    {
        $map = [
            'à'=>'a','á'=>'a','â'=>'a','ã'=>'a','ä'=>'a','å'=>'a','æ'=>'ae','ç'=>'c','è'=>'e','é'=>'e','ê'=>'e','ë'=>'e',
            'ì'=>'i','í'=>'i','î'=>'i','ï'=>'i','ñ'=>'n','ò'=>'o','ó'=>'o','ô'=>'o','õ'=>'o','ö'=>'o','ø'=>'o','ù'=>'u',
            'ú'=>'u','û'=>'u','ü'=>'u','ý'=>'y','ÿ'=>'y','ß'=>'ss',
            'č'=>'c','ć'=>'c','đ'=>'d','š'=>'s','ž'=>'z','ń'=>'n','ł'=>'l','ą'=>'a','ę'=>'e','ś'=>'s','ź'=>'z','ż'=>'z',
            'ă'=>'a','ș'=>'s','ț'=>'t',
            'À'=>'A','Á'=>'A','Â'=>'A','Ã'=>'A','Ä'=>'A','Å'=>'A','Æ'=>'AE','Ç'=>'C','È'=>'E','É'=>'E','Ê'=>'E','Ë'=>'E',
            'Ì'=>'I','Í'=>'I','Î'=>'I','Ï'=>'I','Ñ'=>'N','Ò'=>'O','Ó'=>'O','Ô'=>'O','Õ'=>'O','Ö'=>'O','Ø'=>'O','Ù'=>'U',
            'Ú'=>'U','Û'=>'U','Ü'=>'U','Ý'=>'Y','Č'=>'C','Ć'=>'C','Đ'=>'D','Š'=>'S','Ž'=>'Z','Ń'=>'N','Ł'=>'L','Ą'=>'A','Ę'=>'E','Ś'=>'S','Ź'=>'Z','Ż'=>'Z',
        ];
        return strtr($s, $map);
    }

    private static function format_trf_name(string $name): string
    {
        $name = self::transliterate_to_ascii($name);
        $parts = array_map('trim', explode(',', $name, 2));
        if (count($parts) === 2) {
            return $parts[0] . ', ' . $parts[1];
        }
        $words = preg_split('/\s+/', trim($name), 2);
        if (count($words) >= 2) {
            return $words[1] . ', ' . $words[0];
        }
        return $name;
    }

    private static function format_birth_date(string $date): string
    {
        $ts = strtotime($date);
        if ($ts === false) {
            return '          ';
        }
        return date('Y/m/d', $ts);
    }

    private static function format_trf_date(string $date): string
    {
        $ts = strtotime($date);
        if ($ts === false) {
            return date('Y/m/d');
        }
        return date('Y/m/d', $ts);
    }

    /**
     * Normalize FIDE title to 3-char code. FIDE: GM, IM, WGM, FM, WIM, CM, WFM, WCM.
     *
     * @param string $title Raw title from player.
     * @return string 3-character padded title.
     */
    private static function normalize_fide_title(string $title): string
    {
        $t = strtoupper(trim($title));
        if ($t === '') {
            return '   ';
        }
        $fideTitles = ['GM', 'IM', 'WGM', 'FM', 'WIM', 'CM', 'WFM', 'WCM'];
        if (in_array($t, $fideTitles, true)) {
            return str_pad($t, 3, ' ', STR_PAD_RIGHT);
        }
        return str_pad(mb_substr($t, 0, 3), 3, ' ', STR_PAD_RIGHT);
    }
}
