<?php
/**
 * Export Engine - Generates static HTML for tournament publication
 *
 * @package Chess_Podium
 */

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

final class ChessPodium_ExportEngine
{
    /**
     * SEO-friendly per-tournament player slugs, unique within the export folder.
     *
     * @return array<int,string> playerId => slug (without .html)
     */
    private static function build_player_slug_map(array $players): array
    {
        $used = [];
        $map = [];
        foreach ($players as $pl) {
            $pid = (int) ($pl->id ?? 0);
            if ($pid <= 0) {
                continue;
            }
            $name = (string) ($pl->name ?? '');
            $base = sanitize_title($name);
            if ($base === '') {
                $base = 'player-' . $pid;
            }
            $slug = $base;
            if (isset($used[$slug])) {
                // Ensure uniqueness when two players share the same name.
                $slug = $base . '-' . $pid;
            }
            $used[$slug] = true;
            $map[$pid] = $slug;
        }
        return $map;
    }

    private static function player_profile_filename(int $playerId, array $slugMap): string
    {
        $slug = $slugMap[$playerId] ?? ('player-' . $playerId);
        return $slug . '.html';
    }

    private static function legacy_player_profile_filename(int $playerId): string
    {
        return 'player-' . $playerId . '.html';
    }

    /**
     * Escape href for static export HTML.
     * WordPress esc_url() prepends http:// to strings without a scheme (e.g. "name.html"),
     * which breaks relative player profile links (browser navigates to http://name.html/).
     */
    private static function esc_export_href(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }
        $lower = strtolower($url);
        if (strpos($lower, 'http://') === 0 || strpos($lower, 'https://') === 0 || strpos($lower, '//') === 0) {
            return esc_url($url);
        }
        return esc_attr($url);
    }

    private static function build_legacy_redirect_html(string $title, string $targetFile): string
    {
        $t = esc_html($title);
        $target = esc_attr($targetFile);
        return '<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=' . $target . '"><link rel="canonical" href="' . $target . '"><title>' . $t . '</title></head><body><p><a href="' . $target . '">' . $t . '</a></p></body></html>';
    }

    public static function get_export_base_dir(): string
    {
        $uploadDir = wp_upload_dir();
        $base = $uploadDir['basedir'] . '/chess-podium';
        if (!file_exists($base)) {
            wp_mkdir_p($base);
        }
        return $base;
    }

    public static function get_export_url_base(): string
    {
        $uploadDir = wp_upload_dir();
        return $uploadDir['baseurl'] . '/chess-podium';
    }

    public static function get_tournament_slug(object $tournament): string
    {
        $base = sanitize_title((string) $tournament->name);
        return $base ?: 'torneo-' . (int) $tournament->id;
    }

    public static function get_export_dir(object $tournament): string
    {
        return self::get_export_base_dir() . '/' . self::get_tournament_slug($tournament);
    }

    public static function get_export_url(object $tournament): string
    {
        return self::get_export_url_base() . '/' . self::get_tournament_slug($tournament);
    }

    public static function generate_export(object $tournament, array $players, array $standings, array $allPairings, array $photos, array $pgns, ?string $venuePhotoUrl = null): array
    {
        $dir = self::get_export_dir($tournament);
        if (!file_exists($dir)) {
            wp_mkdir_p($dir);
        }

        $playerSlugById = self::build_player_slug_map($players);
        $slug = self::get_tournament_slug($tournament);
        $baseUrl = self::get_export_url($tournament);
        $roundsTotal = (int) $tournament->current_round;

        $customBrochureUrl = class_exists('ChessPodium_PdfBrochure') ? ChessPodium_PdfBrochure::get_meta((int) $tournament->id, 'custom_brochure_pdf_url') : null;
        $hasBrochure = false;
        if (!empty($customBrochureUrl)) {
            $uploadDir = wp_upload_dir();
            $contentUrl = $uploadDir['baseurl'] . '/';
            $contentPath = $uploadDir['basedir'] . '/';
            $localPath = null;
            if (strpos($customBrochureUrl, $contentUrl) === 0) {
                $relative = substr($customBrochureUrl, strlen($contentUrl));
                $localPath = $contentPath . $relative;
            }
            if ($localPath && file_exists($localPath) && is_readable($localPath)) {
                $body = file_get_contents($localPath);
                if ($body !== false && strlen($body) > 0) {
                    self::write_file($dir . '/bando.pdf', $body);
                    $hasBrochure = true;
                }
            } else {
                $response = wp_remote_get($customBrochureUrl);
                if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
                    $body = wp_remote_retrieve_body($response);
                    if (strlen($body) > 0) {
                        self::write_file($dir . '/bando.pdf', $body);
                        $hasBrochure = true;
                    }
                }
            }
        }
        if (!$hasBrochure && class_exists('ChessPodium_PdfBrochure')) {
            $brochurePdf = ChessPodium_PdfBrochure::generate_pdf($tournament);
            $hasBrochure = $brochurePdf && strlen($brochurePdf) > 0;
            if ($hasBrochure) {
                self::write_file($dir . '/bando.pdf', $brochurePdf);
            }
        }

        $playerStats = self::compute_player_stats($players, $allPairings);
        $navHtml = self::build_nav_html($slug, $roundsTotal, $hasBrochure);
        $standingsById = [];
        foreach ($standings as $pos => $s) {
            $pid = (int) ($s['player_id'] ?? 0);
            if ($pid > 0) {
                $standingsById[$pid] = ['pos' => (int) ($pos + 1), 'row' => $s];
            }
        }
        $gameByPairingId = [];
        $gameId = 0;
        foreach ($pgns as $pgn) {
            $gameId++;
            $pairingId = (int) ($pgn->pairing_id ?? 0);
            if ($pairingId > 0) {
                $gameByPairingId[$pairingId] = $gameId;
            }
        }

        $playersById = [];
        foreach ($players as $pl) {
            $playersById[(int) ($pl->id ?? 0)] = $pl;
        }

        $flagBaseFilter = static fn () => 'img/flags';
        add_filter('chess_podium_flag_img_base_path', $flagBaseFilter);
        try {
            $indexHtml = self::build_index_html($tournament, $players, $standings, $navHtml, $playerStats, $playerSlugById, $playersById, $hasBrochure, $venuePhotoUrl);
            self::write_file($dir . '/index.html', $indexHtml);

            $standingHtml = self::build_standing_html($tournament, $standings, $navHtml, $playerStats, $playerSlugById, $playersById);
            self::write_file($dir . '/standing.html', $standingHtml);

            $eloHtml = self::build_elo_html($tournament, $standings, $playerStats, $navHtml, $playerSlugById, $playersById);
            self::write_file($dir . '/elo.html', $eloHtml);

            for ($r = 1; $r <= $roundsTotal; $r++) {
                $roundPairings = array_filter($allPairings, static fn ($p) => (int) $p->round_no === $r);
                $pairsHtml = self::build_pairs_html($tournament, $roundPairings, $r, $navHtml, $playerStats, $playerSlugById, $playersById);
                self::write_file($dir . '/pairs' . $r . '.html', $pairsHtml);
            }

            $gamesHtml = self::build_games_html($tournament, $allPairings, $pgns, $navHtml);
            self::write_file($dir . '/games.html', $gamesHtml);

            // Single-game pages (optional but useful for player profile deep links).
            $totalGames = is_array($pgns) ? count($pgns) : 0;
            if ($totalGames > 0) {
                $gid = 0;
                foreach ($pgns as $pgn) {
                    $gid++;
                    $single = self::build_single_game_html($tournament, $pgn, $gid, $totalGames, $pgns, $navHtml);
                    self::write_file($dir . '/game-' . $gid . '.html', $single);
                }
            }

            // Player profile pages (static export).
            foreach ($players as $pl) {
                $pid = (int) ($pl->id ?? 0);
                if ($pid <= 0) {
                    continue;
                }
                $standingInfo = $standingsById[$pid] ?? null;
                $profileHtml = self::build_player_profile_html($tournament, $pl, $standingInfo, $playerStats, $navHtml, $gameByPairingId, $playerSlugById, $playersById);
                $newFile = self::player_profile_filename($pid, $playerSlugById);
                self::write_file($dir . '/' . $newFile, $profileHtml);

                // Backward-compatible legacy file name: player-<id>.html → redirect to SEO slug file.
                $legacy = self::legacy_player_profile_filename($pid);
                if ($legacy !== $newFile) {
                    $redirectTitle = (string) ($pl->name ?? 'Player');
                    self::write_file($dir . '/' . $legacy, self::build_legacy_redirect_html($redirectTitle, $newFile));
                }
            }

            $galleryHtml = self::build_gallery_html($tournament, $photos, $navHtml);
            self::write_file($dir . '/galleria.html', $galleryHtml);
        } finally {
            remove_filter('chess_podium_flag_img_base_path', $flagBaseFilter);
        }

        self::write_css($dir . '/style.css');
        self::copy_export_vendor_assets($dir);
        self::copy_export_profile_avatars($dir);

        $pluginDir = defined('CHESS_PODIUM_PLUGIN_DIR') ? CHESS_PODIUM_PLUGIN_DIR : (dirname(plugin_dir_path(__FILE__)) . '/');
        $logoPath = $pluginDir . 'assets/logo-chess-podium.png';
        if (file_exists($logoPath) && is_readable($logoPath)) {
            $logoContent = file_get_contents($logoPath);
            if ($logoContent !== false) {
                self::write_file($dir . '/logo-chess-podium.png', $logoContent);
            }
        }

        return [
            'success' => true,
            'url' => $baseUrl . '/index.html',
            'dir' => $dir,
        ];
    }

    /** Returns player stats (performance, elo_variation) for live display. */
    public static function get_player_stats(array $players, array $allPairings): array
    {
        return self::compute_player_stats($players, $allPairings);
    }

    private static function compute_player_stats(array $players, array $allPairings): array
    {
        $playerMap = [];
        foreach ($players as $p) {
            $playerMap[(int) $p->id] = [
                'name' => (string) $p->name,
                'country' => isset($p->country) ? (string) $p->country : '',
                'rating' => (int) $p->rating,
                'wins' => 0,
                'draws' => 0,
                'losses' => 0,
                'byes' => 0,
                'bye_points_sum' => 0.0,
                'opponents' => [],
            ];
        }

        $byePointsDefault = 1.0;
        foreach ($allPairings as $m) {
            $wId = (int) $m->white_player_id;
            $bId = (int) $m->black_player_id;
            $res = (string) ($m->result ?? '');
            $isBye = (int) $m->is_bye === 1;
            $round = (int) $m->round_no;
            $pairingId = (int) ($m->id ?? 0);

            if ($isBye) {
                if (isset($playerMap[$wId])) {
                    $playerMap[$wId]['byes']++;
                    $pts = isset($m->bye_points) && $m->bye_points !== null ? (float) $m->bye_points : $byePointsDefault;
                    $playerMap[$wId]['bye_points_sum'] += $pts;
                    $playerMap[$wId]['opponents'][] = [
                        'round' => $round,
                        'opponent' => 'BYE',
                        'color' => 'white',
                        'result' => '1',
                        'opponent_rating' => 0,
                    ];
                }
                continue;
            }

            $wName = (string) ($m->white_name ?? '');
            $bName = (string) ($m->black_name ?? '');
            $bRating = $playerMap[$bId]['rating'] ?? 0;
            $wRating = $playerMap[$wId]['rating'] ?? 0;

            $wResult = in_array($res, ['1-0', '1F'], true) ? '1' : (in_array($res, ['0-1', '0F'], true) ? '0' : (($res === '1/2-1/2') ? '½' : '-'));
            $bResult = in_array($res, ['0-1', '0F'], true) ? '1' : (in_array($res, ['1-0', '1F'], true) ? '0' : (($res === '1/2-1/2') ? '½' : '-'));

            if (isset($playerMap[$wId])) {
                $playerMap[$wId]['opponents'][] = [
                    'round' => $round,
                    'opponent' => $bName,
                    'opponent_id' => $bId,
                    'color' => 'white',
                    'result' => $wResult,
                    'opponent_rating' => $bRating,
                    'pairing_id' => $pairingId,
                ];
                if (in_array($res, ['1-0', '1F'], true)) {
                    $playerMap[$wId]['wins']++;
                } elseif (in_array($res, ['0-1', '0F'], true)) {
                    $playerMap[$wId]['losses']++;
                } elseif ($res === '1/2-1/2') {
                    $playerMap[$wId]['draws']++;
                }
            }
            if (isset($playerMap[$bId])) {
                $playerMap[$bId]['opponents'][] = [
                    'round' => $round,
                    'opponent' => $wName,
                    'opponent_id' => $wId,
                    'color' => 'black',
                    'result' => $bResult,
                    'opponent_rating' => $wRating,
                    'pairing_id' => $pairingId,
                ];
                if (in_array($res, ['0-1', '0F'], true)) {
                    $playerMap[$bId]['wins']++;
                } elseif (in_array($res, ['1-0', '1F'], true)) {
                    $playerMap[$bId]['losses']++;
                    if ($res === '1F') {
                        $playerMap[$bId]['byes']++;
                        $playerMap[$bId]['bye_points_sum'] += 0.5;
                    }
                } elseif ($res === '1/2-1/2') {
                    $playerMap[$bId]['draws']++;
                }
            }
            if ($res === '0F' && isset($playerMap[$wId])) {
                $playerMap[$wId]['byes']++;
                $playerMap[$wId]['bye_points_sum'] += 0.5;
            }
        }

        $result = [];
        foreach ($playerMap as $pid => $stats) {
            $n = $stats['wins'] + $stats['draws'] + $stats['losses'];
            $byePts = ($stats['byes'] > 0 && isset($stats['bye_points_sum'])) ? $stats['bye_points_sum'] : ($byePointsDefault * $stats['byes']);
            $points = $stats['wins'] + 0.5 * $stats['draws'] + $byePts;
            $perf = $stats['rating'];
            $eloVar = self::compute_elo_variation_fide($stats);
            $avgOpp = null;
            if ($n > 0) {
                $oppRatings = [];
                foreach ($stats['opponents'] ?? [] as $opp) {
                    if (($opp['opponent'] ?? '') !== 'BYE' && isset($opp['opponent_rating'])) {
                        $oppRatings[] = (int) $opp['opponent_rating'];
                    }
                }
                $avgOpp = count($oppRatings) > 0 ? (int) round(array_sum($oppRatings) / count($oppRatings)) : null;
                $avgOppForPerf = $avgOpp !== null ? $avgOpp : $stats['rating'];
                $dp = 400 * ($stats['wins'] - $stats['losses']) / $n;
                $perf = $avgOppForPerf + $dp;
            }
            $result[$pid] = [
                'name' => $stats['name'],
                'rating' => $stats['rating'],
                'wins' => $stats['wins'],
                'draws' => $stats['draws'],
                'losses' => $stats['losses'],
                'byes' => $stats['byes'],
                'points' => $points,
                'opponents' => $stats['opponents'],
                'aro' => $avgOpp,
                'performance' => (int) round($perf),
                'elo_variation' => $eloVar,
            ];
        }
        return $result;
    }

    /**
     * FIDE Elo variation: sum of K*(S-E) per game.
     * K=20 for rating < 2400 (established players), K=10 for rating >= 2400.
     * Per FIDE 8.3.3: K=40 only for new players (<30 games) or juniors (<18, rating<2300); without that data we use K=20.
     * Per FIDE 8.3.1: Rating difference capped at ±400 for players rated below 2650.
     */
    private static function compute_elo_variation_fide(array $stats): int
    {
        $rating = (int) ($stats['rating'] ?? 0);
        $k = $rating >= 2400 ? 10 : 20;
        $totalDelta = 0.0;
        $capDiff = $rating < 2650;

        foreach ($stats['opponents'] ?? [] as $opp) {
            if (($opp['opponent'] ?? '') === 'BYE') {
                continue;
            }
            $oppRating = (int) ($opp['opponent_rating'] ?? 0);
            $res = $opp['result'] ?? '-';
            if ($res === '-' || $res === '') {
                continue;
            }
            $s = ($res === '1' || $res === 1) ? 1.0 : (($res === '½' || $res === 0.5) ? 0.5 : 0.0);
            $diff = $oppRating - $rating;
            if ($capDiff && abs($diff) > 400) {
                $diff = $diff > 0 ? 400 : -400;
            }
            $e = 1.0 / (1.0 + pow(10, $diff / 400.0));
            $totalDelta += $k * ($s - $e);
        }

        return (int) round($totalDelta);
    }

    private static function player_link(string $name, int $playerId, array $playerStats, array $playerSlugById, string $country = '', ?string $title = null, ?int $rating = null, int $tournamentId = 0, array $playersById = []): string
    {
        $flag = $country ? ChessPodium_CountryHelper::render_flag_img($country, $country) . ' ' : '';
        $titleStr = $title ? esc_html(trim($title)) . ' ' : '';
        $ratingStr = ($rating !== null && $rating > 0) ? ' (' . (int) $rating . ')' : '';
        $displayName = esc_html($name) . $ratingStr;
        if (!isset($playerStats[$playerId])) {
            return $flag . $titleStr . $displayName;
        }
        $href = '';
        if ($tournamentId > 0 && isset($playersById[$playerId]) && class_exists('ChessPodium_PlayerDirectory') && class_exists('ChessPodiumPlugin')) {
            $unified = ChessPodium_PlayerDirectory::find_for_tournament_player($playersById[$playerId]);
            if ($unified && !empty($unified->slug)) {
                $href = ChessPodiumPlugin::get_unified_player_profile_url((string) $unified->slug, $tournamentId);
            }
        }
        if ($href === '') {
            $href = self::player_profile_filename($playerId, $playerSlugById);
        }
        return $flag . $titleStr . '<a href="' . self::esc_export_href($href) . '" class="cp-player-link">' . $displayName . '</a>';
    }

    private static function export_opponent_profile_href(int $tournamentId, int $playerId, array $playerSlugById, array $playersById): string
    {
        if ($tournamentId > 0 && isset($playersById[$playerId]) && class_exists('ChessPodium_PlayerDirectory') && class_exists('ChessPodiumPlugin')) {
            $unified = ChessPodium_PlayerDirectory::find_for_tournament_player($playersById[$playerId]);
            if ($unified && !empty($unified->slug)) {
                return ChessPodiumPlugin::get_unified_player_profile_url((string) $unified->slug, $tournamentId);
            }
        }
        return self::player_profile_filename($playerId, $playerSlugById);
    }

    private static function copy_export_profile_avatars(string $dir): void
    {
        $pluginDir = defined('CHESS_PODIUM_PLUGIN_DIR') ? CHESS_PODIUM_PLUGIN_DIR : (dirname(plugin_dir_path(__FILE__)) . '/');
        foreach (['ChessPlayerMale.svg', 'ChessPlayerFemale.svg'] as $f) {
            $src = $pluginDir . 'assets/' . $f;
            if (file_exists($src) && is_readable($src)) {
                $body = file_get_contents($src);
                if (is_string($body) && $body !== '') {
                    self::write_file($dir . '/' . $f, $body);
                }
            }
        }
    }

    private static function resolve_export_player_photo_url(object $player): string
    {
        $manualId = isset($player->photo_attachment_id) ? (int) $player->photo_attachment_id : 0;
        if ($manualId > 0) {
            $manualUrl = wp_get_attachment_image_url($manualId, 'medium');
            if (is_string($manualUrl) && $manualUrl !== '') {
                return $manualUrl;
            }
        }

        $sex = isset($player->sex) ? strtolower((string) $player->sex) : '';
        $isFemale = ($sex === 'w' || $sex === 'f');
        $generic = $isFemale ? 'ChessPlayerFemale.svg' : 'ChessPlayerMale.svg';
        $fideId = class_exists('ChessPodiumPlugin') ? ChessPodiumPlugin::sanitize_fide_id((string) ($player->fide_id ?? '')) : '';
        if ($fideId !== '') {
            // Prefer the same resolver used by live profiles (fetches + caches when missing).
            if (class_exists('ChessPodiumPlugin') && method_exists('ChessPodiumPlugin', 'get_fide_photo_url_cached')) {
                $f = ChessPodiumPlugin::get_fide_photo_url_cached($fideId);
                if (is_string($f) && $f !== '') {
                    return $f;
                }
            } else {
                $c = get_transient('cp_fide_photo_v2_' . $fideId);
                if (is_string($c) && $c !== '' && $c !== '__none__') {
                    return $c;
                }
            }
            $wd = get_transient('cp_wiki_wd_photo_v1_' . $fideId);
            if (is_string($wd) && $wd !== '' && $wd !== '__none__') {
                return $wd;
            }
        }
        return $generic;
    }

    private static function build_player_profile_html(object $tournament, object $player, $standingInfo, array $playerStats, string $navHtml, array $gameByPairingId, array $playerSlugById, array $playersById = []): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $pid = (int) ($player->id ?? 0);
        $tName = esc_html((string) $tournament->name);
        $pName = esc_html((string) ($player->name ?? ''));
        $title = isset($player->title) && (string) $player->title !== '' ? esc_html((string) $player->title) . ' ' : '';
        $country = isset($player->country) ? (string) $player->country : '';
        $rating = isset($player->rating) ? (int) $player->rating : 0;
        $pos = is_array($standingInfo) ? (int) ($standingInfo['pos'] ?? 0) : 0;
        $srow = is_array($standingInfo) ? ($standingInfo['row'] ?? []) : [];
        $points = isset($srow['points']) ? number_format((float) $srow['points'], 1) : '';
        $photo = self::resolve_export_player_photo_url($player);
        $photoTag = '<div style="width:110px;height:110px;flex-shrink:0;background:#e5e7eb;border-radius:8px;border:1px solid #d1d5db;overflow:hidden;">'
            . '<img src="' . esc_attr($photo) . '" alt="' . esc_attr((string) ($player->name ?? '')) . '" style="width:110px;height:110px;object-fit:cover;display:block;">'
            . '</div>';

        $stats = $playerStats[$pid] ?? null;
        $perf = $stats ? (int) ($stats['performance'] ?? $rating) : $rating;
        $aro = ($stats && isset($stats['aro']) && $stats['aro'] !== null) ? (int) $stats['aro'] : null;
        $eloVar = $stats ? (int) ($stats['elo_variation'] ?? 0) : 0;
        $eloStr = $eloVar > 0 ? '+' . $eloVar : (string) $eloVar;

        $header = '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:1rem 0 1.25rem;">'
            . $photoTag
            . '<div>'
            . '<h2 style="margin:0 0 4px 0;">' . $title . $pName . '</h2>'
            . '<div style="color:#475569;font-size:0.95rem;">'
            . ($country !== '' ? ChessPodium_CountryHelper::render_flag_img($country, $country) . ' ' : '')
            . ($rating > 0 ? '<strong>' . esc_html__('Rating', 'chess-podium') . ':</strong> ' . (int) $rating . ' ' : '')
            . ($points !== '' ? '| <strong>' . esc_html__('Pts', 'chess-podium') . ':</strong> ' . esc_html($points) . ' ' : '')
            . ($pos > 0 ? '| <strong>' . esc_html__('Pos', 'chess-podium') . ':</strong> ' . (int) $pos . ' ' : '')
            . '</div>'
            . '<div style="color:#334155;margin-top:6px;">'
            . '<strong>' . esc_html__('Performance', 'chess-podium') . ':</strong> ' . (int) $perf
            . ($aro !== null ? ' | <strong>' . esc_html__('ARO', 'chess-podium') . ':</strong> ' . (int) $aro : '')
            . ' | <strong>' . esc_html__('Variation', 'chess-podium') . ':</strong> ' . esc_html($eloStr)
            . '</div>'
            . '</div></div>';

        $oppRows = '';
        $opps = ($stats && isset($stats['opponents']) && is_array($stats['opponents'])) ? $stats['opponents'] : [];
        foreach ($opps as $o) {
            $r = (int) ($o['round'] ?? 0);
            $oppName = (string) ($o['opponent'] ?? '');
            $oppId = (int) ($o['opponent_id'] ?? 0);
            $color = (string) ($o['color'] ?? '');
            $res = (string) ($o['result'] ?? '-');
            $oppElo = isset($o['opponent_rating']) ? (int) $o['opponent_rating'] : 0;
            $pairingId = (int) ($o['pairing_id'] ?? 0);
            $oppHref = $oppId > 0 ? self::export_opponent_profile_href($tid, $oppId, $playerSlugById, $playersById) : '';
            $oppCell = ($oppId > 0 && $oppName !== '' && $oppName !== 'BYE' && $oppHref !== '')
                ? '<a href="' . self::esc_export_href($oppHref) . '">' . esc_html($oppName) . '</a>'
                : esc_html($oppName !== '' ? $oppName : '-');
            $links = [];
            if ($r > 0) {
                $links[] = '<a href="pairs' . $r . '.html">' . esc_html__('Round', 'chess-podium') . ' ' . $r . '</a>';
            }
            if ($pairingId > 0 && isset($gameByPairingId[$pairingId])) {
                $links[] = '<a href="game-' . (int) $gameByPairingId[$pairingId] . '.html">' . esc_html__('Game', 'chess-podium') . '</a>';
            }
            $oppRows .= '<tr>'
                . '<td>' . (int) $r . '</td>'
                . '<td>' . $oppCell . '</td>'
                . '<td>' . esc_html($color !== '' ? $color : '-') . '</td>'
                . '<td>' . esc_html($res !== '' ? $res : '-') . '</td>'
                . '<td>' . ($oppElo > 0 ? (int) $oppElo : '–') . '</td>'
                . '<td>' . (!empty($links) ? implode(' | ', $links) : '–') . '</td>'
                . '</tr>';
        }
        if ($oppRows === '') {
            $oppRows = '<tr><td colspan="6">—</td></tr>';
        }

        $content = $header
            . '<h3>' . esc_html__('Opponents', 'chess-podium') . '</h3>'
            . '<table class="table"><thead><tr>'
            . '<th>' . esc_html__('Round', 'chess-podium') . '</th>'
            . '<th>' . esc_html__('Opponents', 'chess-podium') . '</th>'
            . '<th>' . esc_html__('Color', 'chess-podium') . '</th>'
            . '<th>' . esc_html__('Result', 'chess-podium') . '</th>'
            . '<th>Elo</th>'
            . '<th>' . esc_html__('Actions', 'chess-podium') . '</th>'
            . '</tr></thead><tbody>' . $oppRows . '</tbody></table>'
            . '<p style="margin-top:1rem;"><a href="standing.html">&larr; ' . esc_html__('Back to standings', 'chess-podium') . '</a></p>';

        $baseUrl = self::get_export_url($tournament);
        $pageTitle = $pName . ' - ' . $tName;
        $selfFile = self::player_profile_filename($pid, $playerSlugById);
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $pageTitle, $baseUrl . '/' . $selfFile) : '';
        return self::wrap_html($pageTitle, $navHtml, $content, false, $tid, $seo);
    }

    private static function build_player_modal(array $playerStats): string
    {
        $json = wp_json_encode($playerStats);
        $wins = esc_html__('Wins', 'chess-podium');
        $draws = esc_html__('Draws', 'chess-podium');
        $losses = esc_html__('Losses', 'chess-podium');
        $opponents = esc_html__('Opponents', 'chess-podium');
        $round = esc_html__('Round', 'chess-podium');
        $eloVar = esc_html__('Elo variation', 'chess-podium');
        $performance = esc_html__('Performance', 'chess-podium');
        $aro = esc_html__('ARO', 'chess-podium');
        $rating = esc_html__('Rating', 'chess-podium');
        $color = esc_html__('Color', 'chess-podium');
        $result = esc_html__('Result', 'chess-podium');
        $close = esc_attr__('Close', 'chess-podium');

        $html = '
<div id="cp-player-modal" class="cp-player-modal" aria-hidden="true">
    <div class="cp-player-modal-backdrop"></div>
    <div class="cp-player-modal-content">
        <button type="button" class="cp-player-modal-close" aria-label="' . $close . '">&times;</button>
        <div id="cp-player-modal-body"></div>
    </div>
</div>
<script>
(function() {
    var stats = ' . $json . ';
    var modal = document.getElementById("cp-player-modal");
    var body = document.getElementById("cp-player-modal-body");
    var backdrop = modal ? modal.querySelector(".cp-player-modal-backdrop") : null;
    var closeBtn = modal ? modal.querySelector(".cp-player-modal-close") : null;

    function showPlayer(pid) {
        var s = stats[pid];
        if (!s || !body) return;
        var oppRows = "";
        for (var i = 0; i < s.opponents.length; i++) {
            var o = s.opponents[i];
            oppRows += "<tr><td>" + o.round + "</td><td>" + (o.opponent || "-") + "</td><td>" + (o.color || "-") + "</td><td>" + (o.result || "-") + "</td><td>" + (o.opponent_rating || "-") + "</td></tr>";
        }
        var eloStr = s.elo_variation > 0 ? "+" + s.elo_variation : String(s.elo_variation);
        var aroStr = (s.aro != null && s.aro !== undefined) ? String(s.aro) : "-";
        body.innerHTML = "<h3>" + (s.name || "") + "</h3>" +
            "<p><strong>' . $rating . ':</strong> " + (s.rating || 0) + " | <strong>' . $aro . ':</strong> " + aroStr + " | <strong>' . $performance . ':</strong> " + (s.performance || s.rating) + " | <strong>' . $eloVar . ':</strong> " + eloStr + "</p>" +
            "<p><strong>' . $wins . ':</strong> " + (s.wins || 0) + " | <strong>' . $draws . ':</strong> " + (s.draws || 0) + " | <strong>' . $losses . ':</strong> " + (s.losses || 0) + (s.byes ? " | BYE: " + s.byes : "") + "</p>" +
            "<h4>' . $opponents . '</h4><table class=\"table\"><thead><tr><th>' . $round . '</th><th>' . $opponents . '</th><th>' . $color . '</th><th>' . $result . '</th><th>Elo</th></tr></thead><tbody>" + oppRows + "</tbody></table>";
        modal.setAttribute("aria-hidden", "false");
        modal.classList.add("open");
        document.body.style.overflow = "hidden";
    }

    function hide() {
        if (modal) {
            modal.setAttribute("aria-hidden", "true");
            modal.classList.remove("open");
            document.body.style.overflow = "";
            history.replaceState(null, "", location.pathname + location.search);
        }
    }

    // Backward-compatible: only intercept links that still use the modal dataset.
    // New exports link to player-<id>.html; those must navigate normally.
    document.querySelectorAll(".cp-player-link").forEach(function(a) {
        a.addEventListener("click", function(e) {
            var pidAttr = a.getAttribute("data-player-id");
            if (!pidAttr) return;
            var pid = parseInt(pidAttr, 10);
            if (!pid) return;
            e.preventDefault();
            history.replaceState(null, "", a.getAttribute("href") || "#");
            showPlayer(pid);
        });
    });

    if (backdrop) backdrop.addEventListener("click", hide);
    if (closeBtn) closeBtn.addEventListener("click", hide);
    document.addEventListener("keydown", function(e) {
        if (e.key === "Escape" && modal && modal.classList.contains("open")) hide();
    });

    var hash = location.hash;
    if (hash && hash.indexOf("cp-player-") === 1) {
        var pid = parseInt(hash.replace("cp-player-", ""), 10);
        if (pid && stats[pid]) showPlayer(pid);
    }
})();
</script>';
        return $html;
    }

    private static function write_file(string $path, string $content): bool
    {
        return file_put_contents($path, $content) !== false;
    }

    /** Local script for single-game export (chess.js copied into export by copy_export_vendor_assets). */
    private static function get_export_chess_script_only(): string
    {
        return '<script src="js/chess.min.js"></script>';
    }

    /**
     * Script/link tags for static export HTML (games.html etc.).
     * All assets are copied into the export folder by copy_export_vendor_assets; no remote URLs.
     * Output is written to files on disk and viewed without WordPress; wp_enqueue_script/style do not apply.
     */
    private static function get_export_cdn_scripts(): string
    {
        return '<script src="js/jquery.min.js"></script>' . "\n"
            . '<script src="js/chess.min.js"></script>' . "\n"
            . '<script src="js/chessboard-1.0.0.min.js"></script>' . "\n"
            . '<link rel="stylesheet" href="css/chessboard-1.0.0.min.css">';
    }

    /** Local piece theme path in export (img/chesspieces/wikipedia copied by copy_export_vendor_assets). */
    private static function get_export_piece_theme(): string
    {
        return 'img/chesspieces/wikipedia/{piece}.png';
    }

    /** 64 square divs for Unicode board (a8..h8, a7..h7, ..., a1..h1). */
    private static function build_unicode_board_64_squares(): string
    {
        $files = array('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
        $out = '';
        for ($r = 8; $r >= 1; $r--) {
            for ($f = 0; $f < 8; $f++) {
                $sq = $files[$f] . $r;
                $dark = (($f + $r) % 2) === 1;
                $cls = 'sq ' . ($dark ? 'dark' : 'light');
                $out .= '<div class="' . esc_attr($cls) . '" data-square="' . esc_attr($sq) . '"></div>';
            }
        }
        return $out;
    }

    private static function copy_export_vendor_assets(string $destDir): void
    {
        $pluginAssetsDir = defined('CHESS_PODIUM_PLUGIN_DIR') ? CHESS_PODIUM_PLUGIN_DIR . 'assets/' : (dirname(plugin_dir_path(__FILE__)) . '/assets/');
        $assetsToCopy = [
            'js/chess.min.js' => $pluginAssetsDir . 'js/vendor/chess.min.js',
            'js/chessboard-1.0.0.min.js' => $pluginAssetsDir . 'js/vendor/chessboard-1.0.0.min.js',
            'css/chessboard-1.0.0.min.css' => $pluginAssetsDir . 'css/chessboard-1.0.0.min.css',
        ];
        foreach ($assetsToCopy as $relPath => $srcPath) {
            if (file_exists($srcPath) && is_readable($srcPath)) {
                $fullDest = $destDir . '/' . $relPath;
                wp_mkdir_p(dirname($fullDest));
                copy($srcPath, $fullDest);
            }
        }
        $jqDest = $destDir . '/js/jquery.min.js';
        $jqPluginSrc = $pluginAssetsDir . 'js/vendor/jquery.min.js';
        if (file_exists($jqPluginSrc) && is_readable($jqPluginSrc)) {
            wp_mkdir_p(dirname($jqDest));
            copy($jqPluginSrc, $jqDest);
        }
        $imgDir = $destDir . '/img/chesspieces/wikipedia';
        $piecesDir = $pluginAssetsDir . 'img/chesspieces/wikipedia';
        if (is_dir($piecesDir)) {
            wp_mkdir_p($imgDir);
            foreach (glob($piecesDir . '/*.png') ?: [] as $src) {
                if (is_readable($src)) {
                    copy($src, $imgDir . '/' . basename($src));
                }
            }
        }
        $flagsDir = $destDir . '/img/flags';
        $flagsSrc = $pluginAssetsDir . 'img/flags';
        if (is_dir($flagsSrc)) {
            wp_mkdir_p($flagsDir);
            foreach (glob($flagsSrc . '/*.png') ?: [] as $src) {
                if (is_readable($src)) {
                    copy($src, $flagsDir . '/' . basename($src));
                }
            }
        }
    }

    private static function build_nav_html(string $slug, int $roundsTotal, bool $hasBrochure = false): string
    {
        $links = [
            ['index.html', __('Home', 'chess-podium')],
            ['standing.html', __('Standings', 'chess-podium')],
            ['elo.html', __('Elo performance', 'chess-podium')],
        ];
        if ($hasBrochure) {
            $links[] = ['bando.pdf', __('Brochure', 'chess-podium')];
        }
        for ($r = 1; $r <= $roundsTotal; $r++) {
            $links[] = ['pairs' . $r . '.html', sprintf(__('Round %d', 'chess-podium'), $r)];
        }
        $links[] = ['games.html', __('Games', 'chess-podium')];
        $links[] = ['galleria.html', __('Gallery', 'chess-podium')];

        $items = '';
        foreach ($links as $l) {
            $items .= '<li><a href="' . esc_attr($l[0]) . '"' . (strpos($l[0], '.pdf') !== false ? ' target="_blank" rel="noopener"' : '') . '>' . esc_html($l[1]) . '</a></li>';
        }
        return '<nav class="nav"><ul>' . $items . '</ul></nav>';
    }

    private static function build_index_html(object $tournament, array $players, array $standings, string $navHtml, array $playerStats, array $playerSlugById, array $playersById = [], bool $hasBrochure = false, ?string $venuePhotoUrl = null): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $name = esc_html((string) $tournament->name);
        $venueImg = '';
        if ($venuePhotoUrl && $venuePhotoUrl !== '') {
            $venueImg = '<figure class="cp-venue-hero"><img src="' . esc_url($venuePhotoUrl) . '" alt="' . esc_attr__('Venue', 'chess-podium') . '" loading="eager"></figure>';
        }
        $fairPlayBlock = '';
        if (class_exists('ChessPodium_FairPlayDisplay')) {
            $fairPlayBlock = ChessPodium_FairPlayDisplay::render_section_html($tournament);
        }
        $rows = '';
        foreach ($players as $i => $p) {
            $pid = (int) $p->id;
            $country = isset($p->country) ? (string) $p->country : '';
            $title = isset($p->title) && $p->title !== '' ? (string) $p->title : null;
            $link = self::player_link((string) $p->name, $pid, $playerStats, $playerSlugById, $country, $title, null, $tid, $playersById);
            $rows .= '<tr><td>' . ((int) $i + 1) . '</td><td>' . $link . '</td><td>' . ((int) $p->rating) . '</td></tr>';
        }
        $top3 = array_slice($standings, 0, 3);
        $trophies = ['🥇', '🥈', '🥉'];
        $top3Html = '';
        foreach ($top3 as $i => $s) {
            $pid = (int) ($s['player_id'] ?? 0);
            $country = (string) ($s['country'] ?? '');
            $title = isset($s['title']) && $s['title'] !== '' ? (string) $s['title'] : null;
            $link = self::player_link((string) $s['name'], $pid, $playerStats, $playerSlugById, $country, $title, null, $tid, $playersById);
            $normLabels = [];
            $norms = [];
            if (!empty($s['gm_norm'])) {
                $norms[] = 'gm_norm';
            }
            if (!empty($s['im_norm'])) {
                $norms[] = 'im_norm';
            }
            if (!empty($norms) && class_exists('ChessPodium_FideNorm')) {
                $norms = ChessPodium_FideNorm::filter_displayable_norms($norms, $title);
                foreach ($norms as $norm) {
                    $label = ChessPodium_FideNorm::get_norm_label($norm);
                    if ($label !== '') {
                        $normLabels[] = $label;
                    }
                }
            }
            $normBadge = !empty($normLabels) ? ' <span class="cp-norm-badge" title="' . esc_attr(implode(', ', $normLabels)) . '">' . esc_html(implode(', ', $normLabels)) . '</span>' : '';
            $trophy = $trophies[$i] ?? '';
            $top3Html .= '<tr><td>' . ((int) $i + 1) . '</td><td>' . $trophy . ' ' . $link . $normBadge . '</td><td>' . number_format((float) $s['points'], 1) . '</td></tr>';
        }
        $quickLinks = '<a href="standing.html">' . esc_html__('View full standings', 'chess-podium') . '</a> | <a href="elo.html">' . esc_html__('Elo performance', 'chess-podium') . '</a> | <a href="games.html">' . esc_html__('PGN games', 'chess-podium') . '</a> | <a href="galleria.html">' . esc_html__('Photo gallery', 'chess-podium') . '</a>';
        if ($hasBrochure) {
            $quickLinks .= ' | <a href="bando.pdf" target="_blank" rel="noopener"><strong>' . esc_html__('Download brochure (PDF)', 'chess-podium') . '</strong></a>';
        }
        $content = $venueImg . $fairPlayBlock . '<h2>' . esc_html__('Players', 'chess-podium') . '</h2><table class="table"><thead><tr><th>#</th><th>' . esc_html__('Name', 'chess-podium') . '</th><th>' . esc_html__('Rating', 'chess-podium') . '</th></tr></thead><tbody>' . $rows . '</tbody></table>' .
            '<h2>Top 3</h2><table class="table"><thead><tr><th>' . esc_html__('Pos', 'chess-podium') . '</th><th>' . esc_html__('Name', 'chess-podium') . '</th><th>' . esc_html__('Pts', 'chess-podium') . '</th></tr></thead><tbody>' . $top3Html . '</tbody></table>' .
            '<p>' . $quickLinks . '</p>';
        $content .= self::build_player_modal($playerStats);
        $baseUrl = self::get_export_url($tournament);
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $name, $baseUrl . '/index.html', count($players)) : '';
        return self::wrap_html($name, $navHtml, $content, false, (int) $tournament->id, $seo);
    }

    private static function build_standing_html(object $tournament, array $standings, string $navHtml, array $playerStats, array $playerSlugById, array $playersById = []): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $name = esc_html((string) $tournament->name);
        $tiebreakerCols = class_exists('ChessPodiumPlugin') ? ChessPodiumPlugin::get_tiebreaker_columns_for_display($tournament) : ['buchholz' => __('Buchholz', 'chess-podium'), 'sb' => __('SB', 'chess-podium')];
        $headerCells = '<th>' . esc_html__('Pos', 'chess-podium') . '</th><th>' . esc_html__('Player', 'chess-podium') . '</th><th>' . esc_html__('Pts', 'chess-podium') . '</th>';
        foreach ($tiebreakerCols as $label) {
            $headerCells .= '<th>' . esc_html($label) . '</th>';
        }
        $rows = '';
        foreach ($standings as $i => $s) {
            $pid = (int) ($s['player_id'] ?? 0);
            $country = (string) ($s['country'] ?? '');
            $title = isset($s['title']) && $s['title'] !== '' ? (string) $s['title'] : null;
            $rating = isset($s['rating']) ? (int) $s['rating'] : null;
            $link = self::player_link((string) $s['name'], $pid, $playerStats, $playerSlugById, $country, $title, $rating, $tid, $playersById);
            $normLabels = [];
            $norms = [];
            if (!empty($s['gm_norm'])) {
                $norms[] = 'gm_norm';
            }
            if (!empty($s['im_norm'])) {
                $norms[] = 'im_norm';
            }
            if (!empty($norms) && class_exists('ChessPodium_FideNorm')) {
                $norms = ChessPodium_FideNorm::filter_displayable_norms($norms, $title);
                foreach ($norms as $norm) {
                    $label = ChessPodium_FideNorm::get_norm_label($norm);
                    if ($label !== '') {
                        $normLabels[] = $label;
                    }
                }
            }
            $normBadge = !empty($normLabels) ? ' <span class="cp-norm-badge" title="' . esc_attr(implode(', ', $normLabels)) . '">' . esc_html(implode(', ', $normLabels)) . '</span>' : '';
            $rowCells = '<td>' . ((int) $i + 1) . '</td><td>' . $link . $normBadge . '</td><td>' . number_format((float) $s['points'], 1) . '</td>';
            foreach (array_keys($tiebreakerCols) as $key) {
                $val = $s[$key] ?? 0;
                $rowCells .= '<td>' . (in_array($key, ['rating', 'aro', 'win', 'bwg', 'rep'], true) ? (int) $val : number_format((float) $val, 2)) . '</td>';
            }
            $rows .= '<tr>' . $rowCells . '</tr>';
        }
        $content = '<h2>' . esc_html__('Standings', 'chess-podium') . '</h2><table class="table"><thead><tr>' . $headerCells . '</tr></thead><tbody>' . $rows . '</tbody></table>';
        $content .= self::build_player_modal($playerStats);
        $baseUrl = self::get_export_url($tournament);
        $title = $name . ' - ' . __('Standings', 'chess-podium');
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $title, $baseUrl . '/standing.html', count($standings)) : '';
        return self::wrap_html($title, $navHtml, $content, false, (int) $tournament->id, $seo);
    }

    private static function build_elo_html(object $tournament, array $standings, array $playerStats, string $navHtml, array $playerSlugById, array $playersById = []): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $name = esc_html((string) $tournament->name);
        $rows = '';
        foreach ($standings as $i => $s) {
            $pid = (int) ($s['player_id'] ?? 0);
            $stats = $playerStats[$pid] ?? null;
            $perf = $stats ? (int) $stats['performance'] : (int) ($s['rating'] ?? 0);
            $aro = $stats && isset($stats['aro']) && $stats['aro'] !== null ? (int) $stats['aro'] : null;
            $aroCell = $aro !== null ? (string) $aro : '–';
            $eloVar = $stats ? (int) $stats['elo_variation'] : 0;
            $eloStr = $eloVar > 0 ? '+' . $eloVar : (string) $eloVar;
            $country = (string) ($s['country'] ?? '');
            $title = isset($s['title']) && $s['title'] !== '' ? (string) $s['title'] : null;
            $link = isset($playerStats[$pid]) ? self::player_link((string) $s['name'], $pid, $playerStats, $playerSlugById, $country, $title, null, $tid, $playersById) : esc_html((string) $s['name']);
            $normLabels = [];
            $norms = [];
            if (!empty($s['gm_norm'])) {
                $norms[] = 'gm_norm';
            }
            if (!empty($s['im_norm'])) {
                $norms[] = 'im_norm';
            }
            if (!empty($norms) && class_exists('ChessPodium_FideNorm')) {
                $norms = ChessPodium_FideNorm::filter_displayable_norms($norms, $title);
                foreach ($norms as $norm) {
                    $label = ChessPodium_FideNorm::get_norm_label($norm);
                    if ($label !== '') {
                        $normLabels[] = $label;
                    }
                }
            }
            $normBadge = !empty($normLabels) ? ' <span class="cp-norm-badge" title="' . esc_attr(implode(', ', $normLabels)) . '">' . esc_html(implode(', ', $normLabels)) . '</span>' : '';
            $rows .= '<tr><td>' . ((int) $i + 1) . '</td><td>' . $link . $normBadge . '</td><td>' . ((int) ($s['rating'] ?? 0)) . '</td><td>' . esc_html($aroCell) . '</td><td>' . $perf . '</td><td>' . esc_html($eloStr) . '</td></tr>';
        }
        $content = '<h2>' . esc_html__('Elo performance and variation', 'chess-podium') . '</h2>';
        $content .= '<p class="cp-elo-hint">' . esc_html__('Performance: tournament performance rating. ARO: average rating of opponents (excl. BYE). Variation: FIDE Elo change (K=20 for rating < 2400, K=10 for 2400+, rating diff capped at ±400).', 'chess-podium') . '</p>';
        $content .= '<table class="table"><thead><tr><th>' . esc_html__('Pos', 'chess-podium') . '</th><th>' . esc_html__('Player', 'chess-podium') . '</th><th>' . esc_html__('Rating', 'chess-podium') . '</th><th>' . esc_html__('ARO', 'chess-podium') . '</th><th>' . esc_html__('Performance', 'chess-podium') . '</th><th>' . esc_html__('Variation', 'chess-podium') . '</th></tr></thead><tbody>' . $rows . '</tbody></table>';
        $content .= self::build_player_modal($playerStats);
        $baseUrl = self::get_export_url($tournament);
        $title = $name . ' - ' . __('Elo performance', 'chess-podium');
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $title, $baseUrl . '/elo.html', count($standings)) : '';
        return self::wrap_html($title, $navHtml, $content, false, (int) $tournament->id, $seo);
    }

    private static function build_pairs_html(object $tournament, array $pairings, int $round, string $navHtml, array $playerStats, array $playerSlugById, array $playersById = []): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $name = esc_html((string) $tournament->name);
        $rows = '';
        $idx = 0;
        foreach ($pairings as $p) {
            $idx++;
            $wId = (int) $p->white_player_id;
            $bId = (int) $p->black_player_id;
            $isBye = (int) $p->is_bye === 1;
            $whiteCountry = isset($p->white_country) ? (string) $p->white_country : '';
            $blackCountry = isset($p->black_country) ? (string) $p->black_country : '';
            $whiteTitle = isset($p->white_title) && $p->white_title !== '' ? (string) $p->white_title : null;
            $blackTitle = $isBye ? null : (isset($p->black_title) && $p->black_title !== '' ? (string) $p->black_title : null);
            $whiteLink = self::player_link((string) $p->white_name, $wId, $playerStats, $playerSlugById, $whiteCountry, $whiteTitle, null, $tid, $playersById);
            $blackLink = $isBye ? '-' : self::player_link((string) $p->black_name, $bId, $playerStats, $playerSlugById, $blackCountry, $blackTitle, null, $tid, $playersById);
            $result = (int) $p->is_bye === 1 ? 'BYE' : esc_html((string) ($p->result ?: '-'));
            $rows .= '<tr><td>' . $idx . '</td><td>' . $whiteLink . '</td><td>' . $blackLink . '</td><td>' . $result . '</td></tr>';
        }
        $content = '<h2>' . sprintf(esc_html__('Pairings round %d', 'chess-podium'), $round) . '</h2><table class="table"><thead><tr><th>' . esc_html__('Board', 'chess-podium') . '</th><th>' . esc_html__('White', 'chess-podium') . '</th><th>' . esc_html__('Black', 'chess-podium') . '</th><th>' . esc_html__('Result', 'chess-podium') . '</th></tr></thead><tbody>' . $rows . '</tbody></table>';
        $content .= self::build_player_modal($playerStats);
        $baseUrl = self::get_export_url($tournament);
        $title = $name . ' - ' . sprintf(__('Round %d', 'chess-podium'), $round);
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $title, $baseUrl . '/pairs' . $round . '.html') : '';
        return self::wrap_html($title, $navHtml, $content, false, (int) $tournament->id, $seo);
    }

    private static function build_games_html(object $tournament, array $allPairings, array $pgns, string $navHtml): string
    {
        $name = esc_html((string) $tournament->name);
        $gamesList = '';
        $gameId = 0;
        foreach ($pgns as $pgn) {
            $gameId++;
            $round = (int) $pgn->round_no;
            $white = esc_html((string) ($pgn->white_name ?? __('White', 'chess-podium')));
            $black = esc_html((string) ($pgn->black_name ?? __('Black', 'chess-podium')));
            $result = esc_html((string) ($pgn->result ?? ''));
            $pgnContent = $pgn->pgn_content;
            $gamesList .= '<div class="game-block" id="game-wrap-' . $gameId . '" data-game-id="' . $gameId . '">';
            $gamesList .= '<h4 class="cp-game-header">' . sprintf(__('Round %d', 'chess-podium'), $round) . ': ' . $white . ' – ' . $black . ' ' . $result . '</h4>';
            $gamesList .= '<script type="text/plain" id="pgn-' . $gameId . '">' . preg_replace('/<\/script>/', '<\\/script>', $pgnContent) . '</script>';
            $gamesList .= '<div class="cp-game-layout">';
            $gamesList .= '<div class="cp-game-board-wrap"><div class="board-container" id="board-' . $gameId . '"></div><div class="pgn-controls"></div></div>';
            $gamesList .= '<div class="cp-game-moves-panel"><div class="cp-moves-label">' . esc_html__('Moves', 'chess-podium') . '</div><div class="pgn-moves"></div></div>';
            $gamesList .= '</div></div>';
        }
        if ($gamesList === '') {
            $gamesList = '<p>' . esc_html__('No PGN games loaded.', 'chess-podium') . '</p>';
        }
        $pgnViewerJs = self::get_pgn_viewer_script();
        $baseUrl = self::get_export_url($tournament);
        $title = $name . ' - ' . __('Games', 'chess-podium');
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $title, $baseUrl . '/games.html') : '';
        return self::wrap_html(
            $title,
            $navHtml,
            '<h2>' . esc_html__('PGN games', 'chess-podium') . '</h2><p class="cp-games-hint">' . esc_html__('Use the controls or select a move to navigate. Keyboard: ← → previous/next, Home/End start/end.', 'chess-podium') . '</p>' . $gamesList . $pgnViewerJs,
            true,
            (int) $tournament->id,
            $seo
        );
    }

    private static function build_single_game_html(object $tournament, object $pgn, int $gameId, int $totalGames, array $pgns, string $navHtml): string
    {
        $name = esc_html((string) $tournament->name);
        $round = (int) $pgn->round_no;
        $white = esc_html((string) ($pgn->white_name ?? __('White', 'chess-podium')));
        $black = esc_html((string) ($pgn->black_name ?? __('Black', 'chess-podium')));
        $result = esc_html((string) ($pgn->result ?? ''));
        $pgnContent = $pgn->pgn_content;
        $pageTitle = sprintf(__('%s vs %s - Round %d', 'chess-podium'), $white, $black, $round) . ' - ' . $name;
        $baseUrl = self::get_export_url($tournament);
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $pageTitle, $baseUrl . '/game-' . $gameId . '.html') : '';
        $prevUrl = $gameId > 1 ? 'game-' . ($gameId - 1) . '.html' : null;
        $nextUrl = $gameId < $totalGames ? 'game-' . ($gameId + 1) . '.html' : null;
        $prevLabel = $prevUrl ? sprintf(__('Round %d', 'chess-podium'), (int) ($pgns[$gameId - 2]->round_no ?? 0)) . ': ' . ($pgns[$gameId - 2]->white_name ?? '') . ' - ' . ($pgns[$gameId - 2]->black_name ?? '') : '';
        $nextLabel = $nextUrl ? sprintf(__('Round %d', 'chess-podium'), (int) ($pgns[$gameId]->round_no ?? 0)) . ': ' . ($pgns[$gameId]->white_name ?? '') . ' - ' . ($pgns[$gameId]->black_name ?? '') : '';
        $prevGame = esc_html__('Previous game', 'chess-podium');
        $nextGame = esc_html__('Next game', 'chess-podium');
        $backToGames = esc_html__('Back to games', 'chess-podium');
        $start = esc_attr__('Start', 'chess-podium');
        $prev = esc_attr__('Previous', 'chess-podium');
        $next = esc_attr__('Next', 'chess-podium');
        $end = esc_attr__('End', 'chess-podium');
        $version = defined('CHESS_PODIUM_VERSION') ? CHESS_PODIUM_VERSION : '';
        $genComment = '<!-- Chess Podium v' . esc_html($version ?: '?') . ' exported ' . esc_html(current_time('Y-m-d H:i:s')) . ' -->';
        $lang = get_locale();
        $header = '<header id="cp-site-header"><button id="cp-nav-toggle" class="cp-nav-toggle" type="button" aria-label="' . esc_attr__('Menu', 'chess-podium') . '" aria-expanded="false">&#9776;</button>' . $navHtml . '</header>';
        $navScript = '<script>(function(){var t=document.getElementById("cp-nav-toggle"),h=document.getElementById("cp-site-header");if(t&&h){t.onclick=function(){h.classList.toggle("nav-open");t.setAttribute("aria-expanded",h.classList.contains("nav-open"));};}})();</script>';
        $footer = __('Generated by', 'chess-podium') . ' <a href="https://www.chesspodium.com/" target="_blank" rel="noopener">Chess Podium</a>';
        return '<!DOCTYPE html><html lang="' . esc_attr(substr($lang, 0, 2)) . '"><head>' . $genComment . '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html($pageTitle) . '</title>' . $seo . '
<link rel="stylesheet" href="style.css">
' . self::get_export_chess_script_only() . '
<style>
.cp-single-game { max-width: 600px; margin: 0 auto; padding: 1rem; }
.cp-single-game .cp-game-back { margin-bottom: 1rem; }
.cp-single-game .cp-game-back a { color: #2c5282; text-decoration: none; }
.cp-single-game .cp-game-back a:hover { text-decoration: underline; }
.cp-single-game .cp-game-header { text-align: center; margin-bottom: 1rem; font-size: 1.1rem; }
.cp-single-game .cp-game-title { font-weight: 600; margin-bottom: 0.5rem; }
.cp-single-game .cp-game-result { color: #16a34a; font-weight: 700; }
.cp-single-game .cp-game-nav { display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; }
.cp-single-game .cp-game-nav button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; background: #e2e8f0; border: 1px solid #cbd5e1; border-radius: 6px; color: #1e293b; }
.cp-single-game .cp-game-nav button:hover { background: #cbd5e1; }
.cp-single-game .cp-game-info { min-width: 5rem; text-align: center; font-size: 0.95rem; color: #64748b; }
.cp-single-game .board-container { width: 400px; max-width: 100%; min-width: 280px; margin: 0 auto; }
.cp-unicode-board { display: grid; grid-template-columns: repeat(8, 1fr); width: 100%; aspect-ratio: 1; max-width: 400px; margin: 0 auto; border: 2px solid #1e293b; box-sizing: border-box; }
.cp-unicode-board .sq { display: flex; align-items: center; justify-content: center; font-size: clamp(24px, 5vw, 44px); line-height: 1; user-select: none; }
.cp-unicode-board .sq.light { background: #f0d9b5; }
.cp-unicode-board .sq.dark { background: #b58863; }
.cp-single-game .cp-game-moves { margin-top: 1rem; padding: 1rem; background: #f8fafc; border-radius: 8px; max-height: 200px; overflow-y: auto; font-size: 0.9rem; line-height: 1.8; }
.cp-single-game .cp-game-move { cursor: pointer; padding: 0.1em 0.3em; border-radius: 3px; display: inline-block; margin-right: 0.2em; }
.cp-single-game .cp-game-move:hover { background: #e2e8f0; }
.cp-single-game .cp-game-move-current { background: #2c5282; color: #fff; }
.cp-single-game .cp-game-other { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; }
.cp-single-game .cp-game-other a { color: #2c5282; text-decoration: none; font-size: 0.9rem; padding: 0.4rem 0.8rem; background: #f1f5f9; border-radius: 6px; border: 1px solid #e2e8f0; }
.cp-single-game .cp-game-other a:hover { background: #e2e8f0; text-decoration: underline; }
</style>
</head><body>' . $header . '
<main class="container">
<div class="cp-single-game">
<div class="cp-game-back"><a href="games.html">&larr; ' . $backToGames . '</a></div>
<div class="cp-game-header">
<div class="cp-game-title">' . sprintf(__('Round %d', 'chess-podium'), $round) . ': ' . $white . ' vs ' . $black . ($result ? ' <span class="cp-game-result">' . $result . '</span>' : '') . '</div>
<div>' . $name . '</div>
</div>
<div class="cp-game-nav">
<button type="button" id="cp-single-first" aria-label="' . $start . '">&#171;</button>
<button type="button" id="cp-single-prev" aria-label="' . $prev . '">&#8249;</button>
<span class="cp-game-info" id="cp-single-info">—</span>
<button type="button" id="cp-single-next" aria-label="' . $next . '">&#8250;</button>
<button type="button" id="cp-single-last" aria-label="' . $end . '">&#187;</button>
</div>
<div class="board-container"><div id="cp-single-board" class="cp-unicode-board" role="img" aria-label="' . esc_attr__('Chess board', 'chess-podium') . '">' . self::build_unicode_board_64_squares() . '</div></div>
<div class="cp-game-moves" id="cp-single-moves"></div>
' . ($prevUrl || $nextUrl ? '<div class="cp-game-other">' . ($prevUrl ? '<a href="' . esc_attr($prevUrl) . '" title="' . esc_attr($prevLabel) . '">&larr; ' . $prevGame . '</a>' : '') . ($nextUrl ? '<a href="' . esc_attr($nextUrl) . '" title="' . esc_attr($nextLabel) . '">' . $nextGame . ' &rarr;</a>' : '') . '</div>' : '') . '
</div>
</main>
<footer><p>' . $footer . '</p></footer>
<script>
(function(){
var pgn = ' . wp_json_encode($pgnContent) . ';
var moves = [], currentIdx = -1;
var PIECE = { K:"\u2654",Q:"\u2655",R:"\u2656",B:"\u2657",N:"\u2658",P:"\u2659",k:"\u265A",q:"\u265B",r:"\u265C",b:"\u265D",n:"\u265E",p:"\u265F" };
function stripPgn(s){ if(!s||typeof s!=="string")return""; return s.replace(/\s*\{[^}]*\}\s*/g," ").replace(/\[\%[a-zA-Z]+\s+[^\]]*\]/g,"").replace(/\s*\$\d+\s*/g," ").replace(/\s*(?:1-0|0-1|1\/2-1\/2|\*)\s*$/i,"").replace(/\s+/g," ").trim(); }
function loadPgn(c,s,o){ var opts=o||{}; return (c.load_pgn&&c.load_pgn(s,opts))||(c.loadPgn&&c.loadPgn(s,opts)); }
function getMoves(){ if(!pgn||typeof Chess==="undefined")return[]; try{ var c=new Chess(),st=stripPgn(pgn); if(!loadPgn(c,st,{sloppy:true})){ var idx=pgn.search(/\d+\.\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i); if(idx>=0){ var mp=stripPgn(pgn.substring(idx)); if(mp&&loadPgn(c,mp,{sloppy:true})){ var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; } } return[]; } var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; }catch(e){return[];} }
function getFen(moveIdx){ if(!pgn||typeof Chess==="undefined")return null; try{ var c=new Chess(),st=stripPgn(pgn); if(!loadPgn(c,st,{sloppy:true})){ var pos=pgn.search(/\d+\.\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i); if(pos>=0)loadPgn(c,stripPgn(pgn.substring(pos)),{sloppy:true}); } var h=c.history?c.history():[]; c.reset(); for(var i=0;i<=moveIdx&&i<h.length;i++)c.move(h[i]); return c.fen(); }catch(e){return null;} }
function fenToPieces(fen){ if(!fen)return[]; var part=fen.split(" ")[0]; if(!part)return[]; var rows=part.split("/"),out=[]; for(var r=0;r<8;r++){ var row=rows[r]||""; for(var i=0;i<row.length;i++){ var ch=row[i]; if(/[1-8]/.test(ch)){ for(var k=0;k<parseInt(ch,10);k++)out.push(""); } else out.push(PIECE[ch]||""); } } return out; }
function renderBoard(fen){ var boardEl=document.getElementById("cp-single-board"); if(!boardEl)return; var pieces=fenToPieces(fen); var cells=boardEl.querySelectorAll(".sq"); for(var i=0;i<64&&i<cells.length;i++)cells[i].textContent=pieces[i]||""; }
function updateNav(){ var info=document.getElementById("cp-single-info"); if(!info)return; info.textContent=moves.length===0?"—":(currentIdx<0?"0":(Math.floor(currentIdx/2)+1)+(currentIdx%2===0?".":"..."))+(moves.length>0&&currentIdx>=moves.length-1?" ("+moves.length+" moves)":""); }
function renderMoves(){ var el=document.getElementById("cp-single-moves"); if(!el)return; var html=""; for(var i=0;i<moves.length;i++){ html+="<span class=\"cp-game-move"+(i===currentIdx?" cp-game-move-current":"")+"\" data-idx=\""+i+"\">"+(i%2===0?(Math.floor(i/2)+1)+". ":"")+moves[i]+"</span> "; } el.innerHTML=html; el.querySelectorAll(".cp-game-move").forEach(function(sp){ sp.onclick=function(){ showPos(parseInt(sp.getAttribute("data-idx"),10)); }; }); }
function showPos(idx){ currentIdx=idx; var f=idx<0?"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1":getFen(idx); renderBoard(f); updateNav(); renderMoves(); }
function init(){ if(typeof Chess==="undefined")return; moves=getMoves(); var startFen=moves.length>0?getFen(moves.length-1):"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; currentIdx=moves.length>0?moves.length-1:-1; renderBoard(startFen); updateNav(); renderMoves(); var first=document.getElementById("cp-single-first"),prev=document.getElementById("cp-single-prev"),next=document.getElementById("cp-single-next"),last=document.getElementById("cp-single-last"); if(first)first.onclick=function(){showPos(-1);}; if(prev)prev.onclick=function(){showPos(moves.length>0&&currentIdx>=0?currentIdx-1:-1);}; if(next)next.onclick=function(){showPos(moves.length>0?Math.min(currentIdx+1,moves.length-1):0);}; if(last)last.onclick=function(){showPos(moves.length>0?moves.length-1:0);}; document.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"){e.preventDefault();if(moves.length>0&&currentIdx>0)showPos(currentIdx-1);else if(moves.length>0&&currentIdx===0)showPos(-1);}if(e.key==="ArrowRight"){e.preventDefault();if(moves.length>0&&currentIdx<moves.length-1)showPos(currentIdx+1);}}); }
}
if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
} else {
    init();
}
})();
</script>
' . $navScript . '</body></html>';
    }

    private static function build_gallery_html(object $tournament, array $photos, string $navHtml): string
    {
        $name = esc_html((string) $tournament->name);
        $items = '';
        foreach ($photos as $i => $ph) {
            $url = esc_url($ph->photo_url);
            $caption = esc_html((string) ($ph->caption ?? ''));
            $items .= '<figure class="gallery-item"><img src="' . $url . '" alt="' . $caption . '" data-index="' . $i . '" data-src="' . $url . '" data-caption="' . $caption . '" loading="lazy"><figcaption>' . $caption . '</figcaption></figure>';
        }
        $content = '<h2>' . esc_html__('Photo gallery', 'chess-podium') . '</h2>';
        $content .= '<p class="gallery-hint">' . esc_html__('Select a photo to enlarge and browse the gallery.', 'chess-podium') . '</p>';
        $content .= !empty($items) ? '<div class="gallery">' . $items . '</div>' : '<p>' . esc_html__('No photos loaded.', 'chess-podium') . '</p>';
        $content .= self::get_lightbox_html();
        $content .= self::get_lightbox_script();
        $baseUrl = self::get_export_url($tournament);
        $title = $name . ' - ' . __('Gallery', 'chess-podium');
        $seo = class_exists('ChessPodium_TournamentSeo') ? ChessPodium_TournamentSeo::get_export_meta_html($tournament, $title, $baseUrl . '/galleria.html') : '';
        return self::wrap_html($title, $navHtml, $content, false, (int) $tournament->id, $seo);
    }

    private static function get_lightbox_html(): string
    {
        $close = esc_attr__('Close', 'chess-podium');
        $prev = esc_attr__('Previous', 'chess-podium');
        $next = esc_attr__('Next', 'chess-podium');

        return '
<div id="cp-lightbox" class="cp-lightbox" aria-hidden="true">
    <button type="button" class="cp-lightbox-close" aria-label="' . $close . '">&times;</button>
    <button type="button" class="cp-lightbox-prev" aria-label="' . $prev . '">&larr;</button>
    <button type="button" class="cp-lightbox-next" aria-label="' . $next . '">&rarr;</button>
    <div class="cp-lightbox-content">
        <img src="" alt="" id="cp-lightbox-img">
        <p class="cp-lightbox-caption" id="cp-lightbox-caption"></p>
    </div>
</div>';
    }

    private static function get_lightbox_script(): string
    {
        return '
<script>
(function() {
    var lb = document.getElementById("cp-lightbox");
    var img = document.getElementById("cp-lightbox-img");
    var cap = document.getElementById("cp-lightbox-caption");
    var items = [];
    var idx = 0;

    function open(i) {
        idx = i;
        if (items.length === 0) return;
        img.src = items[idx].src;
        img.alt = items[idx].caption;
        cap.textContent = items[idx].caption;
        lb.setAttribute("aria-hidden", "false");
        lb.classList.add("open");
        document.body.style.overflow = "hidden";
    }

    function close() {
        lb.setAttribute("aria-hidden", "true");
        lb.classList.remove("open");
        document.body.style.overflow = "";
    }

    function prev() { idx = (idx - 1 + items.length) % items.length; open(idx); }
    function next() { idx = (idx + 1) % items.length; open(idx); }

    document.querySelectorAll(".gallery-item img").forEach(function(el, i) {
        items.push({ src: el.dataset.src || el.src, caption: el.dataset.caption || el.alt });
        el.style.cursor = "pointer";
        el.onclick = function() { open(i); };
    });

    if (lb) {
        lb.querySelector(".cp-lightbox-close").onclick = close;
        lb.querySelector(".cp-lightbox-prev").onclick = prev;
        lb.querySelector(".cp-lightbox-next").onclick = next;
        lb.onclick = function(e) { if (e.target === lb) close(); };
        document.addEventListener("keydown", function(e) {
            if (!lb.classList.contains("open")) return;
            if (e.key === "Escape") close();
            if (e.key === "ArrowLeft") prev();
            if (e.key === "ArrowRight") next();
        });
    }
})();
</script>';
    }

    private static function get_pgn_viewer_script(): string
    {
        $start = esc_js(__('Start', 'chess-podium'));
        $prev = esc_js(__('Prev', 'chess-podium'));
        $next = esc_js(__('Next', 'chess-podium'));
        $end = esc_js(__('End', 'chess-podium'));
        $play = esc_js(__('Play', 'chess-podium'));
        $pause = esc_js(__('Pause', 'chess-podium'));
        $noMoves = esc_js(__('(no moves)', 'chess-podium'));
        return '
' . self::get_export_cdn_scripts() . '
<script>
document.addEventListener("DOMContentLoaded", function() {
    function initBoards() {
    var blocks = document.querySelectorAll(".game-block");
    blocks.forEach(function(block, idx) {
        var pgnEl = document.getElementById("pgn-" + (idx + 1));
        var boardId = "board-" + (idx + 1);
        var boardEl = document.getElementById(boardId);
        if (!boardEl || !pgnEl) return;
        var pgn = pgnEl.textContent.trim();
        if (!pgn) return;
        try {
            var game = new Chess();
            var loadPgn = function(c, s, o) { var opts = o || {}; return (c.load_pgn && c.load_pgn(s, opts)) || (c.loadPgn && c.loadPgn(s, opts)); };
            var stripPgn = function(s) { if (!s || typeof s !== "string") return ""; return s.replace(/\s*\{[^}]*\}\s*/g, " ").replace(/\[\%[a-zA-Z]+\s+[^\]]*\]/g, "").replace(/\s*\$\d+\s*/g, " ").replace(/\s*(?:1-0|0-1|1\/2-1\/2|\*)\s*$/i, "").replace(/\s+/g, " ").trim(); };
            var stripped = stripPgn(pgn);
            if (!loadPgn(game, stripped, { sloppy: true })) {
                var idx = pgn.search(/\d+\.\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i);
                if (idx >= 0) {
                    var movePart = stripPgn(pgn.substring(idx));
                    if (!movePart || !loadPgn(game, movePart, { sloppy: true })) return;
                } else return;
            }
            var moves = (game.history && game.history()) || [];
            var cfg = {
                pieceTheme: "img/chesspieces/wikipedia/{piece}.png",
                position: "start",
                draggable: false
            };
            var board = ChessBoard(boardId, cfg);
            var moveIdx = 0;
            var autoPlayTimer = null;

            function goToMove(n) {
                moveIdx = Math.max(0, Math.min(n, moves.length));
                game.reset();
                for (var j = 0; j < moveIdx; j++) game.move(moves[j]);
                board.position(game.fen());
                updateMovesHighlight();
            }

            function updateMovesHighlight() {
                block.querySelectorAll(".cp-move").forEach(function(el, i) {
                    el.classList.toggle("cp-move-current", i === moveIdx - 1);
                });
            }

            var movesEl = block.querySelector(".pgn-moves");
            if (movesEl) {
                var html = "";
                var num = 1;
                for (var i = 0; i < moves.length; i++) {
                    if (i % 2 === 0) { html += "<span class=\"cp-move-num\">" + num + ".</span> "; num++; }
                    html += "<span class=\"cp-move\" data-idx=\"" + i + "\">" + moves[i] + "</span> ";
                }
                movesEl.innerHTML = html || "<span class=\"cp-no-moves\">' . $noMoves . '</span>";
                movesEl.querySelectorAll(".cp-move").forEach(function(el) {
                    el.onclick = function() { goToMove(parseInt(el.dataset.idx, 10) + 1); };
                });
            }

            var ctrlEl = block.querySelector(".pgn-controls");
            if (ctrlEl) {
                var btnStart = document.createElement("button");
                btnStart.className = "cp-ctrl-btn";
                btnStart.textContent = "' . $start . '";
                btnStart.onclick = function() { if (autoPlayTimer) clearInterval(autoPlayTimer); autoPlayTimer = null; goToMove(0); };
                var btnPrev = document.createElement("button");
                btnPrev.className = "cp-ctrl-btn";
                btnPrev.textContent = "' . $prev . '";
                btnPrev.onclick = function() { goToMove(moveIdx - 1); };
                var btnNext = document.createElement("button");
                btnNext.className = "cp-ctrl-btn";
                btnNext.textContent = "' . $next . '";
                btnNext.onclick = function() { goToMove(moveIdx + 1); };
                var btnEnd = document.createElement("button");
                btnEnd.className = "cp-ctrl-btn";
                btnEnd.textContent = "' . $end . '";
                btnEnd.onclick = function() { if (autoPlayTimer) clearInterval(autoPlayTimer); autoPlayTimer = null; goToMove(moves.length); };
                var btnPlay = document.createElement("button");
                btnPlay.className = "cp-ctrl-btn cp-ctrl-play";
                btnPlay.textContent = "' . $play . '";
                btnPlay.onclick = function() {
                    if (autoPlayTimer) { clearInterval(autoPlayTimer); autoPlayTimer = null; btnPlay.textContent = "' . $play . '"; return; }
                    btnPlay.textContent = "' . $pause . '";
                    autoPlayTimer = setInterval(function() {
                        if (moveIdx >= moves.length) { clearInterval(autoPlayTimer); autoPlayTimer = null; btnPlay.textContent = "' . $play . '"; return; }
                        goToMove(moveIdx + 1);
                    }, 1500);
                };
                ctrlEl.appendChild(btnStart);
                ctrlEl.appendChild(btnPrev);
                ctrlEl.appendChild(btnNext);
                ctrlEl.appendChild(btnEnd);
                ctrlEl.appendChild(btnPlay);
            }

            block.addEventListener("click", function() { block.focus(); });
            block.addEventListener("keydown", function(e) {
                if (e.key === "ArrowLeft") { e.preventDefault(); goToMove(moveIdx - 1); }
                if (e.key === "ArrowRight") { e.preventDefault(); goToMove(moveIdx + 1); }
                if (e.key === "Home") { e.preventDefault(); goToMove(0); }
                if (e.key === "End") { e.preventDefault(); goToMove(moves.length); }
            });
            block.setAttribute("tabindex", "0");

            goToMove(0);

            function doResize() { if (board && typeof board.resize === "function") board.resize(); }
            setTimeout(doResize, 150);
            window.addEventListener("resize", doResize);
            if (typeof ResizeObserver !== "undefined") {
                var ro = new ResizeObserver(function() { doResize(); });
                if (boardEl && boardEl.parentElement) ro.observe(boardEl.parentElement);
            }
        } catch (e) { console.error(e); }
    });
    }
    requestAnimationFrame(function() { setTimeout(initBoards, 50); });
});
</script>';
    }

    private static function wrap_html(string $title, string $navHtml, string $content, bool $extraScripts = false, ?int $tournamentId = null, string $seoMeta = ''): string
    {
        $css = '<link rel="stylesheet" href="style.css">';
        $scripts = $extraScripts ? '' : '';
        $navToggleLabel = esc_attr__('Menu', 'chess-podium');
        $navScript = '<script>(function(){var t=document.getElementById("cp-nav-toggle"),h=document.getElementById("cp-site-header");if(t&&h){t.onclick=function(){h.classList.toggle("nav-open");t.setAttribute("aria-expanded",h.classList.contains("nav-open"));};}})();</script>';
        $footer = __('Generated by', 'chess-podium') . '<br><span class="cp-footer-brand"><img src="logo-chess-podium.png" alt="" width="20" height="20" class="cp-footer-logo" loading="lazy"><a href="https://www.chesspodium.com/" target="_blank" rel="noopener">' . esc_html__('Chess Podium', 'chess-podium') . '</a></span>';
        $sponsorsHtml = class_exists('ChessPodium_Sponsors') ? ChessPodium_Sponsors::render_html('cp-sponsors', $tournamentId) : '';
        $lang = get_locale();
        $logoUrl = '';
        if ($tournamentId && class_exists('ChessPodium_PdfBrochure')) {
            $logoUrl = ChessPodium_PdfBrochure::get_meta($tournamentId, 'brochure_logo') ?: '';
        }
        if ($logoUrl === '' && class_exists('ChessPodiumPlugin')) {
            $logoUrl = ChessPodiumPlugin::get_default_logo_url();
        }
        $logoBanner = '';
        if ($logoUrl !== '') {
            $logoBanner = '<div class="cp-logo-banner"><img src="' . esc_url($logoUrl) . '" alt="" class="cp-tournament-logo" loading="eager"></div>';
        }
        $header = '<header id="cp-site-header"><button id="cp-nav-toggle" class="cp-nav-toggle" type="button" aria-label="' . $navToggleLabel . '" aria-expanded="false">&#9776;</button>' . $navHtml . '</header>';
        $headMeta = $seoMeta !== '' ? $seoMeta : '';
        $version = defined('CHESS_PODIUM_VERSION') ? CHESS_PODIUM_VERSION : '';
        $genComment = '<!-- Chess Podium v' . esc_html($version ?: '?') . ' exported ' . esc_html(current_time('Y-m-d H:i:s')) . ' (perf=avg_opp+400*(W-L)/n) -->';
        $safeTitle = esc_html($title);
        return '<!DOCTYPE html><html lang="' . esc_attr(substr($lang, 0, 2)) . '"><head>' . $genComment . '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . $safeTitle . '</title>' . $headMeta . $css . '</head><body>' . $header . $logoBanner . '<main class="container"><h1>' . $safeTitle . '</h1>' . $content . '</main>' . $sponsorsHtml . '<footer><p>' . $footer . '</p></footer>' . $scripts . $navScript . '</body></html>';
    }

    private static function write_css(string $path): void
    {
        $css = '
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
header { background: #1a365d; color: #fff; padding: 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; border-bottom: 3px solid #c9a227; }
.cp-nav-toggle { display: none; background: none; border: none; color: #fff; font-size: 1.5rem; cursor: pointer; padding: 0.5rem 0.75rem; line-height: 1; -webkit-tap-highlight-color: transparent; }
.cp-nav-toggle:hover { background: rgba(255,255,255,0.1); border-radius: 4px; }
.nav { flex: 1; }
.nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 1rem; }
.nav a { color: #ecf0f1; text-decoration: none; padding: 0.35em 0; display: block; }
.nav a:hover { text-decoration: underline; }
@media (max-width: 768px) {
  .cp-nav-toggle { display: block; margin-left: auto; }
  .nav { flex: 1 1 100%; order: 3; }
  header:not(.nav-open) .nav { display: none; }
  header.nav-open .nav { display: block; }
  .nav ul { flex-direction: column; gap: 0; padding-top: 0.5rem; }
  .nav ul li { border-top: 1px solid rgba(255,255,255,0.2); }
  .nav ul li a { padding: 0.75rem 0; }
}
.container { max-width: 900px; margin: 0 auto; padding: 1.5rem; background: #fff; min-height: 60vh; }
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
th { background: #1a365d; color: #fff; }
tr:nth-child(even) { background: #f9f9f9; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
.gallery figure { margin: 0; }
.gallery img { width: 100%; height: 200px; object-fit: cover; cursor: pointer; transition: opacity 0.2s; }
.gallery img:hover { opacity: 0.9; }
.gallery figcaption { padding: 0.5rem; font-size: 0.9em; color: #666; }
.gallery-hint { color: #666; font-size: 0.9em; margin-bottom: 1rem; }
.cp-lightbox { display: none; position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.9); align-items: center; justify-content: center; }
.cp-lightbox.open { display: flex; }
.cp-lightbox-content { max-width: 95vw; max-height: 95vh; text-align: center; position: relative; }
.cp-lightbox-content img { max-width: 95vw; max-height: 85vh; object-fit: contain; }
.cp-lightbox-caption { color: #fff; padding: 1rem; font-size: 1rem; }
.cp-lightbox-close, .cp-lightbox-prev, .cp-lightbox-next { position: absolute; background: rgba(255,255,255,0.2); color: #fff; border: none; font-size: 2rem; cursor: pointer; padding: 0.5rem 1rem; border-radius: 4px; }
.cp-lightbox-close:hover, .cp-lightbox-prev:hover, .cp-lightbox-next:hover { background: rgba(255,255,255,0.3); }
.cp-lightbox-close { top: 1rem; right: 1rem; }
.cp-lightbox-prev { left: 1rem; top: 50%; transform: translateY(-50%); }
.cp-lightbox-next { right: 1rem; top: 50%; transform: translateY(-50%); }
.cp-games-hint { color: #666; font-size: 0.9em; margin-bottom: 1.5rem; }
.game-block { margin: 2.5rem 0; padding: 1.25rem; border: 1px solid #ddd; border-radius: 8px; background: #fafafa; }
.cp-game-header { margin: 0 0 1rem; font-size: 1.1em; color: #1a365d; }
.cp-game-layout { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
.cp-game-board-wrap { flex-shrink: 0; }
.board-container { width: 400px; max-width: 100%; min-width: 280px; }
.pgn-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; align-items: center; }
.cp-ctrl-btn { margin: 0; padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.9em; border: 1px solid #ccc; border-radius: 4px; background: #fff; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
.cp-ctrl-btn:hover { background: #f0f0f0; }
.cp-ctrl-play { background: #2c5282; color: #fff; border-color: #1a365d; }
.cp-ctrl-play:hover { background: #1a365d; }
.cp-game-moves-panel { flex: 1; min-width: 220px; }
.cp-moves-label { font-weight: 600; margin-bottom: 0.5rem; font-size: 0.95em; }
.pgn-moves { font-family: "Georgia", serif; font-size: 1rem; line-height: 1.8; color: #333; }
.pgn-moves .cp-move-num { color: #666; margin-right: 0.25em; }
.pgn-moves .cp-move { cursor: pointer; padding: 0.1em 0.2em; border-radius: 3px; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
.pgn-moves .cp-move:hover { background: #e8e8e8; }
.pgn-moves .cp-move.cp-move-current { background: #2c5282; color: #fff; }
.pgn-moves .cp-no-moves { color: #999; }
@media (max-width: 600px) { .cp-game-layout { flex-direction: column; } .board-container { width: 100%; max-width: 400px; min-width: 280px; } }
.cp-sponsors { margin: 2rem 0 1rem; padding: 1.5rem; background: #f8fafc; border-top: 1px solid #e2e8f0; }
.cp-sponsors-inner { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 1.5rem 2rem; }
.cp-sponsor-item { display: flex; align-items: center; }
.cp-sponsor-logo { max-height: 48px; max-width: 140px; object-fit: contain; opacity: 0.85; }
.cp-sponsor-item a:hover .cp-sponsor-logo { opacity: 1; }
footer { text-align: center; padding: 1rem; color: #666; font-size: 0.9em; line-height: 1.6; }
.cp-footer-brand { display: inline-flex; align-items: center; gap: 0.35em; justify-content: center; }
.cp-footer-logo { vertical-align: middle; opacity: 0.9; }
button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; }
.cp-player-link { color: #2c5282; text-decoration: none; }
.cp-player-link:hover { text-decoration: underline; }
.cp-flag-img { display: inline-block; margin-right: 4px; }
.cp-player-modal { display: none; position: fixed; inset: 0; z-index: 9998; align-items: center; justify-content: center; padding: 1rem; }
.cp-player-modal.open { display: flex; }
.cp-player-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); cursor: pointer; }
.cp-player-modal-content { position: relative; background: #fff; border-radius: 8px; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 1.5rem; box-shadow: 0 4px 20px rgba(0,0,0,0.2); }
.cp-player-modal-close { position: absolute; top: 0.5rem; right: 0.5rem; background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; padding: 0.25rem; line-height: 1; }
.cp-player-modal-close:hover { color: #333; }
.cp-player-modal-content h3 { margin-top: 0; }
.cp-player-modal-content h4 { margin: 1rem 0 0.5rem; }
.cp-logo-banner { background: #fff; padding: 1.5rem 1.5rem 1.25rem; text-align: center; border-bottom: 1px solid #e2e8f0; }
.cp-tournament-logo { max-height: 200px; max-width: 100%; width: auto; object-fit: contain; display: inline-block; }
.cp-venue-hero { margin: 0 0 1.5rem; padding: 0; }
.cp-venue-hero img { width: 100%; max-height: 320px; object-fit: cover; border-radius: 8px; display: block; }
.cp-elo-hint { color: #666; font-size: 0.9em; margin-bottom: 1rem; }
.cp-norm-badge { display: inline-block; margin-left: 0.35em; padding: 0.15em 0.5em; font-size: 0.8em; font-weight: 600; color: #1a365d; background: #e8f4fc; border: 1px solid #2c5282; border-radius: 4px; vertical-align: middle; }
';
        self::write_file($path, trim($css));
    }
}
