<?php
/**
 * Plugin Name: Chess Podium
 * Plugin URI: https://chesspodium.com
 * Description: Chess tournament manager for WordPress: players, rounds, Swiss and round-robin pairings (including double round robin), results, standings, and exports. Free plan: up to 10 players per tournament. Upgrade to Pro for unlimited players.
 * Version: 1.9.3
 * Author: Chess Podium
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Requires at least: 5.8
 * Requires PHP: 7.4
 * Text Domain: chess-podium
 */

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

require_once __DIR__ . '/includes/class-export-engine.php';
require_once __DIR__ . '/includes/class-chess-podium-widget.php';
require_once __DIR__ . '/includes/class-country-helper.php';
require_once __DIR__ . '/includes/class-license.php';
require_once __DIR__ . '/includes/class-registration.php';
require_once __DIR__ . '/includes/class-tournament-communications.php';
require_once __DIR__ . '/includes/class-locale.php';
require_once __DIR__ . '/includes/class-player-status.php';
require_once __DIR__ . '/includes/class-admin.php';
require_once __DIR__ . '/includes/class-frontend.php';
require_once __DIR__ . '/includes/class-trf-export.php';
require_once __DIR__ . '/includes/class-uscf-export.php';
require_once __DIR__ . '/includes/class-ecf-export.php';
require_once __DIR__ . '/includes/class-rating-context.php';
require_once __DIR__ . '/includes/class-pdf-brochure.php';
require_once __DIR__ . '/includes/class-events-calendar.php';
require_once __DIR__ . '/includes/class-tournament-archive-card.php';
require_once __DIR__ . '/includes/class-pdf-certificate.php';
$badges_file = __DIR__ . '/includes/class-pdf-badges.php';
if (file_exists($badges_file)) {
    require_once $badges_file;
}
require_once __DIR__ . '/includes/class-sponsors.php';
require_once __DIR__ . '/includes/class-club-ranking.php';
require_once __DIR__ . '/includes/class-grand-prix.php';
require_once __DIR__ . '/includes/class-pgn-live.php';
require_once __DIR__ . '/includes/class-lichess-round-import.php';
require_once __DIR__ . '/includes/class-max-weight-matching.php';
require_once __DIR__ . '/includes/class-download-stats-dashboard.php';
require_once __DIR__ . '/includes/class-tournament-seo.php';
require_once __DIR__ . '/includes/class-frontend-ajax.php';
require_once __DIR__ . '/includes/class-excel-import.php';
require_once __DIR__ . '/includes/class-xlsx-reader.php';
require_once __DIR__ . '/includes/class-chess-results-excel-import.php';
require_once __DIR__ . '/includes/class-fide-norm.php';
require_once __DIR__ . '/includes/class-fair-play-display.php';
require_once __DIR__ . '/includes/class-team-tournaments.php';
require_once __DIR__ . '/includes/class-player-directory.php';
require_once __DIR__ . '/includes/class-player-profile-admin.php';
require_once __DIR__ . '/includes/class-player-profile-frontend.php';

if (!defined('CHESS_PODIUM_PLUGIN_FILE')) {
    define('CHESS_PODIUM_PLUGIN_FILE', __FILE__);
}
if (!defined('CHESS_PODIUM_PLUGIN_DIR')) {
    define('CHESS_PODIUM_PLUGIN_DIR', plugin_dir_path(__FILE__));
}
if (!defined('CHESS_PODIUM_PLUGIN_URL')) {
    define('CHESS_PODIUM_PLUGIN_URL', plugin_dir_url(__FILE__));
}
if (!defined('CHESS_PODIUM_VERSION')) {
    define('CHESS_PODIUM_VERSION', '1.9.3');
}

// Must run before plugins_loaded so load_textdomain sees our locale
add_filter('determine_locale', 'chess_podium_determine_locale', 1);
add_filter('locale', 'chess_podium_determine_locale', 1);

/**
 * Safer fallback for player profile photos:
 * If the player has no usable FIDE profile photo, use generic male/female avatar
 * instead of Wikipedia/Wikidata thumbnails (which can be wrong/offensive).
 */
add_filter('chess_podium_player_photo_url', static function ($url, $player, $tournament) {
    $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 = plugins_url(
        $isFemale ? 'assets/ChessPlayerFemale.svg' : 'assets/ChessPlayerMale.svg',
        defined('CHESS_PODIUM_PLUGIN_FILE') ? CHESS_PODIUM_PLUGIN_FILE : __FILE__
    );

    $fideId = class_exists('ChessPodiumPlugin') ? ChessPodiumPlugin::sanitize_fide_id((string) ($player->fide_id ?? '')) : '';
    if ($fideId === '') {
        return $generic;
    }

    $cached = get_transient('cp_fide_photo_v2_' . $fideId);
    $hasFidePhoto = is_string($cached) && $cached !== '' && $cached !== '__none__';
    if ($hasFidePhoto) {
        return $url;
    }
    // Allow Wikipedia photo only when verified via Wikidata FIDE ID (cp_wiki_wd_photo_v1_*).
    $wd = get_transient('cp_wiki_wd_photo_v1_' . $fideId);
    if (is_string($wd) && $wd !== '' && $wd !== '__none__') {
        return $wd;
    }
    return $generic;
}, 50, 3);

final class ChessPodiumPlugin
{
    private const DB_VERSION = '0.3.2';
    private const REWRITE_VERSION = '2026-03-31-live-dashboard-seo-urls-1';
    private const MENU_SLUG = 'chess-podium';
    private const ONBOARDING_REDIRECT_OPTION = 'chess_podium_do_onboarding_redirect';
    private const ONBOARDING_DONE_OPTION = 'chess_podium_onboarding_completed';
    private const CUSTOM_STYLE_OPTION = 'chess_podium_custom_style_options';

    /** Default bbpPairings API URL (used when exec() is disabled). Override via chess_podium_pairing_api_url filter. */
    private const DEFAULT_PAIRING_API_URL = 'http://89.167.56.28/pairing-api/pair.php';

    /** Max rounds per tournament (double RR: e.g. 16 players → 30 rounds). */
    private const MAX_ROUNDS_TOTAL = 99;

    /** Large tournament threshold for async rendering (avoid timeouts). */
    private const ASYNC_STANDINGS_PLAYER_THRESHOLD = 100;

    /** Cache group for wp_cache_* (object cache when available). */
    private const CACHE_GROUP = 'chess_podium';

    /**
     * Simple per-request memoization. Keys are full cache keys.
     *
     * @var array<string,mixed>
     */
    private static array $runtimeCache = [];

    /** @var array<int,bool> */
    private static array $pgnFetchShutdownScheduled = [];

    private static function cache_version(int $tournamentId): int
    {
        if ($tournamentId <= 0) {
            return 1;
        }
        $key = 'cp_cache_v_' . $tournamentId;
        $v = get_transient($key);
        $v = is_numeric($v) ? (int) $v : 1;
        return max(1, $v);
    }

    private static function bump_cache_version(int $tournamentId): void
    {
        if ($tournamentId <= 0) {
            return;
        }
        $key = 'cp_cache_v_' . $tournamentId;
        $v = self::cache_version($tournamentId);
        set_transient($key, $v + 1, 30 * DAY_IN_SECONDS);
        self::$runtimeCache = [];
    }

    /**
     * Raise PHP time/memory limits for long admin requests (e.g. large Lichess PGN download + many inserts).
     * Default hosting max_execution_time (often 30s) can kill the request while wp_remote_get still waits (e.g. 90s).
     */
    private static function extend_long_operation_budget(int $seconds = 300): void
    {
        if (function_exists('wp_raise_memory_limit')) {
            wp_raise_memory_limit('admin');
        }
        if (function_exists('ignore_user_abort')) {
            @ignore_user_abort(true);
        }
        if (function_exists('set_time_limit')) {
            @set_time_limit($seconds);
        }
        if (function_exists('ini_set')) {
            @ini_set('max_execution_time', (string) $seconds);
        }
    }

    private static function cache_key(int $tournamentId, string $bucket, string $suffix = ''): string
    {
        $v = self::cache_version($tournamentId);
        return 'cp:' . $bucket . ':' . $tournamentId . ':v' . $v . ($suffix !== '' ? ':' . $suffix : '');
    }

    private static function cache_get(string $key)
    {
        if (array_key_exists($key, self::$runtimeCache)) {
            return self::$runtimeCache[$key];
        }
        $val = wp_cache_get($key, self::CACHE_GROUP);
        if ($val !== false) {
            self::$runtimeCache[$key] = $val;
            return $val;
        }
        return null;
    }

    private static function cache_set(string $key, $value, int $ttl = 120): void
    {
        self::$runtimeCache[$key] = $value;
        wp_cache_set($key, $value, self::CACHE_GROUP, $ttl);
    }

    public static function init(): void
    {
        add_action('plugins_loaded', [self::class, 'load_textdomain']);
        if (is_admin() && class_exists('ChessPodium_Admin')) {
            ChessPodium_Admin::init();
        }
        if (is_admin() && class_exists('ChessPodium_PlayerProfileAdmin')) {
            ChessPodium_PlayerProfileAdmin::init();
        }
        if (!is_admin() && class_exists('ChessPodium_Frontend')) {
            ChessPodium_Frontend::init();
        }
        add_action('init', [self::class, 'register_shortcode_block']);
        add_action('enqueue_block_editor_assets', [self::class, 'enqueue_block_editor_assets']);
        add_shortcode('chess_podium_tournament', [self::class, 'render_tournament_shortcode']);
        add_shortcode('chess_podium_brochure', [self::class, 'render_brochure_shortcode']);
        add_shortcode('chess_podium_club_ranking', [self::class, 'render_club_ranking_shortcode']);
        add_shortcode('chess_podium_grand_prix', [ChessPodium_GrandPrix::class, 'render_shortcode']);
        add_shortcode('chess_podium_player_profile', [self::class, 'render_player_profile_shortcode']);
        add_shortcode('chess_podium_tornei', [self::class, 'render_tornei_page_shortcode']);
        add_shortcode('chess_podium_manager_tornei', [self::class, 'render_tornei_page_shortcode']);
        add_shortcode('chess_podium_storico_tornei', [self::class, 'render_tornei_page_shortcode']);
        add_shortcode('checkmate_manager_tornei', [self::class, 'render_tornei_page_shortcode']);
        add_shortcode('storico_tornei', [self::class, 'render_tornei_page_shortcode']);
        add_shortcode('regina_torneo', [self::class, 'render_tournament_shortcode']);
        add_shortcode('chess_podium_events', [ChessPodium_Events_Calendar::class, 'render_shortcode']);
        ChessPodium_Tournament_Archive_Card::init();
        add_action('init', [self::class, 'register_rewrite']);
        add_action('init', [self::class, 'maybe_flush_rewrite_rules'], 20);
        add_action('chess_podium_license_check', [ChessPodium_License::class, 'force_revalidate']);
        add_action('widgets_init', [self::class, 'register_widgets']);
        add_filter('chess_podium_tournament_slug', [self::class, 'filter_slug_by_locale'], 5);
        add_filter('chess_podium_live_slug', [self::class, 'filter_slug_by_locale'], 5);
        add_filter('chess_podium_game_slug', [self::class, 'filter_slug_by_locale'], 5);
        if (class_exists('ChessPodium_FrontendAjax')) {
            ChessPodium_FrontendAjax::init();
        }
        if (class_exists('ChessPodium_PlayerProfileFrontend')) {
            ChessPodium_PlayerProfileFrontend::init();
        }
        ChessPodium_Registration::init();
        ChessPodium_Tournament_Communications::init();
        ChessPodium_PgnLive::init();
        ChessPodium_GrandPrix::init();
        self::maybe_run_schema_updates();
        self::maybe_create_tornei_page();
    }

    public static function maybe_flush_rewrite_rules(): void
    {
        $key = 'chess_podium_rewrite_version';
        $saved = (string) get_option($key, '');
        if ($saved === self::REWRITE_VERSION) {
            return;
        }
        // Ensure new rewrite rules are registered before flushing.
        self::register_rewrite();
        flush_rewrite_rules(false);
        update_option($key, self::REWRITE_VERSION, true);
    }

    public static function activate(): void
    {
        self::create_tables();
        add_option('chess_podium_db_version', self::DB_VERSION);
        add_option(self::ONBOARDING_REDIRECT_OPTION, 1);
        if (class_exists('ChessPodium_PlayerDirectory')) {
            ChessPodium_PlayerDirectory::create_tables();
        }
        if (class_exists('ChessPodium_TeamTournaments')) {
            ChessPodium_TeamTournaments::create_tables();
        }
        self::register_rewrite();
        flush_rewrite_rules();
        self::maybe_create_tornei_page();
        if (!wp_next_scheduled('chess_podium_license_check')) {
            wp_schedule_event(time(), 'daily', 'chess_podium_license_check');
        }
    }

    public static function deactivate(): void
    {
        flush_rewrite_rules();
        wp_clear_scheduled_hook('chess_podium_license_check');
    }

    public static function get_tournament_url(int $tournamentId = 0): string
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        $base = home_url('/' . $slug . '/');
        return $tournamentId > 0 ? add_query_arg('tournament_id', $tournamentId, $base) : $base;
    }

    /**
     * SEO-friendly tournament URL (no query string): /{slug}/{tournamentId}/
     */
    public static function get_tournament_pretty_url(int $tournamentId): string
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        return home_url('/' . $slug . '/' . (int) $tournamentId . '/');
    }

    /**
     * SEO-friendly tournament URL (slug): /{slug}/{tournamentSeoSlug}/
     */
    public static function get_tournament_pretty_slug_url(string $tournamentSeoSlug): string
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        $tournamentSeoSlug = sanitize_title($tournamentSeoSlug);
        return home_url('/' . $slug . '/' . $tournamentSeoSlug . '/');
    }

    /**
     * Public tournament page URL (SEO): prefers name slug, else numeric path. Use for admin links and email fallbacks.
     */
    public static function get_public_tournament_view_url(int $tournamentId): string
    {
        if ($tournamentId <= 0) {
            return self::get_tournament_url(0);
        }
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return self::get_tournament_pretty_url($tournamentId);
        }
        $seo = self::get_tournament_seo_slug($t);
        if ($seo !== '') {
            return self::get_tournament_pretty_slug_url($seo);
        }
        return self::get_tournament_pretty_url($tournamentId);
    }

    /**
     * Public URL for a specific round (pairings), using SEO slug when available.
     */
    public static function get_public_tournament_round_url(int $tournamentId, int $roundNo): string
    {
        if ($tournamentId <= 0) {
            return self::get_tournament_url(0);
        }
        if ($roundNo <= 0) {
            return self::get_public_tournament_view_url($tournamentId);
        }
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return self::get_tournament_round_pretty_url($tournamentId, $roundNo);
        }
        $seo = self::get_tournament_seo_slug($t);
        if ($seo !== '') {
            return self::get_tournament_round_pretty_slug_url($seo, $roundNo);
        }
        return self::get_tournament_round_pretty_url($tournamentId, $roundNo);
    }

    /**
     * SEO-friendly tournament round URL:
     * /{slug}/{tournamentId}/round/{roundNo}/
     */
    public static function get_tournament_round_pretty_url(int $tournamentId, int $roundNo): string
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        return home_url('/' . $slug . '/' . (int) $tournamentId . '/round/' . (int) $roundNo . '/');
    }

    /**
     * SEO-friendly tournament round URL (slug):
     * /{slug}/{tournamentSeoSlug}/round/{roundNo}/
     */
    public static function get_tournament_round_pretty_slug_url(string $tournamentSeoSlug, int $roundNo): string
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        $tournamentSeoSlug = sanitize_title($tournamentSeoSlug);
        return home_url('/' . $slug . '/' . $tournamentSeoSlug . '/round/' . (int) $roundNo . '/');
    }

    private static function get_tournament_seo_slug(?object $tournament): string
    {
        if (!$tournament || empty($tournament->name)) {
            return '';
        }
        $base = sanitize_title((string) $tournament->name);
        /**
         * Filter to override the tournament SEO slug.
         *
         * @param string $base
         * @param object $tournament
         */
        $base = (string) apply_filters('chess_podium_tournament_seo_slug', $base, $tournament);
        return sanitize_title($base);
    }

    private static function find_tournament_id_by_seo_slug(string $seoSlug): int
    {
        $seoSlug = sanitize_title($seoSlug);
        if ($seoSlug === '') {
            return 0;
        }
        $cacheKey = 'chess_podium_tid_by_slug_' . $seoSlug;
        $cached = get_transient($cacheKey);
        if (is_numeric($cached) && (int) $cached > 0) {
            return (int) $cached;
        }
        global $wpdb;
        $table = self::db_table('regina_tournaments');
        $rows = $wpdb->get_results("SELECT id, name FROM {$table} ORDER BY id DESC LIMIT 200"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        foreach ((array) $rows as $r) {
            $nameSlug = sanitize_title((string) ($r->name ?? ''));
            if ($nameSlug !== '' && $nameSlug === $seoSlug) {
                set_transient($cacheKey, (int) $r->id, 12 * HOUR_IN_SECONDS);
                return (int) $r->id;
            }
        }
        set_transient($cacheKey, 0, 30 * MINUTE_IN_SECONDS);
        return 0;
    }

    /**
     * SEO slug for a player name (used in /tournament/{tournament}/player/{slug}/).
     */
    private static function get_player_seo_slug(?object $player): string
    {
        if (!$player || empty($player->name)) {
            return '';
        }
        $base = sanitize_title((string) $player->name);
        /**
         * Filter to override the player SEO slug.
         *
         * @param string $base
         * @param object $player
         */
        $base = (string) apply_filters('chess_podium_player_seo_slug', $base, $player);
        return sanitize_title($base);
    }

    private static function find_player_id_by_seo_slug(int $tournamentId, string $playerSlug): int
    {
        $playerSlug = sanitize_title($playerSlug);
        if ($tournamentId <= 0 || $playerSlug === '') {
            return 0;
        }
        foreach (self::get_players($tournamentId) as $p) {
            $slug = self::get_player_seo_slug($p);
            if ($slug !== '' && $slug === $playerSlug) {
                return (int) $p->id;
            }
        }
        return 0;
    }

    public static function get_live_url(int $tournamentId = 0): string
    {
        $liveSlug = apply_filters('chess_podium_live_slug', 'livetournament');
        $liveSlug = sanitize_title($liveSlug) ?: 'livetournament';
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        $base = home_url('/' . $slug . '/' . $liveSlug . '/');
        if ($tournamentId <= 0) {
            return $base;
        }
        $t = self::get_tournament($tournamentId);
        $seo = $t ? self::get_tournament_seo_slug($t) : '';
        if ($seo !== '') {
            return home_url('/' . $slug . '/' . $liveSlug . '/' . $seo . '/');
        }
        return home_url('/' . $slug . '/' . $liveSlug . '/' . (int) $tournamentId . '/');
    }

    /**
     * URL slug for a game: white-black only (e.g. caruana-nakamura).
     * Double RR rematch has colours reversed → nakamura-caruana (distinct slug, no extra suffix needed).
     */
    private static function get_pairing_seo_slug_from_names(string $whiteName, string $blackName): string
    {
        $w = sanitize_title($whiteName);
        $b = sanitize_title($blackName);
        $base = trim($w . '-' . $b, '-');
        return sanitize_title($base);
    }

    private static function get_pairing_seo_slug(int $pairingId, int $tournamentId): string
    {
        $cacheKey = 'chess_podium_pairing_slug_' . $pairingId;
        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT p.id, p.round_no, wp.name AS white_name, bp.name AS black_name
             FROM $pairings p
             LEFT JOIN $players wp ON p.white_player_id = wp.id
             LEFT JOIN $players bp ON p.black_player_id = bp.id
             WHERE p.id = %d AND p.tournament_id = %d AND p.is_bye = 0",
            $pairingId,
            $tournamentId
        ));
        if (!$row) {
            return '';
        }
        $slug = self::get_pairing_seo_slug_from_names((string) ($row->white_name ?? ''), (string) ($row->black_name ?? ''));
        if ($slug !== '') {
            set_transient($cacheKey, $slug, 12 * HOUR_IN_SECONDS);
        }
        return $slug;
    }

    private static function find_pairing_id_by_seo_slug(int $tournamentId, string $pairingSlug): int
    {
        $pairingSlug = sanitize_title($pairingSlug);
        if ($tournamentId <= 0 || $pairingSlug === '') {
            return 0;
        }
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT p.id, p.round_no, wp.name AS white_name, bp.name AS black_name
             FROM $pairings p
             LEFT JOIN $players wp ON p.white_player_id = wp.id
             LEFT JOIN $players bp ON p.black_player_id = bp.id
             WHERE p.tournament_id = %d AND p.is_bye = 0
             ORDER BY p.round_no DESC, p.id DESC",
            $tournamentId
        ));
        foreach ((array) $rows as $r) {
            $slug = self::get_pairing_seo_slug_from_names((string) ($r->white_name ?? ''), (string) ($r->black_name ?? ''));
            if ($slug !== '' && $slug === $pairingSlug) {
                return (int) $r->id;
            }
        }
        return 0;
    }

    public static function get_game_url(int $pairingId, int $tournamentId = 0): string
    {
        $gameSlug = apply_filters('chess_podium_game_slug', 'game');
        $gameSlug = sanitize_title($gameSlug) ?: 'game';
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        if ($tournamentId > 0) {
            $t = self::get_tournament($tournamentId);
            $seoSlug = self::get_tournament_seo_slug($t);
            if ($seoSlug !== '') {
                $pairingSeoSlug = self::get_pairing_seo_slug((int) $pairingId, (int) $tournamentId);
                if ($pairingSeoSlug !== '') {
                    return home_url('/' . $slug . '/' . $seoSlug . '/' . $gameSlug . '/' . $pairingSeoSlug . '/');
                }
                return home_url('/' . $slug . '/' . $seoSlug . '/' . $gameSlug . '/' . (int) $pairingId . '/');
            }
        }
        return home_url('/' . $slug . '/' . $gameSlug . '/' . (int) $pairingId . '/');
    }

    public static function filter_slug_by_locale(string $slug): string
    {
        $which = current_filter() === 'chess_podium_live_slug' ? 'live' : (current_filter() === 'chess_podium_game_slug' ? 'game' : 'main');
        $locale = function_exists('get_locale') ? get_locale() : '';
        if (strpos($locale, 'it') === 0) {
            return $which === 'live' ? 'livetorneo' : ($which === 'game' ? 'partita' : 'torneo');
        }
        if (strpos($locale, 'en') === 0 || strpos($locale, 'de') === 0 || strpos($locale, 'fr') === 0 || strpos($locale, 'es') === 0) {
            return $which === 'live' ? 'livetournament' : ($which === 'game' ? 'game' : 'tournament');
        }
        return $slug;
    }

    public static function register_rewrite(): void
    {
        $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $slug = sanitize_title($slug) ?: 'torneo';
        $playerSlug = apply_filters('chess_podium_player_slug', 'player');
        $playerSlug = sanitize_title((string) $playerSlug) ?: 'player';
        $liveSlug = apply_filters('chess_podium_live_slug', 'livetournament');
        $liveSlug = sanitize_title($liveSlug) ?: 'livetournament';
        $gameSlug = apply_filters('chess_podium_game_slug', 'game');
        $gameSlug = sanitize_title($gameSlug) ?: 'game';
        // Site-wide player profile: /{playerSlug}/{playerSeoSlug}/
        add_rewrite_rule('^' . preg_quote($playerSlug, '/') . '/([^/]+)/?$', 'index.php?chess_podium_player_page=1&cp_player_global_slug=$matches[1]', 'top');
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/?$', 'index.php?chess_podium_torneo_page=1', 'top');
        // SEO-friendly tournament URL without query string: /{slug}/{tournamentId}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([0-9]+)/?$', 'index.php?chess_podium_torneo_page=1&tournament_id=$matches[1]', 'top');
        // SEO-friendly round URL: /{slug}/{tournamentId}/round/{roundNo}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([0-9]+)/round/([0-9]+)/?$', 'index.php?chess_podium_torneo_page=1&tournament_id=$matches[1]&round_no=$matches[2]', 'top');
        add_rewrite_rule('^currenttournament/?$', 'index.php?chess_podium_torneo_page=1', 'top');
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/' . preg_quote($gameSlug, '/') . '/([0-9]+)/?$', 'index.php?chess_podium_game=1&chess_podium_pairing_id=$matches[1]', 'top');
        // SEO-friendly game URL (id): /{slug}/{tournamentSeoSlug}/{gameSlug}/{pairingId}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([^/]+)/' . preg_quote($gameSlug, '/') . '/([0-9]+)/?$', 'index.php?chess_podium_game=1&chess_podium_pairing_id=$matches[2]&tournament_slug=$matches[1]', 'top');
        // SEO-friendly game URL (pairing slug): /{slug}/{tournamentSeoSlug}/{gameSlug}/{pairingSeoSlug}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([^/]+)/' . preg_quote($gameSlug, '/') . '/([^/]+)/?$', 'index.php?chess_podium_game=1&chess_podium_pairing_slug=$matches[2]&tournament_slug=$matches[1]', 'top');
        // Live dashboard with tournament (SEO): /{slug}/{liveSlug}/{id}/ and /{slug}/{liveSlug}/{tournamentSeoSlug}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/' . preg_quote($liveSlug, '/') . '/([0-9]+)/?$', 'index.php?chess_podium_live=1&tournament_id=$matches[1]', 'top');
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/' . preg_quote($liveSlug, '/') . '/([^/]+)/?$', 'index.php?chess_podium_live=1&tournament_slug=$matches[1]', 'top');
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/' . preg_quote($liveSlug, '/') . '/?$', 'index.php?chess_podium_live=1', 'top');
        add_rewrite_rule('^' . preg_quote($liveSlug, '/') . '/?$', 'index.php?chess_podium_live=1', 'top');
        // SEO-friendly player profile: /{slug}/{tournamentSeoSlug}/player/{playerSeoSlug}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([^/]+)/player/([^/]+)/?$', 'index.php?chess_podium_torneo_page=1&tournament_slug=$matches[1]&cp_player_slug=$matches[2]', 'top');
        // SEO-friendly tournament URL (slug): /{slug}/{tournamentSeoSlug}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([^/]+)/?$', 'index.php?chess_podium_torneo_page=1&tournament_slug=$matches[1]', 'top');
        // SEO-friendly round URL (slug): /{slug}/{tournamentSeoSlug}/round/{roundNo}/
        add_rewrite_rule('^' . preg_quote($slug, '/') . '/([^/]+)/round/([0-9]+)/?$', 'index.php?chess_podium_torneo_page=1&tournament_slug=$matches[1]&round_no=$matches[2]', 'top');
        if ($slug === 'torneo') {
            add_rewrite_rule('^torneo/live/?$', 'index.php?chess_podium_live=1', 'top');
            add_rewrite_rule('^torneo/game/([0-9]+)/?$', 'index.php?chess_podium_game=1&chess_podium_pairing_id=$matches[1]', 'top');
        }
    }

    public static function register_query_vars(array $vars): array
    {
        $vars[] = 'chess_podium_torneo_page';
        $vars[] = 'chess_podium_player_page';
        $vars[] = 'chess_podium_live';
        $vars[] = 'chess_podium_game';
        $vars[] = 'chess_podium_pairing_id';
        $vars[] = 'chess_podium_pairing_slug';
        $vars[] = 'tournament_id';
        $vars[] = 'round_no';
        $vars[] = 'tournament_slug';
        $vars[] = 'cp_player_slug';
        $vars[] = 'cp_player';
        $vars[] = 'cp_player_global_slug';
        return $vars;
    }

    public static function register_live_rest_routes(): void
    {
        register_rest_route('chess-podium/v1', '/live/current', [
            'methods' => 'GET',
            'callback' => [self::class, 'rest_live_current'],
            'permission_callback' => '__return_true',
            'args' => [
                'tournament_id' => ['required' => false, 'type' => 'integer', 'default' => 0],
            ],
        ]);
        register_rest_route('chess-podium/v1', '/update-live-move', [
            'methods' => 'POST',
            'callback' => [self::class, 'rest_update_live_move'],
            'permission_callback' => '__return_true',
            'args' => [
                'pairing_id' => ['required' => false, 'type' => 'integer', 'default' => 0],
                'tournament_id' => ['required' => false, 'type' => 'integer', 'default' => 0],
                'board_no' => ['required' => false, 'type' => 'integer', 'default' => 0],
                'round_no' => ['required' => false, 'type' => 'integer', 'default' => 0],
                'pgn' => ['required' => true, 'type' => 'string'],
                'fen' => ['required' => true, 'type' => 'string'],
            ],
        ]);
        register_rest_route('chess-podium/v1', '/live-moves', [
            'methods' => 'GET',
            'callback' => [self::class, 'rest_live_moves'],
            'permission_callback' => '__return_true',
            'args' => [
                'tournament_id' => [
                    'required' => false,
                    'type' => 'string',
                    'default' => '0',
                    'sanitize_callback' => function ($v) {
                        return (string) max(0, (int) $v);
                    },
                ],
                'pairing_id' => [
                    'required' => false,
                    'type' => 'string',
                    'default' => '0',
                    'sanitize_callback' => function ($v) {
                        return (string) max(0, (int) $v);
                    },
                ],
            ],
        ]);
    }

    public static function rest_live_current(\WP_REST_Request $request): \WP_REST_Response
    {
        $tid = (int) $request->get_param('tournament_id');
        $tournament = $tid > 0 ? self::get_tournament($tid) : self::get_tournament_in_progress();
        if (!$tournament) {
            return new \WP_REST_Response(['tournament' => null, 'standings' => [], 'pairings' => []], 200);
        }
        $tid = (int) $tournament->id;
        $standings = self::compute_standings($tid);
        $currentRound = (int) $tournament->current_round;
        $pairings = $currentRound > 0 ? self::get_pairings_for_round($tid, $currentRound) : [];
        $startingRanks = self::get_starting_ranks($tid);
        $pointsBeforeRound = $currentRound > 0 ? self::compute_standings_before_round($tid, $currentRound) : [];
        $pairingsData = [];
        foreach ($pairings as $i => $p) {
            $whiteId = (int) $p->white_player_id;
            $blackId = (int) $p->black_player_id;
            $pairingsData[] = [
                'board' => $i + 1,
                'pairing_id' => (int) $p->id,
                'white' => $p->white_name ?? '',
                'white_title' => isset($p->white_title) && $p->white_title !== '' ? (string) $p->white_title : '',
                'white_country' => $p->white_country ?? '',
                'white_pts' => $pointsBeforeRound[$whiteId] ?? 0,
                'white_nr' => $startingRanks[$whiteId] ?? 0,
                'black' => (int) $p->is_bye === 1 ? '-' : ($p->black_name ?? ''),
                'black_title' => (int) $p->is_bye === 1 ? '' : (isset($p->black_title) && $p->black_title !== '' ? (string) $p->black_title : ''),
                'black_country' => (int) $p->is_bye === 1 ? '' : ($p->black_country ?? ''),
                'black_pts' => (int) $p->is_bye === 1 ? null : ($pointsBeforeRound[$blackId] ?? 0),
                'black_nr' => (int) $p->is_bye === 1 ? null : ($startingRanks[$blackId] ?? 0),
                'result' => (string) ($p->result ?? ''),
                'is_bye' => (int) $p->is_bye === 1,
                'is_live' => !empty($p->is_live),
            ];
        }
        $tiebreakerCols = self::get_tiebreaker_columns_for_display($tournament);
        $tbKeys = array_keys($tiebreakerCols);
        return new \WP_REST_Response([
            'tournament' => [
                'id' => (int) $tournament->id,
                'name' => (string) $tournament->name,
                'current_round' => $currentRound,
                'rounds_total' => (int) $tournament->rounds_total,
                'tiebreaker_columns' => array_values($tiebreakerCols),
                'tiebreaker_keys' => $tbKeys,
            ],
            'standings' => array_map(static function ($s, $pos) use ($startingRanks, $tbKeys) {
                $pid = (int) ($s['player_id'] ?? 0);
                $title = $s['title'] ?? '';
                $row = [
                    'pos' => $pos + 1,
                    'nr' => $startingRanks[$pid] ?? 0,
                    'name' => $s['name'] ?? '',
                    'title' => $title,
                    'country' => $s['country'] ?? '',
                    'points' => (float) ($s['points'] ?? 0),
                    'rating' => (int) ($s['rating'] ?? 0),
                    'gm_norm' => !empty($s['gm_norm']) && (!class_exists('ChessPodium_FideNorm') || ChessPodium_FideNorm::is_norm_relevant_for_title('gm_norm', (string) $title)),
                    'im_norm' => !empty($s['im_norm']) && (!class_exists('ChessPodium_FideNorm') || ChessPodium_FideNorm::is_norm_relevant_for_title('im_norm', (string) $title)),
                ];
                foreach ($tbKeys as $key) {
                    $val = $s[$key] ?? 0;
                    $row[$key] = in_array($key, ['sb', 'ks'], true) ? (float) $val : (in_array($key, ['rating', 'aro', 'win', 'bwg', 'rep'], true) ? (int) $val : (float) $val);
                }
                return $row;
            }, $standings, array_keys($standings)),
            'pairings' => $pairingsData,
        ], 200);
    }

    public static function rest_update_live_move(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!class_exists('ChessPodium_License') || !ChessPodium_License::is_pro()) {
            return new \WP_REST_Response([
                'success' => false,
                'message' => __('Live transmission requires Chess Podium Pro. Upgrade at chesspodium.com', 'chess-podium'),
                'code' => 'pro_required',
            ], 403);
        }

        $pairingId = (int) $request->get_param('pairing_id');
        $tournamentId = (int) $request->get_param('tournament_id');
        $boardNo = (int) $request->get_param('board_no');
        $roundNo = (int) $request->get_param('round_no');
        $pgn = sanitize_textarea_field((string) $request->get_param('pgn'));
        $fen = sanitize_text_field((string) $request->get_param('fen'));

        if ($pairingId <= 0 && ($tournamentId <= 0 || $boardNo <= 0)) {
            return new \WP_REST_Response([
                'success' => false,
                'message' => __('Provide pairing_id OR (tournament_id + board_no).', 'chess-podium'),
            ], 400);
        }

        if ($pairingId <= 0) {
            $tournament = self::get_tournament($tournamentId);
            if (!$tournament) {
                return new \WP_REST_Response(['success' => false, 'message' => __('Tournament not found.', 'chess-podium')], 400);
            }
            $round = $roundNo > 0 ? $roundNo : (int) $tournament->current_round;
            if ($round <= 0) {
                return new \WP_REST_Response(['success' => false, 'message' => __('No round in progress.', 'chess-podium')], 400);
            }
            $pairings = self::get_pairings_for_round($tournamentId, $round);
            $idx = $boardNo - 1;
            if ($idx < 0 || !isset($pairings[$idx])) {
                return new \WP_REST_Response(['success' => false, 'message' => __('Invalid board number.', 'chess-podium')], 400);
            }
            $p = $pairings[$idx];
            if ((int) $p->is_bye === 1) {
                return new \WP_REST_Response(['success' => false, 'message' => __('Board is a bye.', 'chess-podium')], 400);
            }
            $pairingId = (int) $p->id;
        }

        global $wpdb;
        $table = self::db_table('regina_pairings');
        $pairing = $wpdb->get_row($wpdb->prepare("SELECT id, tournament_id, is_bye FROM $table WHERE id = %d", $pairingId));
        if (!$pairing || (int) $pairing->is_bye === 1) {
            return new \WP_REST_Response(['success' => false, 'message' => __('Invalid pairing or bye.', 'chess-podium')], 400);
        }
        if ($tournamentId <= 0) {
            $tournamentId = (int) $pairing->tournament_id;
        }

        $updated = $wpdb->update(
            $table,
            [
                'is_live' => 1,
                'live_pgn' => $pgn !== '' ? $pgn : null,
                'live_fen' => $fen !== '' ? $fen : null,
            ],
            ['id' => $pairingId],
            ['%d', '%s', '%s'],
            ['%d']
        );

        // Persist chosen DGT boards across rounds: once a physical board is used for live,
        // remember its board number for this tournament and auto-enable it next rounds.
        if ($tournamentId > 0) {
            $persistBoardNo = $boardNo;
            if ($persistBoardNo <= 0) {
                $persistBoardNo = self::get_board_number_for_pairing($tournamentId, $pairingId);
            }
            if ($persistBoardNo > 0) {
                self::remember_persistent_live_board($tournamentId, $persistBoardNo);
            }
        }

        return new \WP_REST_Response([
            'success' => $updated !== false,
            'pairing_id' => $pairingId,
        ], 200);
    }

    private static function remember_persistent_live_board(int $tournamentId, int $boardNo): void
    {
        if ($tournamentId <= 0 || $boardNo <= 0) {
            return;
        }
        if (!class_exists('ChessPodium_PdfBrochure')) {
            return;
        }
        $key = 'persistent_live_boards';
        $raw = ChessPodium_PdfBrochure::get_meta($tournamentId, $key);
        $boards = [];
        if (is_string($raw) && $raw !== '') {
            $decoded = json_decode($raw, true);
            if (is_array($decoded)) {
                $boards = $decoded;
            }
        }
        $boards[] = $boardNo;
        $boards = array_values(array_unique(array_map('intval', array_filter($boards, static fn ($v) => (int) $v > 0))));
        sort($boards);
        ChessPodium_PdfBrochure::set_meta($tournamentId, $key, wp_json_encode($boards));
    }

    private static function get_persistent_live_boards(int $tournamentId): array
    {
        if ($tournamentId <= 0 || !class_exists('ChessPodium_PdfBrochure')) {
            return [];
        }
        $raw = ChessPodium_PdfBrochure::get_meta($tournamentId, 'persistent_live_boards');
        if (!is_string($raw) || $raw === '') {
            return [];
        }
        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return [];
        }
        $boards = array_values(array_unique(array_map('intval', array_filter($decoded, static fn ($v) => (int) $v > 0))));
        sort($boards);
        return $boards;
    }

    private static function get_board_number_for_pairing(int $tournamentId, int $pairingId): int
    {
        if ($tournamentId <= 0 || $pairingId <= 0) {
            return 0;
        }
        global $wpdb;
        $table = self::db_table('regina_pairings');
        $row = $wpdb->get_row($wpdb->prepare("SELECT id, round_no FROM $table WHERE id = %d AND tournament_id = %d", $pairingId, $tournamentId));
        if (!$row) {
            return 0;
        }
        $round = (int) ($row->round_no ?? 0);
        if ($round <= 0) {
            return 0;
        }
        $pairings = self::get_pairings_for_round($tournamentId, $round);
        foreach ($pairings as $i => $p) {
            if ((int) ($p->id ?? 0) === $pairingId) {
                return $i + 1;
            }
        }
        return 0;
    }

    public static function rest_live_moves(\WP_REST_Request $request): \WP_REST_Response
    {
        nocache_headers();
        $tournamentId = (int) $request->get_param('tournament_id');
        $pairingId = (int) $request->get_param('pairing_id');

        global $wpdb;
        $table = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');

        if ($pairingId > 0) {
            $row = $wpdb->get_row($wpdb->prepare(
                "SELECT p.id, p.tournament_id, p.round_no, p.is_live, p.live_pgn, p.live_fen, p.result,
                wp.name AS white_name, bp.name AS black_name
                FROM $table p
                LEFT JOIN $players wp ON p.white_player_id = wp.id
                LEFT JOIN $players bp ON p.black_player_id = bp.id
                WHERE p.id = %d AND ((p.is_live = 1 AND (p.result IS NULL OR p.result = '')) OR (p.live_pgn IS NOT NULL AND p.live_pgn != ''))",
                $pairingId
            ));
            $items = $row ? [self::format_live_move_row($row)] : [];
        } else {
            $tid = $tournamentId > 0 ? $tournamentId : 0;
            if ($tid <= 0) {
                $tournament = self::get_tournament_in_progress();
                $tid = $tournament ? (int) $tournament->id : 0;
            }
            if ($tid <= 0) {
                return new \WP_REST_Response(['live_moves' => []], 200);
            }
            $tournament = self::get_tournament($tid);
            $currentRound = $tournament ? (int) $tournament->current_round : 0;
            if ($currentRound <= 0) {
                return new \WP_REST_Response(['live_moves' => []], 200);
            }
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT p.id, p.tournament_id, p.round_no, p.is_live, p.live_pgn, p.live_fen, p.result,
                wp.name AS white_name, bp.name AS black_name
                FROM $table p
                LEFT JOIN $players wp ON p.white_player_id = wp.id
                LEFT JOIN $players bp ON p.black_player_id = bp.id
                WHERE p.tournament_id = %d AND p.round_no = %d AND p.is_bye = 0
                AND ((p.is_live = 1 AND (p.result IS NULL OR p.result = '')) OR (p.live_pgn IS NOT NULL AND p.live_pgn != ''))
                ORDER BY p.is_live DESC, p.id ASC",
                $tid,
                $currentRound
            ));
            $items = array_map([self::class, 'format_live_move_row'], $rows ?: []);
        }

        $response = new \WP_REST_Response(['live_moves' => $items], 200);
        $response->header('Cache-Control', 'no-cache, no-store, must-revalidate, max-age=0');
        $response->header('Pragma', 'no-cache');
        $response->header('Expires', '0');
        $response->header('X-Accel-Expires', '0');
        $response->header('X-Cache-Status', 'BYPASS');
        return $response;
    }

    private static function format_live_move_row(object $row): array
    {
        return [
            'pairing_id' => (int) $row->id,
            'tournament_id' => (int) $row->tournament_id,
            'round_no' => (int) $row->round_no,
            'is_live' => (int) ($row->is_live ?? 0) === 1,
            'pgn' => (string) ($row->live_pgn ?? ''),
            'fen' => (string) ($row->live_fen ?? 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'),
            'result' => (string) ($row->result ?? ''),
            'white_name' => (string) ($row->white_name ?? ''),
            'black_name' => (string) ($row->black_name ?? ''),
        ];
    }

    public static function parse_game_request(object $wp): void
    {
        $path = isset($wp->request) ? trim((string) $wp->request, '/') : '';
        if ($path === '' && isset($_SERVER['REQUEST_URI'])) {
            $parsed = parse_url(wp_unslash($_SERVER['REQUEST_URI']), PHP_URL_PATH);
            $path = $parsed ? trim(trim((string) $parsed), '/') : '';
            $home = trim(parse_url(home_url('/'), PHP_URL_PATH), '/');
            if ($home !== '' && strpos($path, $home) === 0) {
                $path = trim(substr($path, strlen($home)), '/');
            }
        }
        if ($path === '') {
            return;
        }

        // Some sites switch slugs by locale (e.g. game -> partita). If rewrite rules were flushed under a
        // different locale, pretty URLs can fail to match and fall back to the theme's index ("No content").
        // Parse the path directly so routes keep working even when a ?lang= switch changes locale mid-request.
        $mainSlug = sanitize_title((string) apply_filters('chess_podium_tournament_slug', 'torneo')) ?: 'torneo';
        $gameSlug = sanitize_title((string) apply_filters('chess_podium_game_slug', 'game')) ?: 'game';

        $mainCandidates = array_values(array_unique(array_filter([
            $mainSlug,
            'torneo',
            'tournament',
        ])));
        $gameCandidates = array_values(array_unique(array_filter([
            $gameSlug,
            'game',
            'partita',
        ])));

        // Numeric game URL: /{main}/{game}/{pairingId}/
        foreach ($mainCandidates as $ms) {
            foreach ($gameCandidates as $gs) {
                $pattern = '#^' . preg_quote($ms, '#') . '/' . preg_quote($gs, '#') . '/([0-9]+)/?$#';
                if (preg_match($pattern, $path, $m)) {
                    $wp->query_vars['chess_podium_game'] = 1;
                    $wp->query_vars['chess_podium_pairing_id'] = (int) $m[1];
                    return;
                }
            }
        }

        // Pretty game URL (SEO): /{main}/{tournamentSeoSlug}/{game}/{pairingSeoSlug}/
        foreach ($mainCandidates as $ms) {
            foreach ($gameCandidates as $gs) {
                $pattern = '#^' . preg_quote($ms, '#') . '/([^/]+)/' . preg_quote($gs, '#') . '/([^/]+)/?$#';
                if (preg_match($pattern, $path, $m)) {
                    $wp->query_vars['chess_podium_game'] = 1;
                    $wp->query_vars['tournament_slug'] = sanitize_title((string) $m[1]);
                    $wp->query_vars['chess_podium_pairing_slug'] = sanitize_title((string) $m[2]);
                    return;
                }
            }
        }

        // Player profile (SEO): /{main}/{tournamentSeoSlug}/player/{playerSeoSlug}/
        foreach ($mainCandidates as $ms) {
            $pattern = '#^' . preg_quote($ms, '#') . '/([^/]+)/player/([^/]+)/?$#';
            if (preg_match($pattern, $path, $m)) {
                $wp->query_vars['chess_podium_torneo_page'] = 1;
                $wp->query_vars['tournament_slug'] = sanitize_title((string) $m[1]);
                $wp->query_vars['cp_player_slug'] = sanitize_title((string) $m[2]);
                return;
            }
        }

        // Tournament round (pretty): /{main}/{tournamentSeoSlug}/round/{roundNo}/
        // Tournament round (numeric): /{main}/{tournamentId}/round/{roundNo}/
        $roundCandidates = array_values(array_unique(array_filter([
            'round',
            'turno',
        ])));
        foreach ($mainCandidates as $ms) {
            foreach ($roundCandidates as $rs) {
                $pattern = '#^' . preg_quote($ms, '#') . '/([^/]+)/' . preg_quote($rs, '#') . '/([0-9]+)/?$#';
                if (preg_match($pattern, $path, $m)) {
                    $wp->query_vars['chess_podium_torneo_page'] = 1;
                    $slugOrId = (string) $m[1];
                    if (ctype_digit($slugOrId)) {
                        $wp->query_vars['tournament_id'] = (int) $slugOrId;
                    } else {
                        $wp->query_vars['tournament_slug'] = sanitize_title($slugOrId);
                    }
                    $wp->query_vars['round_no'] = max(0, (int) $m[2]);
                    return;
                }
            }
        }
    }

    public static function handle_public_route(): void
    {
        if ((int) get_query_var('chess_podium_player_page') === 1) {
            $slug = sanitize_title((string) get_query_var('cp_player_global_slug'));
            if ($slug === '' && isset($_GET['cp_player_global_slug'])) {
                $slug = sanitize_title((string) wp_unslash($_GET['cp_player_global_slug']));
            }
            self::render_unified_player_profile_page($slug);
            exit;
        }
        // WordPress may match a Page at /player/{slug}/ so rewrite query vars are never set; recover from the path.
        $pathSlug = self::detect_global_player_slug_from_request_uri();
        if ($pathSlug !== '' && class_exists('ChessPodium_PlayerDirectory') && ChessPodium_PlayerDirectory::get_by_slug($pathSlug)) {
            self::render_unified_player_profile_page($pathSlug);
            exit;
        }
        if ((int) get_query_var('chess_podium_live') === 1) {
            self::render_live_dashboard();
            exit;
        }

        $pairingId = (int) get_query_var('chess_podium_pairing_id');
        $pairingSlug = sanitize_title((string) get_query_var('chess_podium_pairing_slug'));
        if ((int) get_query_var('chess_podium_game') === 1 && ($pairingId > 0 || $pairingSlug !== '')) {
            if ($pairingId <= 0 && $pairingSlug !== '') {
                $tidFromSlug = 0;
                $tSlug = sanitize_title((string) get_query_var('tournament_slug'));
                if ($tSlug !== '') {
                    $tidFromSlug = self::find_tournament_id_by_seo_slug($tSlug);
                }
                if ($tidFromSlug > 0) {
                    $pairingId = self::find_pairing_id_by_seo_slug($tidFromSlug, $pairingSlug);
                }
            }
            if ($pairingId <= 0) {
                status_header(404);
                echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '"><title>' . esc_html__('Game not found', 'chess-podium') . '</title></head><body><h1>' . esc_html__('Game not found', 'chess-podium') . '</h1></body></html>';
                exit;
            }
            self::render_game_page($pairingId);
            exit;
        }

        if ((int) get_query_var('chess_podium_torneo_page') !== 1) {
            return;
        }

        // With "pretty" URLs, WordPress provides these as query vars (not $_GET).
        $tidFromUrl = max(0, (int) get_query_var('tournament_id'));
        if ($tidFromUrl <= 0 && isset($_GET['tournament_id'])) {
            $tidFromUrl = max(0, (int) $_GET['tournament_id']);
        }
        $slugFromUrl = sanitize_title((string) get_query_var('tournament_slug'));
        if ($slugFromUrl === '' && isset($_GET['tournament_slug'])) {
            $slugFromUrl = sanitize_title((string) wp_unslash($_GET['tournament_slug']));
        }
        if ($tidFromUrl <= 0 && $slugFromUrl !== '') {
            $tidFromUrl = self::find_tournament_id_by_seo_slug($slugFromUrl);
        }
        $tournament = $tidFromUrl > 0 ? self::get_tournament($tidFromUrl) : self::get_tournament_in_progress();
        $roundOverride = max(0, (int) get_query_var('round_no'));
        if ($roundOverride <= 0 && isset($_GET['round_no'])) {
            $roundOverride = max(0, (int) $_GET['round_no']);
        }
        if ($tournament && $roundOverride > 0) {
            $roundOverride = min($roundOverride, (int) $tournament->current_round);
        }
        $profilePlayerIdForTitle = max(0, (int) get_query_var('cp_player'));
        if ($profilePlayerIdForTitle <= 0 && isset($_GET['cp_player'])) {
            $profilePlayerIdForTitle = max(0, (int) $_GET['cp_player']);
        }
        $playerSlugForTitle = sanitize_title((string) get_query_var('cp_player_slug'));
        if ($playerSlugForTitle === '' && isset($_GET['cp_player_slug'])) {
            $playerSlugForTitle = sanitize_title((string) wp_unslash($_GET['cp_player_slug']));
        }
        if ($tournament && $profilePlayerIdForTitle <= 0 && $playerSlugForTitle !== '') {
            $profilePlayerIdForTitle = self::find_player_id_by_seo_slug((int) $tournament->id, $playerSlugForTitle);
        }
        $playerNameForTitle = '';
        if ($tournament && $profilePlayerIdForTitle > 0) {
            foreach (self::get_players((int) $tournament->id) as $p) {
                if ((int) ($p->id ?? 0) === $profilePlayerIdForTitle) {
                    $playerNameForTitle = (string) $p->name;
                    break;
                }
            }
        }
        status_header(200);
        nocache_headers();
        ?>
        <!doctype html>
        <html <?php language_attributes(); ?>>
        <head>
            <meta charset="<?php bloginfo('charset'); ?>">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title><?php
            if ($playerNameForTitle !== '' && $tournament) {
                echo esc_html($playerNameForTitle . ' – ' . $tournament->name . ' – ' . get_bloginfo('name'));
            } else {
                echo esc_html(get_bloginfo('name') . ' - ' . ($tournament ? $tournament->name : __('Current tournament', 'chess-podium')));
            }
            ?></title>
            <?php
            if ($tournament && class_exists('ChessPodium_TournamentSeo')) {
                $slug = apply_filters('chess_podium_tournament_slug', 'torneo');
                $slug = sanitize_title($slug) ?: 'torneo';
                $seoSlug = self::get_tournament_seo_slug($tournament);
                $tournamentUrl = isset($tournament->id)
                    ? ($roundOverride > 0
                        ? ($seoSlug !== '' ? self::get_tournament_round_pretty_slug_url($seoSlug, (int) $roundOverride) : self::get_tournament_round_pretty_url((int) $tournament->id, (int) $roundOverride))
                        : ($seoSlug !== '' ? self::get_tournament_pretty_slug_url($seoSlug) : self::get_tournament_pretty_url((int) $tournament->id)))
                    : home_url('/' . $slug . '/');
                if ($profilePlayerIdForTitle > 0 && $seoSlug !== '') {
                    $pObj = null;
                    foreach (self::get_players((int) $tournament->id) as $p) {
                        if ((int) ($p->id ?? 0) === $profilePlayerIdForTitle) {
                            $pObj = $p;
                            break;
                        }
                    }
                    $pSeo = $pObj ? self::get_player_seo_slug($pObj) : '';
                    if ($pSeo !== '') {
                        $tournamentUrl = home_url('/' . $slug . '/' . $seoSlug . '/player/' . $pSeo . '/');
                    }
                }
                $pageTitle = $playerNameForTitle !== ''
                    ? $playerNameForTitle . ' – ' . (string) $tournament->name . ' – ' . get_bloginfo('name')
                    : (string) $tournament->name . ' - ' . get_bloginfo('name');
                $playerCount = count(self::get_players((int) $tournament->id));
                ChessPodium_TournamentSeo::output_meta_tags($tournament, $tournamentUrl, $pageTitle, $playerCount);
            }
            ?>
            <?php wp_head(); ?>
        </head>
        <body <?php body_class('cp-tournament-route'); ?>>
        <div class="wrap cp-tournament-wrap">
            <?php if (!$tournament): ?>
                <div class="cp-no-tournament">
                    <h1><?php echo esc_html__('No tournament in progress', 'chess-podium'); ?></h1>
                    <p class="muted"><?php echo esc_html__('There is no tournament currently being played. Check back later or contact the tournament director.', 'chess-podium'); ?></p>
                </div>
            <?php else: ?>
                <?php echo self::render_public_block($tournament, $roundOverride); // phpcs:ignore ?>
            <?php endif; ?>
        </div>
        <?php wp_footer(); ?>
        </body>
        </html>
        <?php
        exit;
    }

    private static function render_game_page(int $pairingId): void
    {
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT p.*, wp.name AS white_name, wp.country AS white_country, wp.title AS white_title,
            bp.name AS black_name, bp.country AS black_country, bp.title AS black_title
            FROM $pairings p
            LEFT JOIN $players wp ON p.white_player_id = wp.id
            LEFT JOIN $players bp ON p.black_player_id = bp.id
            WHERE p.id = %d AND p.is_bye = 0",
            $pairingId
        ));
        if (!$row) {
            status_header(404);
            echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '"><title>' . esc_html__('Game not found', 'chess-podium') . '</title></head><body><h1>' . esc_html__('Game not found', 'chess-podium') . '</h1></body></html>';
            exit;
        }
        $tid = (int) $row->tournament_id;
        $tournament = self::get_tournament($tid);
        $tournamentUrl = $tournament ? self::get_tournament_pretty_url($tid) : home_url('/');
        $livePairings = self::get_live_pairings_for_tournament($tid);
        $prevGameUrl = null;
        $nextGameUrl = null;
        $prevGameLabel = '';
        $nextGameLabel = '';
        foreach ($livePairings as $i => $lp) {
            if ((int) $lp->id === $pairingId) {
                if ($i > 0) {
                    $prev = $livePairings[$i - 1];
                    // Pairing ID already identifies the tournament; keep the URL clean.
                    $prevGameUrl = self::get_game_url((int) $prev->id, $tid);
                    $prevGameLabel = sprintf(__('Round %d', 'chess-podium'), (int) $prev->round_no) . ': ' . ($prev->white_name ?? '') . ' - ' . ($prev->black_name ?? '');
                }
                if ($i < count($livePairings) - 1) {
                    $next = $livePairings[$i + 1];
                    $nextGameUrl = self::get_game_url((int) $next->id, $tid);
                    $nextGameLabel = sprintf(__('Round %d', 'chess-podium'), (int) $next->round_no) . ': ' . ($next->white_name ?? '') . ' - ' . ($next->black_name ?? '');
                }
                break;
            }
        }
        $tournamentName = $tournament ? $tournament->name : __('Tournament', 'chess-podium');
        $white = $row->white_name ?? '';
        $black = $row->black_name ?? '';
        $round = (int) $row->round_no;
        $result = trim((string) ($row->result ?? ''));
        $pgn = !empty($row->live_pgn) ? $row->live_pgn : '';
        $fen = !empty($row->live_fen) ? $row->live_fen : 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
        $base = plugins_url('', CHESS_PODIUM_PLUGIN_FILE);
        $defaultPieceTheme = $base . '/assets/img/chesspieces/wikipedia/{piece}.png';
        $pieceTheme = apply_filters('chess_podium_piece_theme', $defaultPieceTheme);
        wp_enqueue_script('jquery');
        wp_enqueue_script('chess-podium-chess', $base . '/assets/js/vendor/chess.min.js', [], '1.0.0', true);
        wp_enqueue_style('chess-podium-chessground-base', 'https://unpkg.com/chessground@9.2.1/assets/chessground.base.css', [], '9.2.1');
        // Board theme (squares + highlights). Without this, the board can appear "invisible" on dark backgrounds.
        wp_enqueue_style('chess-podium-chessground-board', 'https://unpkg.com/chessground@9.2.1/assets/chessground.brown.css', [], '9.2.1');
        wp_enqueue_style('chess-podium-chessground-theme', 'https://unpkg.com/chessground@9.2.1/assets/chessground.cburnett.css', [], '9.2.1');
        $game_page_css = '* { box-sizing: border-box; } body { margin: 0; padding: 1rem; font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column; align-items: center; } .cp-game-wrap { max-width: 900px; width: 100%; } .cp-game-back { margin-bottom: 1rem; } .cp-game-back a { color: #58a6ff; text-decoration: none; font-size: 0.95rem; } .cp-game-back a:hover { text-decoration: underline; } .cp-game-header { text-align: center; margin-bottom: 1rem; font-size: 1.1rem; } .cp-game-title { font-weight: 600; margin-bottom: 0.5rem; } .cp-game-result { color: #22c55e; font-weight: 700; } .cp-game-nav { display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; } .cp-game-nav button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; background: #30363d; border: 1px solid #484f58; border-radius: 6px; color: #e6edf3; } .cp-game-nav button:hover { background: #484f58; } .cp-game-info { min-width: 5rem; text-align: center; font-size: 0.95rem; color: #8b949e; } #cp-game-board { width: min(92vw, 720px); height: min(92vw, 720px); min-width: 0; min-height: 0; margin: 0 auto; position: relative; overflow: hidden; background: #f0d9b5; touch-action: none; } #cp-game-board * { touch-action: none; } .cp-game-moves { margin-top: 1rem; padding: 1rem; background: #161b22; border-radius: 8px; max-height: 200px; overflow-y: auto; font-size: 0.9rem; line-height: 1.8; } .cp-game-variation { margin-top: 0.6rem; padding: 0.85rem 1rem; background: #0b1220; border: 1px dashed #30363d; border-radius: 8px; font-size: 0.9rem; line-height: 1.7; color: #e6edf3; } .cp-game-variation strong { color: #93c5fd; } .cp-game-move { cursor: pointer; padding: 0.1em 0.3em; border-radius: 3px; display: inline-block; margin-right: 0.2em; } .cp-game-move:hover { background: #30363d; } .cp-game-move-current { background: #238636; color: #fff; } .cp-game-other { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #30363d; display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; } .cp-game-other a { color: #58a6ff; text-decoration: none; font-size: 0.9rem; padding: 0.4rem 0.8rem; background: #21262d; border-radius: 6px; border: 1px solid #30363d; } .cp-game-other a:hover { background: #30363d; text-decoration: underline; } .cp-game-analysis-layout { display: flex; align-items: flex-start; gap: 1.25rem; justify-content: center; margin-top: 0.5rem; } .cp-eval-bar-wrap { width: 34px; flex: 0 0 34px; } .cp-eval-bar { position: relative; width: 34px; height: 720px; max-height: min(92vw, 720px); background: #21262d; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; } .cp-eval-fill { position: absolute; left: 0; width: 100%; } .cp-eval-fill--white { top: 0; background: rgba(34, 197, 94, 0.92); height: 50%; } .cp-eval-fill--black { bottom: 0; background: rgba(239, 68, 68, 0.92); height: 50%; } .cp-game-analysis-right { display: flex; flex-direction: column; gap: 0.75rem; min-width: 320px; } .cp-analysis-panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 0.95rem 1rem; color: #e6edf3; max-width: 720px; } .cp-analysis-row { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: 0.55rem; font-size: 0.95rem; } .cp-analysis-row span:first-child { color: #8b949e; } .cp-analysis-free { width: 100%; padding: 0.6rem 0.75rem; cursor: pointer; background: #15803d; color: #fff; border: none; border-radius: 8px; font-weight: 800; margin-bottom: 0.75rem; } .cp-analysis-free:hover { background: #166534; } .cp-analysis-back-live { display: none; width: 100%; padding: 0.6rem 0.75rem; cursor: pointer; background: #2563eb; color: #fff; border: none; border-radius: 8px; font-weight: 800; } .cp-analysis-back-live:hover { background: #1d4ed8; } .cp-analysis-toggle-eval { width: 100%; padding: 0.6rem 0.75rem; cursor: pointer; background: #6d28d9; color: #fff; border: none; border-radius: 8px; font-weight: 800; margin-bottom: 0.75rem; } .cp-analysis-toggle-eval:hover { background: #5b21b6; } .cp-analysis-lines { margin: 0.25rem 0 0; background: #0b1220; border: 1px solid #30363d; border-radius: 8px; padding: 0.75rem; color: #e6edf3; max-height: 240px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace; font-size: 0.86rem; line-height: 1.55; } .cp-analysis-line { margin-bottom: 0.25rem; } .cp-analysis-line em { font-style: normal; color: #8b949e; margin-right: 0.35rem; } @media (max-width: 560px) { .cp-game-analysis-layout { flex-direction: column; gap: 0.75rem; } .cp-eval-bar-wrap { width: 100%; flex-basis: auto; } .cp-eval-bar { width: 100%; height: 18px; max-height: none; border-radius: 8px; } #cp-game-board { width: min(94vw, 560px); height: min(94vw, 560px); } .cp-game-analysis-right { min-width: 0; } .cp-analysis-panel { max-width: none; } .cp-analysis-toggle-eval { margin-bottom: 0.6rem; } } ';
        wp_register_style('chess-podium-game-page', false, []);
        wp_enqueue_style('chess-podium-game-page');
        wp_add_inline_style('chess-podium-game-page', $game_page_css);
        // Engine assets:
        // - lite-single (WASM) is fast but may require CSP "wasm-unsafe-eval" on some hosts.
        // - asm.js is slower but works even without COOP/COEP (no cross-origin isolation) and without WASM.
        $stockfishWorkerUrlWasm = $base . '/assets/js/vendor/stockfish/stockfish-18-lite-single.js';
        $stockfishWasmUrl = $base . '/assets/js/vendor/stockfish/stockfish-18-lite-single.wasm';
        $stockfishWorkerUrlAsm = $base . '/assets/js/vendor/stockfish/stockfish-18-asm.js';
        ob_start();
        ?>
        
        (function () {
            if (window.__cp_analysis_patched) return;
            window.__cp_analysis_patched = true;

            window.cp_analysis_mode = false;
            window.cp_current_fen = null;
            window.cp_stockfish_worker_url_wasm = <?php echo wp_json_encode($stockfishWorkerUrlWasm); ?>;
            window.cp_stockfish_worker_url_asm = <?php echo wp_json_encode($stockfishWorkerUrlAsm); ?>;
            window.cp_stockfish_wasm_url = <?php echo wp_json_encode($stockfishWasmUrl); ?>;

            var cpLiveUrl = document.body ? document.body.getAttribute('data-live-moves-url') : null;
            var cpOriginalFetch = window.fetch ? window.fetch.bind(window) : null;
            var cpOriginalChessBoard = window.ChessBoard;

            var cpBoardInstance = null;
            var cpSandboxChess = null;

            var cpEngine = null;
            var cpEngineReady = false;
            var cpLastCp = null;
            var cpLastBestMove = '';
            var cpPendingFen = null;

            var cpUi = {};
            var cpArrowSvg = null;
            var cpArrowNs = 'http://www.w3.org/2000/svg';

            function cpQ(id) { return document.getElementById(id); }
            function cpCacheUi() {
                cpUi.backBtn = cpQ('cp-analysis-back-live');
                cpUi.topMoveEl = cpQ('cp-analysis-top-move');
                cpUi.evalEl = cpQ('cp-analysis-eval');
                cpUi.whiteFill = cpQ('cp-eval-white-fill');
                cpUi.blackFill = cpQ('cp-eval-black-fill');
            }

            function cpSetUiAnalysis(on) {
                cpCacheUi();
                if (cpUi.backBtn) cpUi.backBtn.style.display = on ? 'block' : 'none';
            }

            function cpEvalBarSet(cp) {
                cpCacheUi();
                if (!cpUi.whiteFill || !cpUi.blackFill || typeof cp !== 'number' || !isFinite(cp)) return;
                var maxCp = 2000;
                var clamped = Math.max(-maxCp, Math.min(maxCp, cp));
                var whitePct = (clamped + maxCp) / (2 * maxCp);
                cpUi.whiteFill.style.height = (whitePct * 100).toFixed(2) + '%';
                cpUi.blackFill.style.height = ((1 - whitePct) * 100).toFixed(2) + '%';
            }

            function cpPawnsText(cp) {
                if (typeof cp !== 'number' || !isFinite(cp)) return '—';
                var pawns = cp / 100;
                var sign = pawns > 0 ? '+' : '';
                return sign + pawns.toFixed(1);
            }

            /** UCI e2e4 -> SAN e4 (requires window.cp_current_fen before the move). */
            function cpUciToSanLabel(uci) {
                if (!uci || uci.length < 4 || typeof Chess === 'undefined') return uci || '';
                var fen = window.cp_current_fen;
                if (!fen || fen.indexOf('/') < 0) return uci;
                var parts = fen.trim().split(/\s+/);
                if (parts.length === 4) fen = parts.concat(['0', '1']).join(' ');
                else if (parts.length === 5) fen = parts.concat(['1']).join(' ');
                var ch, mv;
                try {
                    ch = new Chess(fen);
                    var from = uci.substring(0, 2);
                    var to = uci.substring(2, 4);
                    var promo = uci.length > 4 ? uci[4] : undefined;
                    mv = promo ? ch.move({ from: from, to: to, promotion: promo }) : ch.move({ from: from, to: to });
                } catch (e) {
                    mv = null;
                }
                return (mv && mv.san) ? mv.san : uci;
            }

            function cpUpdatePanel(bestMoveUci, cp) {
                cpCacheUi();
                var label = bestMoveUci ? cpUciToSanLabel(bestMoveUci) : '';
                if (cpUi.topMoveEl) cpUi.topMoveEl.textContent = label || '—';
                if (cpUi.evalEl) cpUi.evalEl.textContent = cpPawnsText(cp);
            }

            function cpEnsureArrowLayer() {
                if (!cpBoardInstance) return;
                var boardEl = cpQ('cp-game-board');
                if (!boardEl) return;
                if (cpArrowSvg) return;
                boardEl.style.position = boardEl.style.position || 'relative';
                cpArrowSvg = document.createElementNS(cpArrowNs, 'svg');
                cpArrowSvg.setAttribute('style', 'position:absolute;inset:0;pointer-events:none;');
                boardEl.appendChild(cpArrowSvg);
            }

            function cpSquareCenter(square) {
                var boardEl = cpQ('cp-game-board');
                if (!boardEl) return { x: 0, y: 0 };
                var rect = boardEl.getBoundingClientRect();
                var size = Math.min(rect.width, rect.height) / 8;
                var file = square.charCodeAt(0) - 97;
                var rank = parseInt(square[1], 10) - 1;
                return { x: (file + 0.5) * size, y: (7 - rank + 0.5) * size };
            }

            function cpDrawArrow(uci) {
                cpEnsureArrowLayer();
                if (!cpArrowSvg) return;
                var boardEl = cpQ('cp-game-board');
                if (!boardEl) return;

                var rect = boardEl.getBoundingClientRect();
                cpArrowSvg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
                while (cpArrowSvg.firstChild) cpArrowSvg.removeChild(cpArrowSvg.firstChild);
                if (!uci || uci.length < 4) return;

                var fromSq = uci.substring(0, 2);
                var toSq = uci.substring(2, 4);
                var a = cpSquareCenter(fromSq);
                var b = cpSquareCenter(toSq);

                var isWhiteAdv = (typeof cpLastCp === 'number' ? cpLastCp >= 0 : true);
                var color = isWhiteAdv ? '#22c55e' : '#ef4444';

                var defs = document.createElementNS(cpArrowNs, 'defs');
                var marker = document.createElementNS(cpArrowNs, 'marker');
                marker.setAttribute('id', 'cp-sf-arrowhead');
                marker.setAttribute('markerWidth', '10');
                marker.setAttribute('markerHeight', '10');
                marker.setAttribute('refX', '7');
                marker.setAttribute('refY', '3.5');
                marker.setAttribute('orient', 'auto');
                var path = document.createElementNS(cpArrowNs, 'path');
                path.setAttribute('d', 'M0,0 L0,7 L7,3.5 z');
                path.setAttribute('fill', color);
                marker.appendChild(path);
                defs.appendChild(marker);
                cpArrowSvg.appendChild(defs);

                var line = document.createElementNS(cpArrowNs, 'line');
                line.setAttribute('x1', a.x);
                line.setAttribute('y1', a.y);
                line.setAttribute('x2', b.x);
                line.setAttribute('y2', b.y);
                line.setAttribute('stroke', color);
                line.setAttribute('stroke-width', '4');
                line.setAttribute('stroke-linecap', 'round');
                line.setAttribute('marker-end', 'url(#cp-sf-arrowhead)');
                cpArrowSvg.appendChild(line);
            }

            function cpEnsureEngine() {
                if (cpEngine) return;
                const wasmUrl = window.cp_stockfish_wasm_url || '';
                const engine = new Worker((window.cp_stockfish_worker_url || '') + '#' + encodeURIComponent(wasmUrl) + ',worker');
                cpEngine = engine;
                engine.onmessage = function (e) {
                    var msg = e.data;
                    if (typeof msg !== 'string') return;

                    if (msg === 'uciok') {
                        cpEngineReady = true;
                        engine.postMessage('isready');
                        return;
                    }
                    if (msg.indexOf('readyok') === 0) {
                        cpEngineReady = true;
                        if (cpPendingFen) {
                            var f = cpPendingFen;
                            cpPendingFen = null;
                            cpUpdateEngineAnalysis(f);
                        }
                        return;
                    }

                    if (msg.indexOf('info ') === 0) {
                        var m = msg.match(/score cp (-?\d+)/);
                        if (m) {
                            cpLastCp = parseInt(m[1], 10);
                            cpEvalBarSet(cpLastCp);
                            cpUpdatePanel(cpLastBestMove, cpLastCp);
                        }
                        return;
                    }

                    if (msg.indexOf('bestmove ') === 0) {
                        var parts = msg.split(/\s+/);
                        cpLastBestMove = parts[1] || '';
                        if (cpLastBestMove === '(none)') cpLastBestMove = '';
                        cpUpdatePanel(cpLastBestMove, cpLastCp);
                        cpDrawArrow(cpLastBestMove);
                        return;
                    }
                };
                engine.postMessage('uci');
            }

            function cpUpdateEngineAnalysis(fen) {
                if (!fen) return;
                cpEnsureEngine();
                if (!cpEngineReady) {
                    cpPendingFen = fen;
                    return;
                }
                try { cpEngine.postMessage('stop'); } catch (e) {}
                try { cpEngine.postMessage('ucinewgame'); } catch (e) {}
                cpEngine.postMessage('position fen ' + fen);
                cpEngine.postMessage('go depth 15');
            }

            // Alias richiesto dalla specifica funzionale.
            function updateEngineAnalysis(fen) {
                cpUpdateEngineAnalysis(fen);
            }

            function cpSyncSandboxToFen(fen) {
                if (!fen) return;
                try { cpSandboxChess = new Chess(fen); } catch (e) { cpSandboxChess = null; }
            }

            function cpEnableAnalysisMode() {
                if (window.cp_analysis_mode) return;
                window.cp_analysis_mode = true;
                cpSetUiAnalysis(true);
                if (window.cp_current_fen) {
                    cpSyncSandboxToFen(window.cp_current_fen);
                    cpUpdateEngineAnalysis(window.cp_current_fen);
                }
            }

            function cpGoBackToLive() {
                window.cp_analysis_mode = false;
                cpSetUiAnalysis(false);
                cpLastBestMove = '';
                cpUpdatePanel('—', cpLastCp);
                if (cpArrowSvg) {
                    while (cpArrowSvg.firstChild) cpArrowSvg.removeChild(cpArrowSvg.firstChild);
                }

                if (!cpOriginalFetch || !cpLiveUrl) return;
                var u = cpLiveUrl + (cpLiveUrl.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now();
                cpOriginalFetch(u, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } })
                    .then(function (r) { return r.json(); })
                    .then(function (data) {
                        var list = (data && data.live_moves) ? data.live_moves : [];
                        if (!list.length) return;
                        var m = list[0];
                        if (m && m.fen && cpBoardInstance && typeof cpBoardInstance.position === 'function') {
                            window.cp_current_fen = m.fen;
                            try { cpBoardInstance.position(m.fen); } catch (e) {}
                        }
                    })
                    .catch(function () { });
            }

            function cpHandleSandboxDrop(source, targetSq) {
                if (!window.cp_analysis_mode) cpEnableAnalysisMode();
                if (!window.cp_current_fen) return false;
                if (!cpSandboxChess) cpSyncSandboxToFen(window.cp_current_fen);
                if (!cpSandboxChess) return false;

                var test = new Chess(cpSandboxChess.fen());
                var from = source;
                var to = targetSq;
                var promo = undefined;
                try {
                    var piece = test.get(from);
                    if (piece && piece.type === 'p' && (to[1] === '8' || to[1] === '1')) promo = 'q';
                } catch (e) {}

                var mv = promo ? test.move({ from: from, to: to, promotion: promo }) : test.move({ from: from, to: to });
                if (!mv) return false;

                cpSandboxChess = test;
                window.cp_current_fen = cpSandboxChess.fen();
                cpUpdateEngineAnalysis(window.cp_current_fen);
                return true;
            }

            // Short-circuit polling while analyzing and force board updates in live mode.
            if (cpOriginalFetch && cpLiveUrl) {
                window.fetch = function (input, init) {
                    var url = '';
                    if (typeof input === 'string') url = input;
                    else if (input && typeof input === 'object') url = input.url || '';

                    var isCpLive = url && url.indexOf(cpLiveUrl) === 0;
                    if (isCpLive && window.cp_analysis_mode) {
                        return Promise.resolve({ json: function () { return Promise.resolve({ live_moves: [] }); } });
                    }

                    return cpOriginalFetch(input, init).then(function (r) {
                        return r.json().then(function (data) {
                            if (isCpLive && !window.cp_analysis_mode) {
                                var list = (data && data.live_moves) ? data.live_moves : [];
                                if (list.length && list[0].fen && cpBoardInstance && typeof cpBoardInstance.position === 'function') {
                                    window.cp_current_fen = list[0].fen;
                                    try { cpBoardInstance.position(list[0].fen); } catch (e) {}
                                }
                            }
                            return { json: function () { return Promise.resolve(data); } };
                        });
                    });
                };
            }

            // Patch ChessBoard for drag/drop + capture fen updates.
            if (cpOriginalChessBoard && typeof cpOriginalChessBoard === 'function') {
                window.ChessBoard = function (target, config) {
                    config = config || {};
                    config.draggable = true;
                    // ChessBoard.js supports `position: 'start'`, but our sandbox expects a full FEN.
                    if (config.position === 'start') {
                        config.position = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
                    }

                    var originalOnDragStart = config.onDragStart;
                    var originalOnDrop = config.onDrop;

                    config.onDragStart = function () {
                        cpEnableAnalysisMode();
                        if (typeof originalOnDragStart === 'function') {
                            try { originalOnDragStart.apply(this, arguments); } catch (e) {}
                        }
                    };

                    config.onDrop = function (source, targetSq) {
                        var ok = cpHandleSandboxDrop(source, targetSq);
                        if (typeof originalOnDrop === 'function') {
                            try { originalOnDrop.apply(this, arguments); } catch (e) {}
                        }
                        // ChessBoard.js snaps back only when `onDrop` returns the string 'snapback'.
                        // Returning booleans can still result in inconsistent behavior across versions.
                        if (!ok) return 'snapback';
                        // Force visual sync immediately (important for the very first move).
                        try { if (cpBoardInstance && window.cp_current_fen) cpBoardInstance.position(window.cp_current_fen); } catch (e) {}
                        return;
                    };

                    var board = cpOriginalChessBoard(target, config);
                    cpBoardInstance = board;
                    if (config.position && typeof config.position === 'string') {
                        window.cp_current_fen = config.position;
                        if (window.cp_analysis_mode) {
                            cpSyncSandboxToFen(window.cp_current_fen);
                            cpUpdateEngineAnalysis(window.cp_current_fen);
                        }
                    }

                    if (board && typeof board.position === 'function') {
                        var originalPosition = board.position.bind(board);
                        board.position = function (newPos) {
                            if (typeof newPos === 'string' && newPos.indexOf('/') > 0) {
                                window.cp_current_fen = newPos;
                                if (window.cp_analysis_mode) {
                                    cpSyncSandboxToFen(newPos);
                                    cpUpdateEngineAnalysis(newPos);
                                }
                            }
                            return originalPosition(newPos);
                        };
                    }

                    setTimeout(function () { cpEnsureArrowLayer(); }, 0);
                    return board;
                };
            }

            document.addEventListener('DOMContentLoaded', function () {
                cpCacheUi();
                cpSetUiAnalysis(false);
                var first = document.getElementById('cp-game-first');
                var prev = document.getElementById('cp-game-prev');
                if (first) first.addEventListener('click', function () { cpEnableAnalysisMode(); }, true);
                if (prev) prev.addEventListener('click', function () { cpEnableAnalysisMode(); }, true);
                if (cpUi.backBtn) cpUi.backBtn.addEventListener('click', function () { cpGoBackToLive(); });
            });

            window.addEventListener('pagehide', function () {
                try { if (cpEngine) cpEngine.terminate(); } catch (e) {}
                cpEngine = null;
            });
        })();
        
        <?php
        $analysis_patch_script = ob_get_clean();
        $game_page_script = "(function(){ var pgn = " . wp_json_encode($pgn) . "; var fen = " . wp_json_encode($fen) . "; var pieceTheme = " . wp_json_encode($pieceTheme) . "; var board = null; var moves = []; var currentIdx = -1; var pollInterval = null; var liveUrl = null; var hasResult = false; var expectedPairingId = 0; 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,opts){ var o=opts||{}; return (c.load_pgn&&c.load_pgn(s,o))||(c.loadPgn&&c.loadPgn(s,o)); } function extractMovePart(s){ if(!s||typeof s!=='string')return s; var idx=s.search(/\\d+\\.\\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i); return idx>=0?s.substring(idx):s; } function getMoves(){ if(!pgn||typeof Chess==='undefined')return[]; try{ var c=new Chess(); var stripped=stripPgn(pgn); if(!loadPgn(c,stripped,{sloppy:true})){ var mp=extractMovePart(stripped); if(!mp||mp===stripped||!loadPgn(c,stripPgn(mp),{sloppy:true}))return[]; } var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; }catch(e){return[];} } function getFen(idx){ if(!pgn||typeof Chess==='undefined')return null; try{ var c=new Chess(); var stripped=stripPgn(pgn); if(!loadPgn(c,stripped,{sloppy:true})){ var mp=extractMovePart(stripped); if(mp&&mp!==stripped)loadPgn(c,stripPgn(mp),{sloppy:true}); } var h=c.history?c.history():[]; c.reset(); for(var i=0;i<=idx&&i<h.length;i++)c.move(h[i]); return c.fen(); }catch(e){return null;} } function fenFromPgn(s){ if(!s||typeof Chess==='undefined')return null; try{ var c=new Chess(); if(loadPgn(c,stripPgn(s),{sloppy:true}))return c.fen(); c=new Chess(); if(loadPgn(c,s,{sloppy:true}))return c.fen(); c=new Chess(); var idx=s.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(s.substring(idx)).replace(/\\s*(?:1-0|0-1|1\\/2-1\\/2|\\*)\\s*\\$/i,'').trim(); if(mp&&loadPgn(c,mp,{sloppy:true}))return c.fen(); } return null; }catch(e){return null;} } function poll(){ if(!liveUrl||hasResult)return; var u=liveUrl+(liveUrl.indexOf('?')>=0?'&':'?')+'t='+Date.now(); fetch(u,{cache:'no-store',headers:{'Cache-Control':'no-cache','Pragma':'no-cache'}}).then(function(r){return r.json();}).then(function(data){ try{ var list=data.live_moves||[]; if(list.length===0)return; var m=list[0]; if(expectedPairingId&&m.pairing_id&&m.pairing_id!==expectedPairingId)return; var newPgn=m.pgn||'', newFen=m.fen||null, newRes=(m.result||'').trim(); if(newRes){ hasResult=true; if(pollInterval){ clearInterval(pollInterval); pollInterval=null; } var resEl=document.querySelector('.cp-game-result'); if(!resEl){ var title=document.querySelector('.cp-game-title'); if(title){ var sp=document.createElement('span'); sp.className='cp-game-result'; sp.style.cssText='color:#22c55e;font-weight:700;'; sp.textContent=' '+newRes; title.appendChild(sp); } } else resEl.textContent=newRes; } var newMoves=getMovesFromPgn(newPgn); pgn=newPgn; fen=(newFen&&newFen!=='rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1')?newFen:(fenFromPgn(newPgn)||fen); moves=newMoves; var atLive=(currentIdx<0&&moves.length===0)||(moves.length>0&&currentIdx>=moves.length-1); if(board){ if(atLive){ currentIdx=moves.length>0?moves.length-1:-1; var f=moves.length>0?getFen(currentIdx):fen; if(f)try{board.position(f);}catch(e){} } updateNav(); renderMoves(); } }catch(e){} }).catch(function(){}); } function getMovesFromPgn(s){ if(!s||typeof Chess==='undefined')return[]; try{ var c=new Chess(),st=stripPgn(s); if(loadPgn(c,st,{sloppy:true})){ var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; } c=new Chess(); if(loadPgn(c,s,{sloppy:true})){ var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; } c=new Chess(); var idx=s.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(s.substring(idx)).replace(/\\s*(?:1-0|0-1|1\\/2-1\\/2|\\*)\\s*\\$/i,'').trim(); if(mp&&loadPgn(c,mp,{sloppy:true})){ var h=c.history?c.history():[]; return Array.isArray(h)?h:[]; } } return[]; }catch(e){return[];} } function init(){ var body=document.body; if(body){ liveUrl=body.getAttribute('data-live-moves-url'); hasResult=body.getAttribute('data-has-result')==='1'; var pid=body.getAttribute('data-pairing-id'); expectedPairingId=pid?parseInt(pid,10)|0:0; } if(typeof Chess==='undefined'||typeof ChessBoard==='undefined')return; moves=getMoves(); var startFen=(moves.length>0)?getFen(moves.length-1):fen; if(!startFen)startFen='rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; currentIdx=moves.length>0?moves.length-1:-1; board=ChessBoard('cp-game-board',{position:startFen,pieceTheme:pieceTheme,draggable:false}); if(board&&board.resize)setTimeout(function(){try{board.resize();}catch(e){}},50); updateNav(); renderMoves(); var first=document.getElementById('cp-game-first'),prev=document.getElementById('cp-game-prev'),next=document.getElementById('cp-game-next'),last=document.getElementById('cp-game-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(liveUrl&&!hasResult){ poll(); pollInterval=setInterval(poll,1500); } } function updateNav(){ var info=document.getElementById('cp-game-info'); if(!info)return; var txt=moves.length===0?'—':(currentIdx<0?'0':(Math.floor(currentIdx/2)+1)+(currentIdx%2===0?'.':'...')); if(moves.length>0&&currentIdx>=moves.length-1)txt=moves.length+' moves'; info.textContent=txt; } function showPos(idx){ if(!board)return; currentIdx=idx; var f=idx<0?'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1':getFen(idx); if(f)try{board.position(f);}catch(e){} updateNav(); renderMoves(); } function renderMoves(){ var el=document.getElementById('cp-game-moves'); if(!el)return; var html=''; for(var i=0;i<moves.length;i++){ var cls=(i===currentIdx)?' cp-game-move-current':''; html+='<span class=\"cp-game-move'+cls+'\" 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)); }; }); } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init); else init(); })();";
        wp_register_script('chess-podium-game-page', false, ['chess-podium-chess'], CHESS_PODIUM_VERSION, true);
        wp_enqueue_script('chess-podium-game-page');
        // The legacy inline script (chessboard-based) is kept for compatibility but
        // will early-return because ChessBoard is no longer enqueued.
        wp_add_inline_script('chess-podium-game-page', $game_page_script);
        status_header(200);
        nocache_headers();
        ?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo('charset'); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html(sprintf(__('%s vs %s - Round %d', 'chess-podium'), $white, $black, $round) . ' - ' . $tournamentName); ?></title>
    <meta name="description" content="<?php echo esc_attr(sprintf(__('Live Analysis: %s vs %s - Chess Podium', 'chess-podium'), $white, $black)); ?>">
    <?php wp_head(); ?>
</head>
<body class="cp-game-page" data-pairing-id="<?php echo (int) $pairingId; ?>" data-live-moves-url="<?php echo esc_attr(add_query_arg('pairing_id', $pairingId, rest_url('chess-podium/v1/live-moves'))); ?>" data-has-result="<?php echo esc_attr($result ? '1' : '0'); ?>">
    <!-- cp-game-build: 2026-03-30-r33 -->
    <div class="cp-game-wrap">
        <div class="cp-game-back"><a href="<?php echo esc_url($tournamentUrl); ?>">&larr; <?php echo esc_html__('Back to tournament', 'chess-podium'); ?></a></div>
        <div class="cp-game-header">
            <div class="cp-game-title"><?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), $round)); ?>: <?php echo esc_html($white); ?> vs <?php echo esc_html($black); ?><?php if ($result): ?> <span class="cp-game-result"><?php echo esc_html($result); ?></span><?php endif; ?></div>
            <div><?php echo esc_html($tournamentName); ?></div>
        </div>
        <div class="cp-game-nav">
            <button type="button" id="cp-game-first" aria-label="<?php esc_attr_e('Start', 'chess-podium'); ?>">«</button>
            <button type="button" id="cp-game-prev" aria-label="<?php esc_attr_e('Previous', 'chess-podium'); ?>">‹</button>
            <span class="cp-game-info" id="cp-game-info">—</span>
            <button type="button" id="cp-game-next" aria-label="<?php esc_attr_e('Next', 'chess-podium'); ?>">›</button>
            <button type="button" id="cp-game-last" aria-label="<?php esc_attr_e('End', 'chess-podium'); ?>">»</button>
        </div>
        <div class="cp-game-analysis-layout">
            <div class="cp-eval-bar-wrap">
                <div id="cp-eval-bar" class="cp-eval-bar" aria-label="<?php echo esc_attr__('Evaluation bar', 'chess-podium'); ?>">
                    <div id="cp-eval-white-fill" class="cp-eval-fill cp-eval-fill--white"></div>
                    <div id="cp-eval-black-fill" class="cp-eval-fill cp-eval-fill--black"></div>
                </div>
            </div>

            <div class="cp-game-analysis-right">
                <div id="cp-game-board"></div>

                <div class="cp-analysis-panel" id="cp-analysis-panel">
                    <div class="cp-analysis-row">
                        <span><?php echo esc_html__('Top Move', 'chess-podium'); ?></span>
                        <span id="cp-analysis-top-move">—</span>
                    </div>
                    <div class="cp-analysis-row">
                        <span><?php echo esc_html__('Evaluation', 'chess-podium'); ?></span>
                        <span id="cp-analysis-eval">—</span>
                    </div>
                    <button type="button" id="cp-analysis-toggle-eval" class="cp-analysis-toggle-eval">
                        <?php echo esc_html__('Start evaluation', 'chess-podium'); ?>
                    </button>
                    <div id="cp-analysis-lines" class="cp-analysis-lines" aria-live="polite"></div>
                    <button type="button" id="cp-analysis-back-live" class="cp-analysis-back-live">
                        <?php echo esc_html__('Sync with live', 'chess-podium'); ?>
                    </button>
                </div>
            </div>
        </div>

        <div class="cp-game-moves" id="cp-game-moves"></div>
        <div class="cp-game-variation" id="cp-game-variation" style="display:none"></div>
        <?php if ($prevGameUrl || $nextGameUrl): ?>
        <div class="cp-game-other">
            <?php if ($prevGameUrl): ?><a href="<?php echo esc_url($prevGameUrl); ?>" title="<?php echo esc_attr($prevGameLabel); ?>">&larr; <?php echo esc_html__('Previous game', 'chess-podium'); ?></a><?php endif; ?>
            <?php if ($nextGameUrl): ?><a href="<?php echo esc_url($nextGameUrl); ?>" title="<?php echo esc_attr($nextGameLabel); ?>"><?php echo esc_html__('Next game', 'chess-podium'); ?> &rarr;</a><?php endif; ?>
        </div>
        <?php endif; ?>
    </div>
    <?php wp_footer(); ?>

    <script type="module">
        let Chessground = null;
        try {
            ({ Chessground } = await import('https://unpkg.com/chessground@9.2.1/dist/chessground.js'));
        } catch (e) {
            const el = document.getElementById('cp-game-board');
            if (el) {
                el.style.cssText = 'padding:1rem;background:#7f1d1d;color:#fff;border-radius:12px;';
                el.textContent = 'Errore caricamento scacchiera (Chessground): ' + (e && e.message ? e.message : String(e));
            }
            throw e;
        }

        window.cp_stockfish_worker_url_wasm = <?php echo wp_json_encode($stockfishWorkerUrlWasm); ?>;
        window.cp_stockfish_worker_url_asm = <?php echo wp_json_encode($stockfishWorkerUrlAsm); ?>;
        window.cp_stockfish_wasm_url = <?php echo wp_json_encode($stockfishWasmUrl); ?>;

        const cpPgnText = <?php echo wp_json_encode($pgn); ?>;
        const cpInitialFenFull = <?php echo wp_json_encode($fen); ?>;

        const cpUi = {
            info: document.getElementById('cp-game-info'),
            moves: document.getElementById('cp-game-moves'),
            first: document.getElementById('cp-game-first'),
            prev: document.getElementById('cp-game-prev'),
            next: document.getElementById('cp-game-next'),
            last: document.getElementById('cp-game-last'),
            analysisPanel: document.getElementById('cp-analysis-panel'),
            backLiveBtn: document.getElementById('cp-analysis-back-live'),
            evalToggleBtn: document.getElementById('cp-analysis-toggle-eval'),
            topMove: document.getElementById('cp-analysis-top-move'),
            eval: document.getElementById('cp-analysis-eval'),
            evalWhiteFill: document.getElementById('cp-eval-white-fill'),
            evalBlackFill: document.getElementById('cp-eval-black-fill'),
            linesEl: document.getElementById('cp-analysis-lines'),
            variationEl: document.getElementById('cp-game-variation'),
        };

        try {
            // Visible marker to confirm which PHP/JS version is live.
        if (cpUi.linesEl) cpUi.linesEl.innerHTML = '<div class="cp-analysis-line"><em>build</em>2026-03-30-r33</div>';
        } catch (e) {}

        const body = document.body;
        const liveUrl = body ? body.getAttribute('data-live-moves-url') : null;
        const hasResult = body ? body.getAttribute('data-has-result') === '1' : false;
        const expectedPairingId = body ? (parseInt(body.getAttribute('data-pairing-id') || '0', 10) || 0) : 0;

        const ChessLib = window.Chess;
        if (!ChessLib) {
            console.error('Chess library missing (chess.min.js).');
            throw new Error('Chess library missing');
        }

        function stripPgn(s) {
            if (!s || typeof s !== 'string') return '';
            return s
                .replace(/\s*\{[^}]*\}\s*/g, ' ')
                // Lichess-style annotations like [%eval 0.15] [%clk 1:23:45]
                .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, opts) {
            const o = opts || {};
            return (c.load_pgn && c.load_pgn(s, o)) || (c.loadPgn && c.loadPgn(s, o));
        }

        function extractMovePart(s) {
            if (!s || typeof s !== 'string') return s;
            const idx = s.search(/\d+\.\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i);
            return idx >= 0 ? s.substring(idx) : s;
        }

        function getMoves(pgnText) {
            if (!pgnText || typeof ChessLib === 'undefined') return [];
            try {
                const c = new ChessLib();
                const stripped = stripPgn(pgnText);
                if (!loadPgn(c, stripped, { sloppy: true })) {
                    const mp = extractMovePart(stripped);
                    if (!mp || mp === stripped || !loadPgn(c, stripPgn(mp), { sloppy: true })) return [];
                }
                const h = c.history ? c.history() : [];
                return Array.isArray(h) ? h : [];
            } catch (e) {
                return [];
            }
        }

        function parsePgnToState(pgnText) {
            // Single source of truth: load PGN once and derive both moves[] and final FEN from the same chess.js instance.
            if (!pgnText || typeof ChessLib === 'undefined') return { moves: [], fen: null, error: 'no-pgn' };
            try {
                const c = new ChessLib();
                const stripped = stripPgn(pgnText);
                let ok = loadPgn(c, stripped, { sloppy: true });
                if (!ok) {
                    const mp = extractMovePart(stripped);
                    ok = !!(mp && mp !== stripped && loadPgn(c, stripPgn(mp), { sloppy: true }));
                }
                if (!ok) return { moves: [], fen: null, error: 'load_pgn_failed' };
                const h = c.history ? c.history() : [];
                const moves = Array.isArray(h) ? h : [];
                let fen = (typeof c.fen === 'function') ? c.fen() : null;

                // Defensive: we've seen cases where moves[] is non-empty but fen() still returns startpos.
                // In that case, try to reconstruct the final position by replaying SAN moves.
                if (moves.length > 0 && typeof fen === 'string' && fen.includes(' ') && fullFenToPiecesFen(fen) === fullFenToPiecesFen(cpInitialFenFull)) {
                    const replayFen = fenFromSanMovesList(moves);
                    if (typeof replayFen === 'string' && replayFen.includes(' ') && fullFenToPiecesFen(replayFen) !== fullFenToPiecesFen(cpInitialFenFull)) {
                        fen = replayFen;
                    }
                }

                return { moves, fen, error: null };
            } catch (e) {
                return { moves: [], fen: null, error: (e && e.message) ? e.message : String(e) };
            }
        }

        function fenFromSanMovesList(movesList) {
            if (!movesList || !movesList.length || typeof ChessLib === 'undefined') return null;
            try {
                const c = new ChessLib();
                for (let i = 0; i < movesList.length; i++) {
                    const ok = c.move(movesList[i]);
                    if (!ok) return null;
                }
                return c.fen();
            } catch (e) {
                try { window.__cp_fen_from_moves_error = (e && e.message) ? e.message : String(e); } catch (err) {}
                return null;
            }
        }

        function getFenFromPgn(pgnText, idx) {
            if (!pgnText || typeof ChessLib === 'undefined') return null;
            try {
                const c = new ChessLib();
                const stripped = stripPgn(pgnText);
                if (!loadPgn(c, stripped, { sloppy: true })) {
                    const mp = extractMovePart(stripped);
                    if (mp && mp !== stripped) loadPgn(c, stripPgn(mp), { sloppy: true });
                }
                const h = c.history ? c.history() : [];
                c.reset();
                for (let i = 0; i <= idx && i < h.length; i++) c.move(h[i]);
                return c.fen();
            } catch (e) {
                return null;
            }
        }

        function fenFromPgnText(pgnText) {
            if (!pgnText || typeof ChessLib === 'undefined') return null;
            try {
                let c = new ChessLib();
                const stripped = stripPgn(pgnText);
                if (loadPgn(c, stripped, { sloppy: true })) return c.fen();
                c = new ChessLib();
                if (loadPgn(c, pgnText, { sloppy: true })) return c.fen();
                c = new ChessLib();
                const idx = pgnText.search(/\d+\.\s*(?:[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8]|O-O-O|O-O)/i);
                if (idx >= 0) {
                    const mp = stripPgn(pgnText.substring(idx)).replace(/\s*(?:1-0|0-1|1\/2-1\/2|\*)\s*$/i, '').trim();
                    if (mp && loadPgn(c, mp, { sloppy: true })) return c.fen();
                }
                return null;
            } catch (e) {
                try { window.__cp_pgn_last_error = (e && e.message) ? e.message : String(e); } catch (err) {}
                return null;
            }
        }

        function fullFenToPiecesFen(fullFen) {
            if (!fullFen || typeof fullFen !== 'string') return 'start';
            return fullFen.split(' ')[0];
        }

        function parseFenMeta(fullFen) {
            const parts = (fullFen || '').split(/\s+/);
            return {
                turn: parts[1] === 'b' ? 'b' : 'w',
                fullmove: parseInt(parts[5] || '1', 10) || 1,
            };
        }

        /** chess.js requires 6 space-separated FEN fields; live/API often sends only 4. */
        function cpNormalizeFullFen(fen) {
            if (!fen || typeof fen !== 'string') return fen;
            const p = fen.trim().split(/\s+/);
            if (p.length >= 6) return fen.trim();
            if (p.length === 5) return p.concat(['1']).join(' ');
            if (p.length === 4) return p.concat(['0', '1']).join(' ');
            return fen.trim();
        }

        /**
         * Stockfish PV uses UCI (from+to squares). Convert to Standard Algebraic Notation for display.
         */
        function cpUciPvToSanLine(fullFen, uciTokens, maxMoves) {
            const max = maxMoves == null ? 20 : maxMoves;
            if (!fullFen || typeof fullFen !== 'string' || !uciTokens || !Array.isArray(uciTokens) || !ChessLib) {
                return uciTokens ? uciTokens.slice(0, max).join(' ') : '';
            }
            let ch;
            try {
                ch = new ChessLib(cpNormalizeFullFen(fullFen));
            } catch (e) {
                return uciTokens.slice(0, max).join(' ');
            }
            const out = [];
            for (let ti = 0; ti < uciTokens.length && out.length < max; ti++) {
                const raw = String(uciTokens[ti] || '').toLowerCase().trim();
                if (!/^[a-h][1-8][a-h][1-8][qrbn]?$/.test(raw)) break;
                const from = raw.slice(0, 2);
                const to = raw.slice(2, 4);
                const promo = raw.length > 4 ? raw[4] : undefined;
                let mv = null;
                try {
                    mv = promo ? ch.move({ from, to, promotion: promo }) : ch.move({ from, to });
                } catch (e) {
                    mv = null;
                }
                if (!mv) break;
                out.push(mv.san);
            }
            return out.length ? out.join(' ') : uciTokens.slice(0, max).join(' ');
        }

        // --- Game state ---
        let pgnText = cpPgnText || '';
        const initialState = parsePgnToState(pgnText);
        let moves = initialState.moves;
        let currentIdx = moves.length > 0 ? moves.length - 1 : -1;
        let cp_current_fen_full = (initialState.fen && typeof initialState.fen === 'string' && initialState.fen.includes(' '))
            ? initialState.fen
            : ((moves.length > 0) ? (getFenFromPgn(pgnText, moves.length - 1) || cpInitialFenFull) : cpInitialFenFull);

        let analysisMode = false;
        let evaluationEnabled = false;
        const analysisMultiPv = 5;
        let pollInterval = null;
        let engineWorker = null;
        let engineReady = false;
        let cpPendingFen = null;

        // User-entered variation in analysis mode
        let variationBaseIdx = -1;
        let variationSans = [];

        // Analysis sandbox state (used to build a full FEN for Stockfish)
        let sandboxLastValidPiecesFen = null;
        let sandboxTurn = 'w';
        let sandboxFullmove = 1;

        // Latest game state from server (always updated while polling); used when re-syncing after free analysis.
        let lastServerPgnText = pgnText;
        let lastServerMoves = moves.slice();
        let lastServerFenFull = cp_current_fen_full;

        const stockfishAnalysisDepth = 15;
        let lastCpCentipawns = null;
        let multipvData = {};
        /** FEN passed to Stockfish for the current search (for correct SAN when PV lines arrive). */
        let lastEnginePositionFen = '';

        function cpPublishFullFenToWindow() {
            try { window.cp_current_fen = cp_current_fen_full; } catch (e) {}
        }

        function renderPvLines() {
            if (!cpUi.linesEl) return;
            const fenForSan = lastEnginePositionFen || cp_current_fen_full;
            const lines = [];
            for (let i = 1; i <= analysisMultiPv; i++) {
                const d = multipvData[i];
                if (!d) continue;
                const sign = d.cp >= 0 ? '+' : '';
                const cpText = `${sign}${(d.cp / 100).toFixed(2)}`;
                const pvText = cpUciPvToSanLine(fenForSan, d.pvTokens, 20);
                lines.push(
                    `<div class="cp-analysis-line"><em>${i}</em>${cpText}: ${pvText}</div>`
                );
            }
            cpUi.linesEl.innerHTML = lines.join('');
        }

        function setEngineStatus(msg, isError) {
            // UI log removed (too noisy). Keep console output for debugging.
            try {
                const m = String(msg || '');
                if (isError) console.warn('[ChessPodium][Engine]', m);
                else console.log('[ChessPodium][Engine]', m);
            } catch (e) {}
        }

        let lastEngineAssetDiag = null;

        function cpPickEngine() {
            const jsWasm = window.cp_stockfish_worker_url_wasm || '';
            const jsAsm = window.cp_stockfish_worker_url_asm || '';
            const wasmUrl = window.cp_stockfish_wasm_url || '';

            // IMPORTANT:
            // Cross-Origin Isolation (COOP/COEP) is only required for SharedArrayBuffer/WASM threads.
            // Our "lite-single" WASM build is single-threaded, so it can run WITHOUT isolation.
            // Therefore we always prefer the WASM single-thread engine if present.
            if (jsWasm && wasmUrl) return { kind: 'wasm', jsUrl: jsWasm, wasmUrl };

            // Optional fallback (slower). Some hosts may serve this URL as HTML (404/redirect),
            // so do NOT pick it unless WASM is unavailable.
            if (jsAsm) return { kind: 'asm', jsUrl: jsAsm, wasmUrl: '' };

            return { kind: 'none', jsUrl: '', wasmUrl: '' };
        }

        async function checkStockfishAssets() {
            const eng = cpPickEngine();
            const jsUrl = eng.jsUrl || '';
            const wasmUrl = eng.wasmUrl || '';
            if (!jsUrl) throw new Error('URL motore mancante (worker)');

            // Check JS
            const r1 = await fetch(jsUrl, { cache: 'no-store' });
            if (!r1.ok) throw new Error('Worker JS non raggiungibile: ' + r1.status);
            const ctJs = (r1.headers.get('content-type') || '').toLowerCase();
            // If server returns an HTML error page, workers will fail with "Unexpected token <".
            try {
                const peek = (await r1.clone().text()).slice(0, 200).trim().toLowerCase();
                if (peek.startsWith('<!doctype') || peek.startsWith('<html') || peek.includes('<head') || peek.includes('<body')) {
                    throw new Error('Worker JS risponde HTML (redirect/404/WAF). URL=' + jsUrl);
                }
            } catch (e) {
                // If clone().text() fails, ignore; content-type will still help.
                if (e && e.message && String(e.message).includes('risponde HTML')) throw e;
            }

            let ctWasm = '';
            let wasmStatus = 0;
            if (wasmUrl) {
                // Check WASM (HEAD is sometimes blocked; GET with small range is not reliable across servers)
                const r2 = await fetch(wasmUrl, { cache: 'no-store' });
                if (!r2.ok) throw new Error('WASM non raggiungibile: ' + r2.status);
                ctWasm = (r2.headers.get('content-type') || '').toLowerCase();
                wasmStatus = r2.status;
            }

            const diag = { kind: eng.kind, isolated: !!window.crossOriginIsolated, jsUrl, wasmUrl, ctJs, ctWasm, wasmStatus };
            lastEngineAssetDiag = diag;

            // Soft warnings for common hosting/CSP pitfalls.
            if (ctJs && !(ctJs.includes('javascript') || ctJs.includes('ecmascript') || ctJs.includes('text/plain'))) {
                setEngineStatus('Warning: content-type JS sospetto: ' + ctJs, true);
            }
            if (ctWasm && !ctWasm.includes('wasm')) {
                setEngineStatus('Warning: content-type WASM sospetto: ' + ctWasm, true);
            }

            return diag;
        }

        function cpSetUiAnalysis(on) {
            analysisMode = !!on;
            if (cpUi.backLiveBtn) cpUi.backLiveBtn.style.display = on ? 'block' : 'none';
            if (cpUi.topMove) cpUi.topMove.textContent = '—';
            if (cpUi.eval) cpUi.eval.textContent = '—';
            // Keep panel visible always; only the button toggles.
        }

        async function setEvaluationEnabled(on) {
            evaluationEnabled = !!on;
            if (cpUi.evalToggleBtn) {
                cpUi.evalToggleBtn.textContent = evaluationEnabled
                    ? '<?php echo esc_js(__('Stop evaluation', 'chess-podium')); ?>'
                    : '<?php echo esc_js(__('Start evaluation', 'chess-podium')); ?>';
            }

            // Stop engine and clear UI when turned off.
            if (!evaluationEnabled) {
                try { ground?.setShapes([]); } catch (e) {}
                try {
                    if (engineWorker) engineWorker.terminate();
                } catch (e) {}
                engineWorker = null;
                engineReady = false;
                cpPendingFen = null;
                lastCpCentipawns = null;
                multipvData = {};

                if (cpUi.evalWhiteFill) cpUi.evalWhiteFill.style.height = '50%';
                if (cpUi.evalBlackFill) cpUi.evalBlackFill.style.height = '50%';
                if (cpUi.eval) cpUi.eval.textContent = '—';
                if (cpUi.topMove) cpUi.topMove.textContent = '—';
                if (cpUi.linesEl) cpUi.linesEl.innerHTML = '';
                return;
            }

            // If turned on, start analyzing current board position.
            try {
                setEngineStatus('Carico motore…', false);
                await checkStockfishAssets();
                setEngineStatus('Motore pronto. Avvio analisi…', false);
                if (cp_current_fen_full) updateEngineAnalysis(cp_current_fen_full);
            } catch (e) {
                setEngineStatus('Errore motore: ' + (e && e.message ? e.message : String(e)), true);
                evaluationEnabled = false;
                if (cpUi.evalToggleBtn) cpUi.evalToggleBtn.textContent = '<?php echo esc_js(__('Start evaluation', 'chess-podium')); ?>';
            }
        }

        function ensureSandboxMetaFromCurrentFullFen() {
            if (!cp_current_fen_full) return;
            const meta = parseFenMeta(cp_current_fen_full);
            sandboxTurn = meta.turn;
            sandboxFullmove = meta.fullmove;
            sandboxLastValidPiecesFen = ground ? ground.getFen() : fullFenToPiecesFen(cp_current_fen_full);
        }

        function cpEvalBarSet(cp) {
            if (!Number.isFinite(cp)) return;
            lastCpCentipawns = cp;
            const maxAbs = 300; // cap visualization
            const capped = Math.max(-maxAbs, Math.min(maxAbs, cp));
            const ratio = Math.abs(capped) / maxAbs; // 0..1
            const whitePercent = cp >= 0 ? 50 + ratio * 50 : 50 - ratio * 50;
            const blackPercent = cp >= 0 ? 50 - ratio * 50 : 50 + ratio * 50;
            if (cpUi.evalWhiteFill) cpUi.evalWhiteFill.style.height = `${whitePercent}%`;
            if (cpUi.evalBlackFill) cpUi.evalBlackFill.style.height = `${blackPercent}%`;

            if (cpUi.eval) {
                const sign = cp >= 0 ? '+' : '';
                cpUi.eval.textContent = `${sign}${(cp / 100).toFixed(2)}`;
            }
        }

        function clearArrowAndShapes() {
            try { ground?.setShapes([]); } catch (e) {}
        }

        function ensureEngine() {
            if (engineWorker) return;

            // Lazy load: created only when analysis starts.
            const eng = cpPickEngine();
            if (!eng.jsUrl) {
                setEngineStatus('Motore non configurato (manca URL).', true);
                return;
            }
            // IMPORTANT:
            // Although stockfish.js supports passing the wasm URL via location.hash ("#<wasmUrl>,worker"),
            // some hosts/proxies behave oddly with Worker fragments. Since our .wasm sits alongside the .js,
            // letting stockfish.js auto-resolve "<same-name>.wasm" is the most robust path.
            const workerUrl = eng.jsUrl;
            try {
                setEngineStatus('Creo Worker… ' + workerUrl.slice(0, 120), false);
                engineWorker = new Worker(workerUrl);
            } catch (e) {
                setEngineStatus('Worker bloccato: ' + (e && e.message ? e.message : String(e)), true);
                throw e;
            }

            let sawUciOk = false;
            let sawReadyOk = false;
            let sawAnyMessage = false;

            async function diagnoseNoResponse() {
                try {
                    const eng2 = cpPickEngine();
                    const jsUrl = eng2.jsUrl || '';
                    const wasmUrl2 = eng2.wasmUrl || '';

                    let ctJs = '';
                    let jsStatus = 0;
                    if (jsUrl) {
                        const rJs = await fetch(jsUrl, { cache: 'no-store' });
                        jsStatus = rJs.status;
                        ctJs = (rJs.headers.get('content-type') || '').toLowerCase();
                        if (!rJs.ok) throw new Error('Worker JS status ' + rJs.status);
                    }

                    // Try Range read of first 4 bytes to validate WASM magic: 00 61 73 6D ("\0asm")
                    let wasmMagic = '';
                    let ctWasm = '';
                    let wasmStatus = 0;
                    try {
                        if (wasmUrl2) {
                            const rW = await fetch(wasmUrl2, { cache: 'no-store', headers: { 'Range': 'bytes=0-3' } });
                            wasmStatus = rW.status;
                            ctWasm = (rW.headers.get('content-type') || '').toLowerCase();
                            if (rW.ok) {
                                const buf = await rW.arrayBuffer();
                                const u8 = new Uint8Array(buf);
                                if (u8.length >= 4) wasmMagic = Array.from(u8.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(' ');
                            }
                        }
                    } catch (e) {}

                    // Additional check: can we compile this WASM inside a basic worker?
                    let wasmWorkerResult = 'n/a';
                    try {
                        if (!wasmUrl2) {
                            wasmWorkerResult = 'no-wasm-url';
                        } else {
                            wasmWorkerResult = await new Promise((resolve) => {
                            const code =
                                `self.onmessage = async (e) => {` +
                                `  const url = e.data;` +
                                `  try {` +
                                `    const r = await fetch(url, { cache: 'no-store' });` +
                                `    const ab = await r.arrayBuffer();` +
                                `    const ok = WebAssembly.validate(new Uint8Array(ab));` +
                                `    self.postMessage({ ok, bytes: ab.byteLength, ct: (r.headers.get('content-type')||'') });` +
                                `  } catch (err) {` +
                                `    self.postMessage({ ok: false, err: String(err) });` +
                                `  }` +
                                `};`;
                            const blob = new Blob([code], { type: 'text/javascript' });
                            const w = new Worker(URL.createObjectURL(blob));
                            const t = setTimeout(() => {
                                try { w.terminate(); } catch (e) {}
                                resolve('timeout');
                            }, 8000);
                            w.onmessage = (ev) => {
                                clearTimeout(t);
                                try { w.terminate(); } catch (e) {}
                                const d = ev.data || {};
                                if (d.ok) resolve(`ok bytes=${d.bytes} ct=${d.ct || 'n/a'}`);
                                else resolve(`fail ${d.err || 'unknown'}`);
                            };
                            w.onerror = (err) => {
                                clearTimeout(t);
                                try { w.terminate(); } catch (e) {}
                                resolve('worker-error ' + (err && err.message ? err.message : String(err)));
                            };
                            w.postMessage(wasmUrl2);
                            });
                        }
                    } catch (e) {
                        wasmWorkerResult = 'diag-error ' + (e && e.message ? e.message : String(e));
                    }

                    setEngineStatus(
                        'Motore non risponde dopo 15s. ' +
                        `Engine=${eng2.kind || 'n/a'} isolated=${!!window.crossOriginIsolated}. ` +
                        `JS status ${jsStatus || 'n/a'} (${ctJs || 'no-ct'}). ` +
                        `WASM status ${wasmStatus || 'n/a'} (${ctWasm || 'no-ct'}) magic[${wasmMagic || 'n/a'}]. ` +
                        `WASM compile test in worker: ${wasmWorkerResult}.`,
                        true
                    );
                } catch (e) {
                    setEngineStatus('Sto ancora compilando il motore + diagnostica fallita: ' + (e && e.message ? e.message : String(e)), false);
                }
            }

            engineWorker.onerror = function (err) {
                console.error('Stockfish worker error', err);
                engineReady = false;
                const details = [
                    (err && err.message) ? ('msg=' + err.message) : 'msg=(vuoto)',
                    (err && err.filename) ? ('file=' + err.filename) : null,
                    (err && typeof err.lineno !== 'undefined') ? ('line=' + err.lineno) : null,
                    (err && typeof err.colno !== 'undefined') ? ('col=' + err.colno) : null,
                ].filter(Boolean).join(' ');
                setEngineStatus('Worker error: ' + details, true);
            };
            engineWorker.onmessageerror = function (err) {
                setEngineStatus('Worker messageerror: ' + (err && err.message ? err.message : String(err)), true);
            };

            engineWorker.onmessage = function (e) {
                const msg = e.data;
                if (typeof msg !== 'string') return;
                if (!sawAnyMessage) {
                    sawAnyMessage = true;
                    // Surface first engine output in UI (helps debug aborts/feature errors).
                    setEngineStatus('Output motore: ' + msg.slice(0, 140), false);
                }

                if (msg === 'uciok') {
                    engineReady = false;
                    sawUciOk = true;
                    setEngineStatus('UCI OK. Attendo ready…', false);
                    engineWorker.postMessage('isready');
                    return;
                }

                if (msg === 'readyok') {
                    engineReady = true;
                    sawReadyOk = true;
                    setEngineStatus('Motore avviato. Calcolo…', false);
                    if (cpPendingFen) {
                        const fenToSend = cpPendingFen;
                        cpPendingFen = null;
                        updateEngineAnalysis(fenToSend);
                    }
                    return;
                }

                // Past this point, ignore output if user turned evaluation off.
                if (!evaluationEnabled) return;

                // Common fatal messages from emscripten builds.
                if (msg.toLowerCase().includes('aborted') || msg.toLowerCase().includes('exception') || msg.toLowerCase().includes('error')) {
                    setEngineStatus('Errore motore: ' + msg.slice(0, 200), true);
                    return;
                }

                // Example: "info depth 15 seldepth 0 score cp 34 multipv 1 pv e2e4 e7e5 ..."
                if (msg.startsWith('info')) {
                    const mMate = msg.match(/score mate\s+(-?\d+)/);
                    if (mMate) {
                        const m = parseInt(mMate[1], 10);
                        const sign = m >= 0 ? '+' : '';
                        if (cpUi.eval) cpUi.eval.textContent = `M${sign}${m}`;
                        cpEvalBarSet(m > 0 ? 2000 : -2000);
                        // keep going to parse multipv/pv if present
                    }

                    const mCp = msg.match(/score cp\s+(-?\d+)/);
                    if (!mCp && !mMate) return;
                    const cp = mCp ? parseInt(mCp[1], 10) : (mMate ? (parseInt(mMate[1], 10) > 0 ? 2000 : -2000) : 0);

                    const mMulti = msg.match(/multipv\s+(\d+)/);
                    const mpv = mMulti ? parseInt(mMulti[1], 10) : 1;

                    const mPv = msg.match(/\spv\s+(.+)$/);
                    const pvTokens = mPv ? mPv[1].trim().split(/\s+/) : [];

                    multipvData[mpv] = {
                        cp,
                        pvTokens,
                    };

                    if (mpv === 1) {
                        cpEvalBarSet(cp);
                    }

                    renderPvLines();
                    return;
                }

                // Example: "bestmove e2e4 ponder ..."
                if (msg.startsWith('bestmove')) {
                    const m = msg.match(/^bestmove\s+([a-h][1-8][a-h][1-8][qrbn]?)/i);
                    if (!m) return;
                    const bestmove = m[1].toLowerCase();
                    const fenForSan = lastEnginePositionFen || cp_current_fen_full;
                    const sanLine = cpUciPvToSanLine(fenForSan, [bestmove], 1);
                    const displayMove = (sanLine && sanLine.split(/\s+/)[0]) || bestmove;

                    if (cpUi.topMove) cpUi.topMove.textContent = displayMove;

                    const from = bestmove.slice(0, 2);
                    const to = bestmove.slice(2, 4);
                    if (from && to && bestmove !== '(none)') {
                        const arrowBrush = (lastCpCentipawns === null ? true : lastCpCentipawns >= 0) ? 'green' : 'red';
                        try {
                            ground?.setShapes([{
                                orig: from,
                                dest: to,
                                brush: arrowBrush,
                                modifiers: { lineWidth: 8 }
                            }]);
                        } catch (e) {}
                    }
                }
            };

            try {
                setEngineStatus('Invio comando UCI…', false);
                engineWorker.postMessage('uci');
            } catch (e) {
                setEngineStatus('Impossibile inviare comandi al Worker: ' + (e && e.message ? e.message : String(e)), true);
                throw e;
            }

            // If we see absolutely no output soon after "uci", it's almost always CSP/MIME/worker init crash.
            setTimeout(function () {
                if (!evaluationEnabled) return;
                if (sawAnyMessage || sawUciOk || sawReadyOk) return;
                const d = lastEngineAssetDiag || {};
                setEngineStatus(
                    'Nessun output dal motore dopo UCI (attendo diagnostica completa a 15s). ' +
                    `ctJs=${d.ctJs || 'n/a'} ctWasm=${d.ctWasm || 'n/a'}`,
                    true
                );
            }, 2000);

            // Watchdog: compilation can be slow on first run (esp. mobile).
            setTimeout(function () {
                if (!evaluationEnabled) return;
                if (sawReadyOk || sawUciOk) return;
                diagnoseNoResponse();
            }, 15000);
        }

        function updateEngineAnalysis(fullFen) {
            if (!fullFen) return;
            if (!evaluationEnabled) return;
            // Don't clear PV/arrow/UI here: live polling calls this often and clearing causes flicker.
            ensureEngine();
            if (!engineReady) {
                cpPendingFen = fullFen;
                return;
            }
            try { engineWorker.postMessage('stop'); } catch (e) {}
            try { engineWorker.postMessage('ucinewgame'); } catch (e) {}
            try { engineWorker.postMessage('setoption name MultiPV value ' + analysisMultiPv); } catch (e) {}
            try { engineWorker.postMessage(`position fen ${fullFen}`); } catch (e) { setEngineStatus('Errore position: ' + (e && e.message ? e.message : String(e)), true); return; }
            lastEnginePositionFen = fullFen;
            try { engineWorker.postMessage(`go depth ${stockfishAnalysisDepth}`); } catch (e) { setEngineStatus('Errore go: ' + (e && e.message ? e.message : String(e)), true); return; }
        }

        function updateNavUi() {
            if (!cpUi.info) return;
            const total = moves.length;
            let txt = total === 0 ? '—' : (currentIdx < 0 ? '0' : (Math.floor(currentIdx / 2) + 1) + (currentIdx % 2 === 0 ? '.' : '...'));
            if (total > 0 && currentIdx >= total - 1) txt = `${total} moves`;
            cpUi.info.textContent = txt;
        }

        function renderMovesList() {
            const el = cpUi.moves;
            if (!el) return;
            let html = '';
            for (let i = 0; i < moves.length; i++) {
                const cls = (i === currentIdx) ? ' cp-game-move-current' : '';
                const moveNo = Math.floor(i / 2) + 1;
                const prefix = i % 2 === 0 ? `${moveNo}. ` : '';
                html += `<span class="cp-game-move${cls}" data-idx="${i}">${prefix}${moves[i]}</span> `;
            }
            el.innerHTML = html;
            el.querySelectorAll('.cp-game-move').forEach(function (sp) {
                sp.onclick = function () {
                    // Navigating the move list should enter analysis mode, otherwise live polling will
                    // immediately pull the board back to the latest move.
                    ensureAnalysisFromNav();
                    const idx = parseInt(sp.getAttribute('data-idx'), 10);
                    showPos(idx);
                };
            });
        }

        function renderVariation() {
            if (!cpUi.variationEl) return;
            if (!analysisMode || variationSans.length === 0) {
                cpUi.variationEl.style.display = 'none';
                cpUi.variationEl.innerHTML = '';
                return;
            }
            const baseLabel = (variationBaseIdx < 0) ? 'start' : ('mossa ' + (variationBaseIdx + 1));
            const safe = variationSans.map(s => String(s).replace(/[<>&]/g, ch => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]))).join(' ');
            cpUi.variationEl.style.display = 'block';
            cpUi.variationEl.innerHTML = `<strong>Variante</strong> (da ${baseLabel}): ${safe}`;
        }

        function showPos(idx) {
            const total = moves.length;
            currentIdx = idx;

            const fenFull = (idx < 0) ? cpInitialFenFull : (getFenFromPgn(pgnText, idx) || cpInitialFenFull);
            if (fenFull) {
                cp_current_fen_full = fenFull;
                const piecesFen = fullFenToPiecesFen(fenFull);
                try {
                    ground.set({ fen: piecesFen });
                } catch (e) {}
            }

            // When navigating, we are in analysis mode: keep current arrow/PV until new analysis finishes.
            updateNavUi();
            renderMovesList();

            if (evaluationEnabled) {
                ensureSandboxMetaFromCurrentFullFen();
                updateEngineAnalysis(cp_current_fen_full);
            }

            // Enable sandbox moves (variations) from this position.
            cpSyncSandboxToFullFen(cp_current_fen_full);

            variationBaseIdx = idx;
            variationSans = [];
            renderVariation();
            cpPublishFullFenToWindow();
        }

        function ensureAnalysisFromNav() {
            if (analysisMode) return;
            cpEnterAnalysisMode();
            // Do not clear arrow/PV here to prevent flicker.
            if (evaluationEnabled) updateEngineAnalysis(cp_current_fen_full);
        }

        function resetToLivePosition() {
            cpSetUiAnalysis(false);
            window.cp_analysis_mode = false;
            clearArrowAndShapes();
            analysisMode = false;
            variationSans = [];
            renderVariation();

            pgnText = lastServerPgnText;
            moves = lastServerMoves.length ? lastServerMoves.slice() : getMoves(pgnText);
            const liveIdx = moves.length > 0 ? moves.length - 1 : -1;
            currentIdx = liveIdx;

            let fenFull = lastServerFenFull;
            if (!fenFull || (typeof fenFull === 'string' && !fenFull.includes(' '))) {
                fenFull = (liveIdx < 0) ? cpInitialFenFull : (getFenFromPgn(pgnText, liveIdx) || cpInitialFenFull);
            }
            cp_current_fen_full = fenFull;

            // Hard rebuild board (interactive) so Sync restores grab/analysis capability.
            try { if (ground && typeof ground.destroy === 'function') ground.destroy(); } catch (e) {}
            try { boardEl.innerHTML = ''; } catch (e) {}
            try {
                ground = Chessground(boardEl, {
                    fen: fullFenToPiecesFen(fenFull),
                    orientation: 'white',
                    viewOnly: false,
                    coordinates: false,
                    draggable: { enabled: true, distance: 3, autoDistance: true, showGhost: true },
                    selectable: { enabled: true },
                    movable: {
                        free: true,
                        color: 'both',
                        showDests: false,
                        dests: undefined,
                        events: { after: function (orig, dest, metadata) { onSandboxMoveAfter(orig, dest, metadata); } }
                    },
                    drawable: { enabled: true, visible: true, shapes: [] }
                });
                if (ground && ground.resize) setTimeout(function(){ try { ground.resize(); } catch(_){} }, 50);
            } catch (e) {}
            // Re-sync sandbox to live FEN so legal move dests/SAN work after detaching.
            try { cpSyncSandboxToFullFen(cp_current_fen_full); } catch (e) {}
            ensureSandboxMetaFromCurrentFullFen();
            updateNavUi();
            renderMovesList();
            if (evaluationEnabled) updateEngineAnalysis(cp_current_fen_full);
            cpUpdateMovableFromSandbox();
            cpPublishFullFenToWindow();

            if (liveUrl && !hasResult && !pollInterval) {
                pollInterval = setInterval(poll, 1500);
            }
        }

        // --- Polling Live moves ---
        function getMovesFromPgnText(newPgn) {
            return getMoves(newPgn);
        }

        function poll() {
            if (!liveUrl || hasResult) return;

            const u = liveUrl + (liveUrl.indexOf('?') >= 0 ? '&' : '?') + 't=' + Date.now();
            fetch(u, { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } })
                .then(r => r.json())
                .then(data => {
                    try {
                        const list = data.live_moves || [];
                        if (list.length === 0) return;

                        const m = list[0];
                        if (expectedPairingId && m.pairing_id && m.pairing_id !== expectedPairingId) return;

                        const newPgn = m.pgn || '';
                        const newFen = m.fen || null;
                        const newRes = (m.result || '').trim();

                        if (newRes) {
                            // Result arrived: stop polling
                            hasResult = true;
                            if (pollInterval) {
                                clearInterval(pollInterval);
                                pollInterval = null;
                            }
                            const resEl = document.querySelector('.cp-game-result');
                            if (resEl) {
                                resEl.textContent = ' ' + newRes;
                            } else {
                                const title = document.querySelector('.cp-game-title');
                                if (title) {
                                    const sp = document.createElement('span');
                                    sp.className = 'cp-game-result';
                                    sp.style.cssText = 'color:#22c55e;font-weight:700;';
                                    sp.textContent = ' ' + newRes;
                                    title.appendChild(sp);
                                }
                            }
                        }

                        const state = parsePgnToState(newPgn);
                        const newMoves = state.moves;

                        // Prefer a reliable full FEN:
                        // - If server provides a full FEN and it is not obviously wrong, use it.
                        // - Otherwise use the FEN computed from the *same* parse that produced moves[].
                        let liveFenFull = null;
                        const serverFenFull = (typeof newFen === 'string' && newFen.includes(' ')) ? newFen : null;
                        const parsedFenFull = (state.fen && typeof state.fen === 'string' && state.fen.includes(' ')) ? state.fen : null;

                        // If server says "startpos" but we have moves, treat it as unreliable and prefer parsed fen.
                        if (serverFenFull && !(newMoves.length > 0 && fullFenToPiecesFen(serverFenFull) === fullFenToPiecesFen(cpInitialFenFull) && parsedFenFull && fullFenToPiecesFen(parsedFenFull) !== fullFenToPiecesFen(cpInitialFenFull))) {
                            liveFenFull = serverFenFull;
                        }
                        if (!liveFenFull && parsedFenFull) liveFenFull = parsedFenFull;
                        if (!liveFenFull) {
                            // If we have moves/PGN but can't compute the FEN, keep the last known server position (or current live board when not in analysis).
                            if ((newPgn && newPgn.trim()) || newMoves.length > 0) {
                                if (!analysisMode && typeof setEngineStatus === 'function') {
                                    setEngineStatus('Live update: mosse/PGN presenti ma FEN non calcolabile (mantengo posizione corrente). parseErr=' + (state.error || 'n/a'), true);
                                }
                                liveFenFull = analysisMode ? lastServerFenFull : cp_current_fen_full;
                            } else {
                                liveFenFull = cpInitialFenFull;
                            }
                        }

                        lastServerPgnText = newPgn;
                        lastServerMoves = newMoves.slice();
                        lastServerFenFull = liveFenFull || cpInitialFenFull;

                        if (analysisMode) {
                            return;
                        }

                        pgnText = newPgn;
                        moves = newMoves;

                        const f = lastServerFenFull;
                        cp_current_fen_full = f;
                        cpPublishFullFenToWindow();
                        currentIdx = moves.length > 0 ? moves.length - 1 : -1;

                        try { ground.set({ fen: fullFenToPiecesFen(f) }); } catch (e) {}
                        try {
                            if (typeof setEngineStatus === 'function' && newMoves.length > 0 && fullFenToPiecesFen(f) === fullFenToPiecesFen(cpInitialFenFull)) {
                                const fenPreview = (liveFenFull && typeof liveFenFull === 'string') ? liveFenFull.slice(0, 60) : 'null';
                                setEngineStatus('DEBUG live: mosse=' + newMoves.length + ' ma FEN=START. pgnLen=' + (newPgn ? newPgn.length : 0) + ' liveFenFull=' + fenPreview + ' parseErr=' + (state.error || 'n/a'), true);
                            }
                        } catch (e) {}
                        ensureSandboxMetaFromCurrentFullFen();
                        updateNavUi();
                        renderMovesList();
                        if (evaluationEnabled) updateEngineAnalysis(cp_current_fen_full);
                    } catch (e) {
                        // ignore live poll issues
                    }
                })
                .catch(() => {});
        }

        // --- Events ---
        if (cpUi.first) cpUi.first.addEventListener('click', () => { ensureAnalysisFromNav(); showPos(-1); }, true);
        if (cpUi.prev) cpUi.prev.addEventListener('click', () => { ensureAnalysisFromNav(); showPos(Math.max(-1, currentIdx - 1)); }, true);
        if (cpUi.next) cpUi.next.addEventListener('click', () => { showPos(Math.min(moves.length - 1, currentIdx + 1)); }, true);
        if (cpUi.last) cpUi.last.addEventListener('click', () => { showPos(moves.length > 0 ? moves.length - 1 : -1); }, true);
        if (cpUi.backLiveBtn) cpUi.backLiveBtn.addEventListener('click', () => resetToLivePosition(), true);
        if (cpUi.evalToggleBtn) cpUi.evalToggleBtn.addEventListener('click', () => setEvaluationEnabled(!evaluationEnabled), true);

        document.addEventListener('keydown', (e) => {
            if (e.key === 'ArrowLeft') {
                e.preventDefault();
                if (moves.length === 0) return;
                ensureAnalysisFromNav();
                showPos(currentIdx > 0 ? currentIdx - 1 : -1);
            }
            if (e.key === 'ArrowRight') {
                e.preventDefault();
                if (moves.length === 0) return;
                showPos(currentIdx < moves.length - 1 ? currentIdx + 1 : moves.length - 1);
            }
        });

        // Make sure we don't leak Stockfish worker
        window.addEventListener('pagehide', () => {
            try {
                engineWorker?.terminate();
            } catch (e) {}
            engineWorker = null;
        });

        // --- Init Chessground ---
        const boardEl = document.getElementById('cp-game-board');
        if (!boardEl) {
            console.error('Missing #cp-game-board');
            throw new Error('Missing cp-game-board');
        }

        let ground = null;
        let sandboxChess = null;

        function cpTurnColorFromChess(ch) {
            return (ch && typeof ch.turn === 'function' && ch.turn() === 'b') ? 'black' : 'white';
        }

        function cpBuildDestsMap(ch) {
            // Chessground expects a Map<square, squares[]>
            const dests = new Map();
            if (!ch || typeof ch.moves !== 'function') return dests;
            const board = ch.board && typeof ch.board === 'function' ? ch.board() : null;
            if (!board) return dests;

            for (let r = 0; r < board.length; r++) {
                for (let f = 0; f < board[r].length; f++) {
                    const piece = board[r][f];
                    if (!piece) continue;
                    if (piece.color !== ch.turn()) continue;
                    const file = 'abcdefgh'[f];
                    const rank = String(8 - r);
                    const from = file + rank;
                    const ms = ch.moves({ square: from, verbose: true }) || [];
                    if (!ms.length) continue;
                    dests.set(from, ms.map(m => m.to));
                }
            }
            return dests;
        }

        function cpSyncSandboxToFullFen(fullFen) {
            sandboxChess = null;
            const normalizedFen = cpNormalizeFullFen(fullFen);
            try {
                const ch = new ChessLib();
                const ok = (typeof ch.load === 'function') ? ch.load(normalizedFen) : true;
                if (typeof ch.load === 'function' && !ok) throw new Error('invalid fen');
                sandboxChess = ch;
            } catch (e) {
                sandboxChess = null;
            }

            const piecesFen = fullFenToPiecesFen(normalizedFen || fullFen);
            try { ground.set({ fen: piecesFen }); } catch (e) {}
            sandboxLastValidPiecesFen = piecesFen;
            const meta = parseFenMeta(normalizedFen || fullFen);
            sandboxTurn = meta.turn;
            sandboxFullmove = meta.fullmove;
            cpUpdateMovableFromSandbox();
        }

        function cpUpdateMovableFromSandbox() {
            if (!ground) return;
            // Broadcast-like UX: board is always interactive.
            // - Not detached (analysisMode=false): allow free dragging; first user move detaches.
            // - Detached (analysisMode=true): prefer legal-move interaction so SAN variants work reliably.
            let dests = null;
            let turnColor = null;
            if (analysisMode && sandboxChess) {
                try {
                    dests = cpBuildDestsMap(sandboxChess);
                    turnColor = cpTurnColorFromChess(sandboxChess);
                } catch (e) {
                    dests = null;
                    turnColor = null;
                }
            }
            const hasDests = !!(dests && typeof dests.size === 'number' && dests.size > 0);
            try {
                ground.set({
                    viewOnly: false,
                    turnColor: hasDests && turnColor ? turnColor : undefined,
                    draggable: { enabled: true, distance: 3, autoDistance: true, showGhost: true },
                    selectable: { enabled: true },
                    movable: {
                        free: analysisMode ? !hasDests : true,
                        color: analysisMode ? (hasDests && turnColor ? turnColor : 'both') : 'both',
                        dests: analysisMode && hasDests ? dests : undefined,
                        showDests: analysisMode && hasDests,
                        events: {
                            after: function (orig, dest, metadata) {
                                onSandboxMoveAfter(orig, dest, metadata);
                            }
                        }
                    },
                });
            } catch (e) {}
            return;
        }

        function cpEnterAnalysisMode() {
            if (analysisMode) return;
            analysisMode = true;
            cpSetUiAnalysis(true);
            window.cp_analysis_mode = true;
            try { if (boardEl) boardEl.style.pointerEvents = 'auto'; } catch (e) {}

            // Detach from live without rebuilding the whole Chessground instance.
            variationBaseIdx = currentIdx;
            variationSans = [];
            renderVariation();
            cpSyncSandboxToFullFen(cp_current_fen_full);
            return;

            // Hard rebuild Chessground to ensure pieces become selectable/draggable.
            try {
                if (ground && typeof ground.destroy === 'function') ground.destroy();
            } catch (e) {}
            try { boardEl.innerHTML = ''; } catch (e) {}
            cpArrowSvg = null;
            try {
                ground = Chessground(boardEl, {
                    fen: fullFenToPiecesFen(cp_current_fen_full),
                    orientation: 'white',
                    viewOnly: false,
                    coordinates: false,
                    draggable: { enabled: true, distance: 3, autoDistance: true, showGhost: true },
                    selectable: { enabled: true },
                    movable: {
                        free: true,
                        color: 'both',
                        dests: undefined,
                        showDests: false,
                        events: {
                            after: function (orig, dest, metadata) {
                                onSandboxMoveAfter(orig, dest, metadata);
                            }
                        }
                    },
                    drawable: {
                        enabled: true,
                        visible: true,
                        shapes: []
                    }
                });
                cpFreeGroundConfigured = true;
            } catch (e) {
                // If rebuild fails, keep UI usable by re-creating a visible view-only board.
                cpFreeGroundConfigured = true; // prevent cpUpdateMovableFromSandbox from trying again in this mode
                try {
                    if (cpUi.linesEl) {
                        var msg = (e && e.message) ? e.message : String(e);
                        msg = msg.replace(/[<>&]/g, function (ch) { return ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[ch]; });
                        cpUi.linesEl.innerHTML =
                            '<div class="cp-analysis-line"><em>Free analysis rebuild failed</em> ' + msg + '</div>';
                    }
                } catch (_) {}

                try {
                    ground = Chessground(boardEl, {
                        fen: fullFenToPiecesFen(cp_current_fen_full),
                        orientation: 'white',
                        viewOnly: true,
                        coordinates: false,
                        draggable: { enabled: false },
                        selectable: { enabled: false },
                        movable: { free: false, color: undefined, showDests: false, dests: undefined },
                        drawable: { enabled: true, visible: true, shapes: [] }
                    });
                    if (ground && ground.resize) setTimeout(function(){ try { ground.resize(); } catch(_){} }, 50);
                } catch (e2) {
                    // Keep blank; diagnostics above should help.
                }
            }

            // Diagnostics (visible in UI): confirms click path + state.
            try {
                const fenNow = (ground && typeof ground.getFen === 'function') ? ground.getFen() : '';
                const sbOk = !!sandboxChess;
                if (cpUi.linesEl) {
                    cpUi.linesEl.innerHTML =
                        '<div class="cp-analysis-line"><em>Free analysis</em> activated ' +
                        '(analysisMode=' + analysisMode + ', sandboxChess=' + sbOk + ', fen=' + (fenNow ? String(fenNow).slice(0, 36) : 'n/a') + ')</div>';
                }
            } catch (e) {}

            // Keep polling in the background so lastServer* stays fresh; board is not updated while analysisMode is true.
            variationBaseIdx = currentIdx;
            variationSans = [];
            renderVariation();
            cpSyncSandboxToFullFen(cp_current_fen_full);
        }

        function onSandboxMoveAfter(orig, dest) {
            // Make the board interactive + legal-moves only.
            cpEnterAnalysisMode();
            if (!sandboxChess) {
                try {
                    const ch = new ChessLib();
                    const nf = cpNormalizeFullFen(cp_current_fen_full);
                    if (typeof ch.load === 'function' && nf && ch.load(nf)) {
                        sandboxChess = ch;
                    }
                } catch (e) {}
            }
            if (!sandboxChess) return;

            // Apply move through chess.js (legality enforced).
            const move = sandboxChess.move({ from: orig, to: dest, promotion: 'q' });
            if (!move) {
                // Revert any visual change
                try { ground.set({ fen: fullFenToPiecesFen(sandboxChess.fen()) }); } catch (e) {}
                cpUpdateMovableFromSandbox();
                return;
            }

            const fullFenNow = sandboxChess.fen();
            cp_current_fen_full = fullFenNow;
            sandboxLastValidPiecesFen = fullFenToPiecesFen(fullFenNow);
            // Ensure the UI matches the sandbox state.
            // This is especially important on the *first* free move, because entering analysis mode
            // may rebuild/resync the board to the live FEN and visually "snap back".
            try { ground.set({ fen: sandboxLastValidPiecesFen }); } catch (e) {}
            const meta = parseFenMeta(fullFenNow);
            sandboxTurn = meta.turn;
            sandboxFullmove = meta.fullmove;

            try {
                if (move && move.san) {
                    variationSans.push(move.san);
                    renderVariation();
                }
            } catch (e) {}

            cpUpdateMovableFromSandbox();
            clearArrowAndShapes();
            updateEngineAnalysis(cp_current_fen_full);
            cpPublishFullFenToWindow();
        }

        ground = Chessground(boardEl, {
            fen: fullFenToPiecesFen(cp_current_fen_full),
            orientation: 'white',
            viewOnly: false,
            coordinates: false,
            draggable: { enabled: true, distance: 3, autoDistance: true, showGhost: true },
            selectable: { enabled: true },
            movable: {
                free: true,
                color: 'both',
                showDests: false,
                dests: undefined,
                events: {
                    after: function (orig, dest, metadata) {
                        // For analysis, we want to activate engine only after user makes a move.
                        onSandboxMoveAfter(orig, dest, metadata);
                    }
                }
            },
            drawable: {
                enabled: true,
                visible: true,
                shapes: []
            }
        });

        ensureSandboxMetaFromCurrentFullFen();
        updateNavUi();
        renderMovesList();
        cpPublishFullFenToWindow();
        cpSetUiAnalysis(false);
        window.cp_analysis_mode = false;

        // Start live polling
        if (liveUrl && !hasResult) {
            pollInterval = setInterval(poll, 1500);
        }
    </script>
</body>
</html>
        <?php
        exit;
    }

    private static function get_player_profile_base_url(): string
    {
        $playerSlug = apply_filters('chess_podium_player_slug', 'player');
        $playerSlug = sanitize_title((string) $playerSlug) ?: 'player';
        return home_url('/' . $playerSlug . '/');
    }

    public static function get_unified_player_profile_url(string $playerSlug, int $fromTournamentId = 0): string
    {
        $playerSlug = sanitize_title($playerSlug);
        if ($playerSlug === '') {
            return home_url('/');
        }
        $base = self::get_player_profile_base_url();
        $url = trailingslashit($base . $playerSlug);
        if ($fromTournamentId > 0) {
            $url = add_query_arg('cp_tournament', (string) $fromTournamentId, $url);
        }
        return $url;
    }

    /**
     * When a WP Page exists at the same path as a unified profile, core may win the rewrite race.
     * Parse the request path and return the player slug segment, or ''.
     */
    private static function detect_global_player_slug_from_request_uri(): string
    {
        if (empty($_SERVER['REQUEST_URI'])) {
            return '';
        }
        $playerSlug = apply_filters('chess_podium_player_slug', 'player');
        $playerSlug = sanitize_title((string) $playerSlug) ?: 'player';
        $reqPath = (string) parse_url((string) wp_unslash($_SERVER['REQUEST_URI']), PHP_URL_PATH);
        if ($reqPath === '') {
            return '';
        }
        $homePath = trim((string) parse_url(home_url('/'), PHP_URL_PATH), '/');
        $reqPath = trim($reqPath, '/');
        if ($homePath !== '' && $reqPath !== '') {
            $prefix = $homePath . '/';
            if (strpos($reqPath, $prefix) === 0) {
                $reqPath = substr($reqPath, strlen($prefix));
            } elseif ($reqPath === $homePath) {
                $reqPath = '';
            }
        }
        if ($reqPath === '') {
            return '';
        }
        $parts = array_values(array_filter(explode('/', $reqPath), static function ($p) {
            return $p !== '';
        }));
        $n = count($parts);
        for ($i = 0; $i < $n - 1; $i++) {
            if ($parts[$i] !== $playerSlug) {
                continue;
            }
            // .../player/{slug}/ or .../it/player/{slug}/ (language prefix before "player")
            if ($i + 1 !== $n - 1) {
                continue;
            }
            $seg = sanitize_title($parts[$i + 1]);
            return $seg !== '' ? $seg : '';
        }
        return '';
    }

    private static function unified_player_row_matches_standings(string $displayName, string $fideNorm, array $s): bool
    {
        if (!class_exists('ChessPodium_PlayerDirectory')) {
            return false;
        }
        if ($fideNorm !== '') {
            $sf = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($s['fide_id'] ?? ''));
            if ($sf === $fideNorm) {
                return true;
            }
        }
        $standName = trim((string) ($s['name'] ?? ''));
        if ($displayName !== '' && strcasecmp($standName, $displayName) === 0) {
            return true;
        }
        $a = ChessPodium_PlayerDirectory::normalize_name_alias($displayName);
        $b = ChessPodium_PlayerDirectory::normalize_name_alias($standName);
        return $a !== '' && $b === $a;
    }

    private static function find_tournament_roster_row_for_unified_player(object $unifiedPlayer, int $tournamentId): ?object
    {
        if (!class_exists('ChessPodium_PlayerDirectory') || $tournamentId <= 0) {
            return null;
        }
        $displayName = trim((string) ($unifiedPlayer->display_name ?? ''));
        $fideNorm = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($unifiedPlayer->fide_id ?? ''));
        $nameNorm = ChessPodium_PlayerDirectory::normalize_name_alias($displayName);
        foreach (self::get_players($tournamentId) as $tp) {
            $tpFide = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($tp->fide_id ?? ''));
            if ($fideNorm !== '' && $tpFide === $fideNorm) {
                return $tp;
            }
            $tpNameNorm = ChessPodium_PlayerDirectory::normalize_name_alias((string) ($tp->name ?? ''));
            if ($nameNorm !== '' && $tpNameNorm === $nameNorm) {
                return $tp;
            }
            if ($displayName !== '' && strcasecmp(trim((string) ($tp->name ?? '')), $displayName) === 0) {
                return $tp;
            }
        }
        return null;
    }

    /**
     * @return array{series: list<array<string, mixed>>, totals: array{w:int,d:int,l:int}}
     */
    private static function collect_unified_player_stats_series(object $player): array
    {
        $empty = ['series' => [], 'totals' => ['w' => 0, 'd' => 0, 'l' => 0]];
        if (!class_exists('ChessPodium_PlayerDirectory') || !class_exists('ChessPodium_ExportEngine')) {
            return $empty;
        }
        $series = [];
        $tw = 0;
        $td = 0;
        $tl = 0;
        $list = self::get_tournaments();
        $list = array_reverse($list);
        foreach ($list as $t) {
            $tid = (int) ($t->id ?? 0);
            if ($tid <= 0) {
                continue;
            }
            $tp = self::find_tournament_roster_row_for_unified_player($player, $tid);
            if (!$tp) {
                continue;
            }
            $pid = (int) ($tp->id ?? 0);
            if ($pid <= 0) {
                continue;
            }
            $players = self::get_players($tid);
            $pairings = self::get_pairings_detailed($tid);
            $statsMap = ChessPodium_ExportEngine::get_player_stats($players, $pairings);
            $st = $statsMap[$pid] ?? null;
            if (!$st || !is_array($st)) {
                continue;
            }
            $w = (int) ($st['wins'] ?? 0);
            $d = (int) ($st['draws'] ?? 0);
            $l = (int) ($st['losses'] ?? 0);
            $tw += $w;
            $td += $d;
            $tl += $l;
            $series[] = [
                'tournament_id' => $tid,
                'name' => (string) ($t->name ?? ''),
                'rating' => (int) ($st['rating'] ?? 0),
                'performance' => (int) ($st['performance'] ?? 0),
                'elo_var' => (int) ($st['elo_variation'] ?? 0),
                'w' => $w,
                'd' => $d,
                'l' => $l,
            ];
        }
        return ['series' => $series, 'totals' => ['w' => $tw, 'd' => $td, 'l' => $tl]];
    }

    /**
     * @return list<array{tournament_id:int,tournament_name:string,position:int,points:float}>
     */
    private static function collect_unified_player_tournament_history(object $player): array
    {
        if (!class_exists('ChessPodium_PlayerDirectory')) {
            return [];
        }
        $displayName = trim((string) ($player->display_name ?? ''));
        $fideNorm = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($player->fide_id ?? ''));
        $out = [];
        foreach (self::get_tournaments() as $t) {
            $tid = (int) ($t->id ?? 0);
            if ($tid <= 0) {
                continue;
            }
            $tp = self::find_tournament_roster_row_for_unified_player($player, $tid);
            if (!$tp) {
                continue;
            }
            $stand = self::compute_standings($tid);
            $pos = null;
            $pts = null;
            foreach ($stand as $idx => $row) {
                if (!self::unified_player_row_matches_standings($displayName, $fideNorm, $row)) {
                    continue;
                }
                $pos = $idx + 1;
                $pts = (float) ($row['points'] ?? 0);
                break;
            }
            if ($pos === null) {
                continue;
            }
            $out[] = [
                'tournament_id' => $tid,
                'tournament_name' => (string) ($t->name ?? ''),
                'position' => (int) $pos,
                'points' => (float) $pts,
            ];
        }
        return $out;
    }

    /**
     * Official FIDE ratings profile URL (numeric ID only).
     */
    public static function get_fide_ratings_profile_url(string $fideId): string
    {
        $n = class_exists('ChessPodium_PlayerDirectory')
            ? ChessPodium_PlayerDirectory::normalize_fide_id_alias($fideId)
            : preg_replace('/\D+/', '', (string) $fideId);
        return $n !== '' ? 'https://ratings.fide.com/profile/' . $n : '';
    }

    private static function pairing_row_has_pgn_content(int $pairingId, array $pairingById, array $archivedPgnByPairingId): bool
    {
        if ($pairingId <= 0) {
            return false;
        }
        if (!empty($archivedPgnByPairingId[$pairingId]) && trim((string) $archivedPgnByPairingId[$pairingId]) !== '') {
            return true;
        }
        $row = $pairingById[$pairingId] ?? null;
        return $row && trim((string) ($row->live_pgn ?? '')) !== '';
    }

    private static function render_unified_profile_tournament_context_section(object $unifiedPlayer, int $tournamentId): void
    {
        if ($tournamentId <= 0 || !class_exists('ChessPodium_PlayerDirectory') || !class_exists('ChessPodium_ExportEngine')) {
            return;
        }
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return;
        }
        $tp = self::find_tournament_roster_row_for_unified_player($unifiedPlayer, $tournamentId);
        if (!$tp) {
            return;
        }
        $pid = (int) ($tp->id ?? 0);
        if ($pid <= 0) {
            return;
        }
        $players = self::get_players($tournamentId);
        $pairings = self::get_pairings_detailed($tournamentId);
        $statsMap = ChessPodium_ExportEngine::get_player_stats($players, $pairings);
        $st = $statsMap[$pid] ?? null;
        $playersById = [];
        foreach ($players as $pl) {
            $playersById[(int) ($pl->id ?? 0)] = $pl;
        }
        $pairingById = [];
        foreach ($pairings as $m) {
            $pairingById[(int) ($m->id ?? 0)] = $m;
        }
        $displayName = trim((string) ($unifiedPlayer->display_name ?? ''));
        $fideNorm = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($unifiedPlayer->fide_id ?? ''));
        $stand = self::compute_standings($tournamentId);
        $pos = null;
        $pts = null;
        foreach ($stand as $i => $row) {
            if (self::unified_player_row_matches_standings($displayName, $fideNorm, $row)) {
                $pos = $i + 1;
                $pts = (float) ($row['points'] ?? 0);
                break;
            }
        }
        $rating = (int) ($tp->rating ?? 0);
        $perf = $st ? (int) ($st['performance'] ?? $rating) : $rating;
        $aro = ($st && isset($st['aro']) && $st['aro'] !== null) ? (int) $st['aro'] : null;
        $eloV = $st ? (int) ($st['elo_variation'] ?? 0) : 0;
        $eloStr = $eloV > 0 ? '+' . $eloV : (string) $eloV;
        $opps = ($st && isset($st['opponents']) && is_array($st['opponents'])) ? $st['opponents'] : [];
        $oppPairingIds = [];
        foreach ($opps as $ox) {
            $opid = (int) ($ox['pairing_id'] ?? 0);
            if ($opid > 0) {
                $oppPairingIds[] = $opid;
            }
        }
        $archivedPgnByPairingId = $oppPairingIds !== [] ? self::get_latest_archived_pgns_by_pairing_ids($tournamentId, $oppPairingIds) : [];
        ?>
        <div class="cp-prof-context" style="margin-top:1.5rem;padding:1.25rem;border:1px solid #c7d2fe;border-radius:14px;background:linear-gradient(135deg,#f8fafc 0%,#eef2ff 100%);">
            <h2 style="font-size:1.15rem;margin:0 0 0.5rem 0;"><?php esc_html_e('From this tournament', 'chess-podium'); ?></h2>
            <p style="margin:0 0 0.75rem 0;font-weight:600;">
                <a href="<?php echo esc_url(self::get_tournament_pretty_url($tournamentId)); ?>"><?php echo esc_html((string) ($t->name ?? '')); ?></a>
            </p>
            <div style="display:flex;flex-wrap:wrap;gap:12px;color:#334155;font-size:0.95rem;margin-bottom:1rem;">
                <?php if ($pos !== null): ?>
                    <span><strong><?php esc_html_e('Position', 'chess-podium'); ?>:</strong> <?php echo (int) $pos; ?></span>
                <?php endif; ?>
                <?php if ($pts !== null): ?>
                    <span><strong><?php esc_html_e('Points', 'chess-podium'); ?>:</strong> <?php echo esc_html(number_format((float) $pts, 1)); ?></span>
                <?php endif; ?>
                <?php if ($rating > 0): ?>
                    <span><strong><?php esc_html_e('Rating', 'chess-podium'); ?>:</strong> <?php echo (int) $rating; ?></span>
                <?php endif; ?>
                <span><strong><?php esc_html_e('Performance', 'chess-podium'); ?>:</strong> <?php echo (int) $perf; ?></span>
                <?php if ($aro !== null): ?>
                    <span><strong><?php esc_html_e('ARO', 'chess-podium'); ?>:</strong> <?php echo (int) $aro; ?></span>
                <?php endif; ?>
                <span><strong><?php esc_html_e('Variation', 'chess-podium'); ?>:</strong> <?php echo esc_html($eloStr); ?></span>
            </div>
            <h3 style="font-size:1rem;margin:0 0 0.5rem 0;"><?php esc_html_e('Games in this tournament', 'chess-podium'); ?></h3>
            <div style="overflow-x:auto;">
                <table style="width:100%;border-collapse:collapse;background:#fff;border-radius:8px;">
                    <thead>
                        <tr style="background:#f1f5f9;">
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Round', 'chess-podium'); ?></th>
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Opponent', 'chess-podium'); ?></th>
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Color', 'chess-podium'); ?></th>
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Result', 'chess-podium'); ?></th>
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Opp. Elo', 'chess-podium'); ?></th>
                            <th style="text-align:left;padding:8px 10px;border-bottom:1px solid #e2e8f0;"><?php esc_html_e('Game', 'chess-podium'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php
                        if ($opps === []) {
                            echo '<tr><td colspan="6" style="padding:10px;">—</td></tr>';
                        } else {
                            foreach ($opps as $o) {
                                $rno = (int) ($o['round'] ?? 0);
                                $oppName = trim((string) ($o['opponent'] ?? ''));
                                $oppId = (int) ($o['opponent_id'] ?? 0);
                                if ($oppName === '' && $oppId > 0 && isset($playersById[$oppId])) {
                                    $oppName = trim((string) ($playersById[$oppId]->name ?? ''));
                                }
                                $colorRaw = strtolower((string) ($o['color'] ?? ''));
                                $colorLabel = $colorRaw === 'white' ? __('White', 'chess-podium') : ($colorRaw === 'black' ? __('Black', 'chess-podium') : '—');
                                $resSym = (string) ($o['result'] ?? '-');
                                $oppElo = isset($o['opponent_rating']) ? (int) $o['opponent_rating'] : 0;
                                if ($oppElo <= 0 && $oppId > 0 && isset($playersById[$oppId])) {
                                    $oppElo = (int) ($playersById[$oppId]->rating ?? 0);
                                }
                                $pairingId = (int) ($o['pairing_id'] ?? 0);
                                $hasPgn = self::pairing_row_has_pgn_content($pairingId, $pairingById, $archivedPgnByPairingId);
                                $gameUrl = ($pairingId > 0 && $hasPgn) ? self::get_game_url($pairingId, $tournamentId) : '';
                                ?>
                                <tr>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;"><?php echo (int) $rno; ?></td>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;">
                                        <?php
                                        if ($oppId > 0 && $oppName !== '' && strcasecmp($oppName, 'BYE') !== 0) {
                                            echo '<a href="' . esc_url(self::build_player_profile_url($tournamentId, $oppId)) . '">' . esc_html($oppName) . '</a>';
                                        } else {
                                            echo esc_html($oppName !== '' ? $oppName : '—');
                                        }
                                        ?>
                                    </td>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;"><?php echo esc_html($colorLabel); ?></td>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;"><?php echo esc_html($resSym); ?></td>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;"><?php echo $oppElo > 0 ? (int) $oppElo : '—'; ?></td>
                                    <td style="padding:8px 10px;border-bottom:1px solid #f1f5f9;">
                                        <?php if ($gameUrl !== ''): ?>
                                            <a href="<?php echo esc_url($gameUrl); ?>" rel="noopener"><?php esc_html_e('Open', 'chess-podium'); ?></a>
                                        <?php else: ?>
                                            —
                                        <?php endif; ?>
                                    </td>
                                </tr>
                                <?php
                            }
                        }
                        ?>
                    </tbody>
                </table>
            </div>
        </div>
        <?php
    }

    private static function render_unified_profile_visual_stats_section(array $statsPayload): void
    {
        $series = $statsPayload['series'] ?? [];
        $totals = $statsPayload['totals'] ?? ['w' => 0, 'd' => 0, 'l' => 0];
        $tw = (int) ($totals['w'] ?? 0);
        $td = (int) ($totals['d'] ?? 0);
        $tl = (int) ($totals['l'] ?? 0);
        if ($series === [] && ($tw + $td + $tl) === 0) {
            return;
        }
        $sum = $tw + $td + $tl;
        $pw = $sum > 0 ? ($tw / $sum) * 100 : 0;
        $pd = $sum > 0 ? ($td / $sum) * 100 : 0;
        $pl = $sum > 0 ? ($tl / $sum) * 100 : 0;
        ?>
        <div style="margin-top:1.75rem;">
            <h2 style="font-size:1.1rem;margin:0 0 0.75rem 0;"><?php esc_html_e('Career overview', 'chess-podium'); ?></h2>
            <div class="cp-prof-grid">
                <div class="cp-prof-card">
                    <h3 style="margin:0 0 0.5rem 0;font-size:0.95rem;"><?php esc_html_e('Win / draw / loss share', 'chess-podium'); ?></h3>
                    <?php if ($sum > 0): ?>
                        <div class="cp-prof-pie" style="background:conic-gradient(#16a34a 0 <?php echo esc_attr((string) $pw); ?>%, #ca8a04 <?php echo esc_attr((string) $pw); ?>% <?php echo esc_attr((string) ($pw + $pd)); ?>%, #dc2626 <?php echo esc_attr((string) ($pw + $pd)); ?>% 100%);"></div>
                        <p style="text-align:center;margin:0;font-size:0.9rem;color:#475569;">
                            <span style="color:#16a34a;font-weight:600;"><?php echo (int) $tw; ?> W</span> ·
                            <span style="color:#ca8a04;font-weight:600;"><?php echo (int) $td; ?> D</span> ·
                            <span style="color:#dc2626;font-weight:600;"><?php echo (int) $tl; ?> L</span>
                        </p>
                    <?php else: ?>
                        <p class="description"><?php esc_html_e('No rated games counted yet.', 'chess-podium'); ?></p>
                    <?php endif; ?>
                </div>
                <?php
                if (count($series) >= 1) {
                    $ratings = array_map(static fn ($r) => (int) ($r['rating'] ?? 0), $series);
                    $perfs = array_map(static fn ($r) => (int) ($r['performance'] ?? 0), $series);
                    $eloVs = array_map(static fn ($r) => (int) ($r['elo_var'] ?? 0), $series);
                    $rMin = min($ratings) ?: 0;
                    $rMax = max($ratings) ?: 0;
                    if ($rMax - $rMin < 40) {
                        $rMin = max(0, $rMin - 30);
                        $rMax = $rMax + 30;
                    }
                    $pMin = min($perfs) ?: 0;
                    $pMax = max($perfs) ?: 0;
                    if ($pMax - $pMin < 40) {
                        $pMin = max(0, $pMin - 30);
                        $pMax = $pMax + 30;
                    }
                    $n = count($series);
                    $w = 360;
                    $h = 140;
                    $pad = 24;
                    $iw = $w - 2 * $pad;
                    $ih = $h - 2 * $pad;
                    $ptsR = [];
                    $ptsP = [];
                    foreach ($series as $i => $row) {
                        $xi = $n <= 1 ? $pad + $iw / 2 : $pad + ($i / ($n - 1)) * $iw;
                        $rv = (int) ($row['rating'] ?? 0);
                        $pv = (int) ($row['performance'] ?? 0);
                        $yr = $ih - (($rv - $rMin) / max(1, $rMax - $rMin)) * $ih + $pad;
                        $yp = $ih - (($pv - $pMin) / max(1, $pMax - $pMin)) * $ih + $pad;
                        $ptsR[] = (int) round($xi) . ',' . (int) round($yr);
                        $ptsP[] = (int) round($xi) . ',' . (int) round($yp);
                    }
                    if ($n === 1 && isset($ptsR[0], $ptsP[0])) {
                        $pr = explode(',', $ptsR[0]);
                        $pp = explode(',', $ptsP[0]);
                        if (count($pr) === 2 && count($pp) === 2) {
                            $x1 = (int) $pr[0];
                            $y1r = (int) $pr[1];
                            $y1p = (int) $pp[1];
                            $ptsR[] = ($x1 + 36) . ',' . $y1r;
                            $ptsP[] = ($x1 + 36) . ',' . $y1p;
                        }
                    }
                    $lineR = implode(' ', $ptsR);
                    $lineP = implode(' ', $ptsP);
                    ?>
                    <div class="cp-prof-card" style="grid-column:1/-1;">
                        <h3 style="margin:0 0 0.5rem 0;font-size:0.95rem;"><?php esc_html_e('Rating & performance by tournament', 'chess-podium'); ?></h3>
                        <svg viewBox="0 0 <?php echo (int) $w; ?> <?php echo (int) $h; ?>" style="width:100%;max-width:520px;height:auto;display:block;" role="img" aria-label="chart">
                            <rect x="0" y="0" width="<?php echo (int) $w; ?>" height="<?php echo (int) $h; ?>" fill="#fafafa" rx="8"/>
                            <polyline fill="none" stroke="#2563eb" stroke-width="2.5" points="<?php echo esc_attr($lineR); ?>"/>
                            <polyline fill="none" stroke="#9333ea" stroke-width="2" stroke-dasharray="4 3" points="<?php echo esc_attr($lineP); ?>"/>
                        </svg>
                        <p style="margin:0.35rem 0 0;font-size:0.8rem;color:#64748b;">
                            <span style="color:#2563eb;font-weight:600;">●</span> <?php esc_html_e('Start rating', 'chess-podium'); ?>
                            &nbsp;·&nbsp;
                            <span style="color:#9333ea;font-weight:600;">●</span> <?php esc_html_e('Performance', 'chess-podium'); ?>
                        </p>
                    </div>
                    <div class="cp-prof-card" style="grid-column:1/-1;">
                        <h3 style="margin:0 0 0.5rem 0;font-size:0.95rem;"><?php esc_html_e('Elo change by tournament (estimate)', 'chess-podium'); ?></h3>
                        <?php
                        $maxAbs = 1;
                        foreach ($eloVs as $ev) {
                            $maxAbs = max($maxAbs, abs($ev));
                        }
                        foreach ($series as $i => $row) {
                            $ev = (int) ($row['elo_var'] ?? 0);
                            $pct = $maxAbs > 0 ? min(100, (abs($ev) / $maxAbs) * 100) : 0;
                            $fullName = (string) ($row['name'] ?? '');
                            $label = function_exists('mb_strlen') && function_exists('mb_substr') && mb_strlen($fullName) > 40
                                ? mb_substr($fullName, 0, 39) . '…'
                                : (strlen($fullName) > 40 ? substr($fullName, 0, 39) . '…' : $fullName);
                            $barColor = $ev >= 0 ? '#16a34a' : '#dc2626';
                            ?>
                            <div style="margin-bottom:10px;">
                                <div style="display:flex;justify-content:space-between;font-size:0.8rem;color:#475569;margin-bottom:2px;">
                                    <span title="<?php echo esc_attr($fullName); ?>"><?php echo esc_html($label); ?></span>
                                    <span style="font-weight:600;"><?php echo esc_html($ev > 0 ? '+' . $ev : (string) $ev); ?></span>
                                </div>
                                <div class="cp-prof-bar">
                                    <span style="position:absolute;top:0;<?php echo $ev >= 0 ? 'left:0;' : 'right:0;'; ?>width:<?php echo esc_attr((string) $pct); ?>%;height:100%;background:<?php echo esc_attr($barColor); ?>;border-radius:6px;"></span>
                                </div>
                            </div>
                            <?php
                        }
                        ?>
                    </div>
                    <?php
                }
                ?>
            </div>
        </div>
        <?php
    }

    private static function render_unified_player_profile_page(string $playerSlug): void
    {
        if (!class_exists('ChessPodium_PlayerDirectory')) {
            status_header(404);
            echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '"><title>' . esc_html__('Player not found', 'chess-podium') . '</title></head><body><h1>' . esc_html__('Player not found', 'chess-podium') . '</h1></body></html>';
            return;
        }
        $playerSlug = sanitize_title($playerSlug);
        $player = ChessPodium_PlayerDirectory::get_by_slug($playerSlug);
        if (!$player) {
            status_header(404);
            echo '<!DOCTYPE html><html><head><meta charset="' . esc_attr(get_bloginfo('charset')) . '"><title>' . esc_html__('Player not found', 'chess-podium') . '</title></head><body><h1>' . esc_html__('Player not found', 'chess-podium') . '</h1></body></html>';
            return;
        }
        status_header(200);
        nocache_headers();

        $name = (string) ($player->display_name ?? '');
        $country = (string) ($player->country ?? '');
        $bio = (string) ($player->bio ?? '');
        $fideId = (string) ($player->fide_id ?? '');
        $avatarAttachmentId = isset($player->avatar_attachment_id) ? (int) $player->avatar_attachment_id : 0;
        $avatarUrl = $avatarAttachmentId > 0 ? (string) wp_get_attachment_image_url($avatarAttachmentId, 'medium') : '';
        $lichess = trim((string) ($player->lichess_username ?? ''));
        $chesscom = trim((string) ($player->chesscom_username ?? ''));
        $fideRating = 0;
        if ($fideId !== '' && class_exists('ChessPodium_PlayerDirectory')) {
            $fidNorm = ChessPodium_PlayerDirectory::normalize_fide_id_alias($fideId);
            if ($fidNorm !== '') {
                foreach (self::get_tournaments() as $tobj) {
                    $tid = (int) ($tobj->id ?? 0);
                    if ($tid <= 0) {
                        continue;
                    }
                    foreach (self::get_players($tid) as $tp) {
                        $tpF = ChessPodium_PlayerDirectory::normalize_fide_id_alias((string) ($tp->fide_id ?? ''));
                        if ($tpF === $fidNorm) {
                            $fideRating = max($fideRating, (int) ($tp->rating ?? 0));
                        }
                    }
                }
            }
        }
        $gpBlocks = [];
        $socialElo = null;
        if (class_exists('ChessPodium_PlayerDirectory') && class_exists('ChessPodium_GrandPrix')) {
            global $wpdb;
            $gpMap = ChessPodium_PlayerDirectory::get_gp_map_table();
            $rows = $wpdb->get_results($wpdb->prepare("SELECT grand_prix_id, player_key FROM $gpMap WHERE player_id = %d", (int) $player->id));
            foreach ((array) $rows as $r) {
                $gpId = (int) ($r->grand_prix_id ?? 0);
                $pk = (string) ($r->player_key ?? '');
                if ($gpId <= 0 || $pk === '') {
                    continue;
                }
                $gp = ChessPodium_GrandPrix::get($gpId);
                if (!$gp) {
                    continue;
                }
                $combined = ChessPodium_GrandPrix::get_combined_standings($gpId);
                $me = null;
                foreach ($combined as $c) {
                    if (($c['player_key'] ?? '') === $pk) {
                        $me = $c;
                        break;
                    }
                }
                if ($me && $socialElo === null && isset($me['social_elo'])) {
                    $socialElo = $me['social_elo'];
                }
                $items = [];
                foreach (ChessPodium_GrandPrix::get_tournaments_with_results($gpId) as $gt) {
                    $tid = (int) ($gt->tournament_id ?? 0);
                    if ($tid <= 0) {
                        continue;
                    }
                    $stand = self::compute_standings($tid);
                    $pos = null;
                    $pts = null;
                    foreach ($stand as $i => $s) {
                        $match = (!empty($fideId) && (string) ($s['fide_id'] ?? '') === $fideId)
                            || (strcasecmp(trim((string) ($s['name'] ?? '')), trim($name)) === 0);
                        if ($match) {
                            $pos = $i + 1;
                            $pts = (float) ($s['points'] ?? 0);
                            break;
                        }
                    }
                    if ($pos !== null) {
                        $items[] = [
                            'tournament_id' => $tid,
                            'tournament_name' => (string) ($gt->tournament_name ?? ''),
                            'position' => (int) $pos,
                            'points' => (float) ($pts ?? 0),
                        ];
                    }
                }
                $gpBlocks[] = [
                    'gp_id' => $gpId,
                    'gp_name' => (string) ($gp->name ?? ''),
                    'items' => $items,
                ];
            }
        }

        $tournamentHistory = self::collect_unified_player_tournament_history($player);
        $statsPayload = self::collect_unified_player_stats_series($player);
        $highlightTid = max(0, (int) ($_GET['cp_tournament'] ?? 0));

        ?>
        <!doctype html>
        <html <?php language_attributes(); ?>>
        <head>
            <meta charset="<?php bloginfo('charset'); ?>">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title><?php echo esc_html($name !== '' ? ($name . ' – ' . get_bloginfo('name')) : get_bloginfo('name')); ?></title>
            <link rel="canonical" href="<?php echo esc_url(self::get_unified_player_profile_url($playerSlug)); ?>">
            <?php wp_head(); ?>
            <style id="cp-unified-player-prof">
                .cp-prof-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.25rem;margin-top:0.5rem;}
                .cp-prof-card{border:1px solid #e5e7eb;border-radius:12px;padding:1rem;background:#fafafa;}
                .cp-prof-pie{width:140px;height:140px;border-radius:50%;margin:0 auto 8px;box-shadow:inset 0 0 0 1px rgba(0,0,0,.06);}
                .cp-prof-bar{height:10px;border-radius:6px;background:#f1f5f9;position:relative;overflow:hidden;}
            </style>
        </head>
        <body <?php body_class('cp-player-profile-page'); ?>>
        <?php if (function_exists('wp_body_open')) { wp_body_open(); } ?>
        <div class="cp-player-profile-wrap" style="max-width:980px;margin:0 auto;padding:1.5rem 1rem;">
            <div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap;">
                <div style="width:120px;flex:0 0 120px;">
                    <?php if ($avatarUrl !== ''): ?>
                        <img src="<?php echo esc_url($avatarUrl); ?>" alt="" style="width:120px;height:120px;object-fit:cover;border-radius:12px;border:1px solid #e5e7eb;">
                    <?php else: ?>
                        <div style="width:120px;height:120px;border-radius:12px;border:1px solid #e5e7eb;background:#f3f4f6;"></div>
                    <?php endif; ?>
                </div>
                <div style="flex:1;min-width:240px;">
                    <h1 style="margin:0 0 0.25rem 0;"><?php echo esc_html($name); ?></h1>
                    <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;color:#374151;">
                        <?php if ($country !== ''): ?>
                            <span><?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag_img($country, $country)); ?></span>
                        <?php endif; ?>
                        <?php if ($fideId !== ''): ?>
                            <?php
                            $fideProfileUrl = self::get_fide_ratings_profile_url($fideId);
                            ?>
                            <span><strong><?php esc_html_e('FIDE ID', 'chess-podium'); ?>:</strong>
                                <?php if ($fideProfileUrl !== ''): ?>
                                    <a href="<?php echo esc_url($fideProfileUrl); ?>" rel="noopener noreferrer" target="_blank"><?php echo esc_html($fideId); ?></a>
                                <?php else: ?>
                                    <?php echo esc_html($fideId); ?>
                                <?php endif; ?>
                            </span>
                        <?php endif; ?>
                        <?php if ($fideRating > 0): ?>
                            <span><strong><?php esc_html_e('FIDE Elo', 'chess-podium'); ?>:</strong> <?php echo (int) $fideRating; ?></span>
                        <?php endif; ?>
                        <?php if ($socialElo !== null): ?>
                            <span><strong><?php esc_html_e('Social ELO', 'chess-podium'); ?>:</strong> <?php echo esc_html((string) (int) $socialElo); ?></span>
                        <?php endif; ?>
                    </div>
                    <div style="margin-top:0.75rem;display:flex;gap:10px;flex-wrap:wrap;">
                        <?php if ($lichess !== ''): ?>
                            <a class="button button-secondary" href="<?php echo esc_url('https://lichess.org/@/' . rawurlencode($lichess)); ?>" rel="noopener" target="_blank"><?php echo esc_html__('Lichess profile', 'chess-podium'); ?></a>
                        <?php endif; ?>
                        <?php if ($chesscom !== ''): ?>
                            <a class="button button-secondary" href="<?php echo esc_url('https://www.chess.com/member/' . rawurlencode($chesscom)); ?>" rel="noopener" target="_blank"><?php echo esc_html__('Chess.com profile', 'chess-podium'); ?></a>
                        <?php endif; ?>
                    </div>
                </div>
            </div>

            <?php
            if ($highlightTid > 0) {
                self::render_unified_profile_tournament_context_section($player, $highlightTid);
            }
            ?>

            <?php if ($bio !== ''): ?>
                <div style="margin-top:1.25rem;">
                    <h2 style="font-size:1.1rem;margin:0 0 0.5rem 0;"><?php esc_html_e('Biography', 'chess-podium'); ?></h2>
                    <div style="color:#111827;line-height:1.55;"><?php echo wp_kses_post(wpautop($bio)); ?></div>
                </div>
            <?php endif; ?>

            <?php self::render_unified_profile_visual_stats_section($statsPayload); ?>

            <div style="margin-top:1.5rem;">
                <h2 style="font-size:1.1rem;margin:0 0 0.5rem 0;"><?php esc_html_e('Tournament history', 'chess-podium'); ?></h2>
                <?php if (empty($tournamentHistory)): ?>
                    <p class="description" style="margin-top:0;"><?php esc_html_e('No matching tournaments on this site yet.', 'chess-podium'); ?></p>
                <?php else: ?>
                    <table style="width:100%;border-collapse:collapse;">
                        <thead>
                            <tr>
                                <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Tournament', 'chess-podium'); ?></th>
                                <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Position', 'chess-podium'); ?></th>
                                <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Points', 'chess-podium'); ?></th>
                            </tr>
                        </thead>
                        <tbody>
                            <?php foreach ($tournamentHistory as $th): ?>
                                <tr>
                                    <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;">
                                        <a href="<?php echo esc_url(self::get_unified_player_profile_url($playerSlug, (int) $th['tournament_id'])); ?>"><?php echo esc_html((string) $th['tournament_name']); ?></a>
                                    </td>
                                    <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;"><?php echo (int) $th['position']; ?></td>
                                    <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;"><?php echo esc_html(number_format((float) $th['points'], 1)); ?></td>
                                </tr>
                            <?php endforeach; ?>
                        </tbody>
                    </table>
                <?php endif; ?>
            </div>

            <div style="margin-top:1.5rem;">
                <h2 style="font-size:1.1rem;margin:0 0 0.5rem 0;"><?php esc_html_e('Grand Prix tournaments', 'chess-podium'); ?></h2>
                <?php if (empty($gpBlocks)): ?>
                    <p class="description" style="margin-top:0;"><?php esc_html_e('This player is not linked to a Grand Prix yet.', 'chess-podium'); ?></p>
                <?php else: ?>
                    <?php foreach ($gpBlocks as $b): ?>
                        <h3 style="margin:0.75rem 0 0.5rem 0;font-size:1rem;"><?php echo esc_html($b['gp_name']); ?></h3>
                        <?php if (empty($b['items'])): ?>
                            <p class="description" style="margin-top:0;"><?php esc_html_e('No tournaments found for this Grand Prix.', 'chess-podium'); ?></p>
                        <?php else: ?>
                            <table style="width:100%;border-collapse:collapse;">
                                <thead>
                                    <tr>
                                        <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Tournament', 'chess-podium'); ?></th>
                                        <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Position', 'chess-podium'); ?></th>
                                        <th style="text-align:left;padding:6px 8px;border-bottom:1px solid #e5e7eb;"><?php esc_html_e('Points', 'chess-podium'); ?></th>
                                    </tr>
                                </thead>
                                <tbody>
                                    <?php foreach ($b['items'] as $it): ?>
                                        <tr>
                                            <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;">
                                                <a href="<?php echo esc_url(self::get_tournament_pretty_url((int) $it['tournament_id'])); ?>"><?php echo esc_html((string) $it['tournament_name']); ?></a>
                                            </td>
                                            <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;"><?php echo (int) $it['position']; ?></td>
                                            <td style="padding:6px 8px;border-bottom:1px solid #f3f4f6;"><?php echo esc_html(number_format((float) $it['points'], 1)); ?></td>
                                        </tr>
                                    <?php endforeach; ?>
                                </tbody>
                            </table>
                        <?php endif; ?>
                    <?php endforeach; ?>
                <?php endif; ?>
            </div>
        </div>
        <?php wp_footer(); ?>
        </body>
        </html>
        <?php
    }

    private static function render_live_dashboard(): void
    {
        $restUrl = rest_url('chess-podium/v1/live/current');
        $tid = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
        if ($tid <= 0) {
            $tid = max(0, (int) get_query_var('tournament_id'));
        }
        if ($tid <= 0) {
            $slugLive = sanitize_title((string) get_query_var('tournament_slug'));
            if ($slugLive === '' && isset($_GET['tournament_slug'])) {
                $slugLive = sanitize_title((string) wp_unslash($_GET['tournament_slug']));
            }
            if ($slugLive !== '') {
                $tid = self::find_tournament_id_by_seo_slug($slugLive);
            }
        }
        if ($tid > 0) {
            $restUrl = add_query_arg('tournament_id', $tid, $restUrl);
        }
        $tournamentForLogo = $tid > 0 ? self::get_tournament($tid) : self::get_tournament_in_progress();
        $liveDashboardLogoUrl = '';
        if ($tournamentForLogo && class_exists('ChessPodium_PdfBrochure')) {
            $liveDashboardLogoUrl = ChessPodium_PdfBrochure::get_meta((int) $tournamentForLogo->id, 'brochure_logo') ?: '';
        }
        status_header(200);
        nocache_headers();
        ?>
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="<?php bloginfo('charset'); ?>">
            <meta name="viewport" content="width=device-width, initial-scale=1">
            <title><?php esc_html_e('Live - Tournament', 'chess-podium'); ?></title>
            <style>
                * { box-sizing: border-box; }
                body { margin: 0; padding: 0; font-family: 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; min-height: 100vh; overflow-x: hidden; }
                .cp-live { max-width: 100%; padding: 2rem; }
                .cp-live-logo { text-align: center; margin: 0 0 1.25rem; }
                .cp-live-logo img { max-height: 140px; max-width: min(100%, 420px); width: auto; object-fit: contain; vertical-align: middle; }
                .cp-live h1 { font-size: 2.5rem; margin: 0 0 1rem; color: #58a6ff; }
                .cp-live-section { margin-bottom: 2rem; }
                .cp-live-section h2 { font-size: 1.5rem; margin: 0 0 1rem; color: #8b949e; border-bottom: 2px solid #30363d; padding-bottom: 0.5rem; }
                .cp-live-table { width: 100%; border-collapse: collapse; font-size: 1.25rem; }
                .cp-live-table th, .cp-live-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #21262d; }
                .cp-live-table th { color: #8b949e; font-weight: 600; }
                .cp-live-table tr:hover { background: rgba(88, 166, 255, 0.08); }
                .cp-live-round { font-size: 1.1rem; color: #58a6ff; margin-bottom: 0.5rem; }
                .cp-live-no-data { text-align: center; padding: 3rem; color: #8b949e; font-size: 1.2rem; }
                .cp-norm-badge { display: inline-block; margin-left: 0.35em; padding: 0.15em 0.5em; font-size: 0.75em; font-weight: 600; color: #58a6ff; background: rgba(88,166,255,0.15); border: 1px solid #58a6ff; border-radius: 4px; vertical-align: middle; }
                .cp-live-scroll { max-height: 50vh; overflow-y: auto; }
                .cp-live-scroll::-webkit-scrollbar { width: 8px; }
                .cp-live-scroll::-webkit-scrollbar-track { background: #21262d; }
                .cp-live-scroll::-webkit-scrollbar-thumb { background: #484f58; border-radius: 4px; }
                .cp-public-footer { text-align: center; margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid #30363d; color: #8b949e; font-size: 0.9em; line-height: 1.6; }
                .cp-public-footer.cp-public-footer--dark a { color: #58a6ff !important; text-decoration: none; }
                .cp-public-footer.cp-public-footer--dark a:hover { text-decoration: underline; }
                .cp-public-footer .cp-footer-brand { display: inline-flex; align-items: center; gap: 0.35em; justify-content: center; }
                .cp-public-footer .cp-footer-logo { vertical-align: middle; opacity: 0.9; }
            </style>
        </head>
        <body>
        <div class="cp-live">
            <?php if ($liveDashboardLogoUrl !== ''): ?>
            <div class="cp-live-logo">
                <img src="<?php echo esc_url($liveDashboardLogoUrl); ?>" alt="" loading="eager">
            </div>
            <?php endif; ?>
            <h1 id="cp-live-title"><?php esc_html_e('Live Tournament', 'chess-podium'); ?></h1>
            <p id="cp-live-round" class="cp-live-round"></p>
            <div class="cp-live-section">
                <h2><?php esc_html_e('Standings', 'chess-podium'); ?></h2>
                <div class="cp-live-scroll">
                    <table class="cp-live-table" id="cp-live-standings">
                        <thead><tr></tr></thead>
                        <tbody></tbody>
                    </table>
                </div>
            </div>
            <div class="cp-live-section">
                <h2><?php esc_html_e('Pairings', 'chess-podium'); ?></h2>
                <div class="cp-live-scroll">
                    <table class="cp-live-table" id="cp-live-pairings">
                        <thead><tr><th><?php esc_html_e('Board', 'chess-podium'); ?></th><th><?php esc_html_e('White', 'chess-podium'); ?></th><th><?php esc_html_e('Pts', 'chess-podium'); ?></th><th><?php esc_html_e('Nr', 'chess-podium'); ?></th><th><?php esc_html_e('Result', 'chess-podium'); ?></th><th><?php esc_html_e('Nr', 'chess-podium'); ?></th><th><?php esc_html_e('Pts', 'chess-podium'); ?></th><th><?php esc_html_e('Black', 'chess-podium'); ?></th></tr></thead>
                        <tbody></tbody>
                    </table>
                </div>
            </div>
            <?php echo self::render_branding_footer_html(true); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
        </div>
        <script>
        (function(){
            var url = <?php echo wp_json_encode(esc_url($restUrl)); ?>;
            var flagsBase = <?php echo wp_json_encode(esc_url(plugins_url('assets/img/flags', CHESS_PODIUM_PLUGIN_FILE))); ?>;
            var lblPos='<?php echo esc_js(__('Pos', 'chess-podium')); ?>', lblNr='<?php echo esc_js(__('Nr', 'chess-podium')); ?>', lblPlayer='<?php echo esc_js(__('Player', 'chess-podium')); ?>', lblPts='<?php echo esc_js(__('Pts', 'chess-podium')); ?>';
            function esc(s){if(s==null||s===undefined)return'';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
            function flagImg(iso){if(!iso||typeof iso!=='string'||iso.length!==2)return'';var c=iso.toLowerCase();if(!/^[a-z]{2}$/.test(c))return'';return'<img src="'+flagsBase+'/'+c+'.png" alt="" width="20" height="15" style="vertical-align:middle;margin-right:4px;">';}
            function fetchData(){
                fetch(url).then(function(r){return r.json();}).then(function(data){
                    var t=data.tournament, st=data.standings||[], pr=data.pairings||[];
                    document.getElementById('cp-live-title').textContent=t?t.name:'<?php echo esc_js(__('No tournament in progress', 'chess-podium')); ?>';
                    document.getElementById('cp-live-round').textContent=t?('<?php echo esc_js(__('Round', 'chess-podium')); ?> '+t.current_round+' / '+t.rounds_total):'';
                    var tbCols=t&&t.tiebreaker_columns&&t.tiebreaker_columns.length?t.tiebreaker_columns:['<?php echo esc_js(__('Buchholz', 'chess-podium')); ?>'];
                    var tbKeys=t&&t.tiebreaker_keys&&t.tiebreaker_keys.length?t.tiebreaker_keys:['buchholz'];
                    var stHead=document.querySelector('#cp-live-standings thead tr');
                    stHead.innerHTML='<th>'+lblPos+'</th><th>'+lblNr+'</th><th>'+lblPlayer+'</th><th>'+lblPts+'</th>';
                    tbCols.forEach(function(l){stHead.innerHTML+='<th>'+esc(l)+'</th>';});
                    var stBody=document.querySelector('#cp-live-standings tbody');
                    stBody.innerHTML='';
                    var colCount=4+tbCols.length;
                    if(st.length===0){stBody.innerHTML='<tr><td colspan="'+colCount+'" class="cp-live-no-data"><?php echo esc_js(__('No standings', 'chess-podium')); ?></td></tr>';}
                    else{st.forEach(function(s){var f=flagImg(s.country);
                        var nameStr=(s.title?esc(s.title)+' ':'')+esc(s.name)+(s.rating&&s.rating>0?' ('+esc(s.rating)+')':'');
                        var normParts=[];
                        if(s.gm_norm)normParts.push('GM norm');
                        if(s.im_norm)normParts.push('IM norm');
                        var normStr=normParts.length?normParts.join(', '):'';
                        if(normStr)nameStr+=' <span class="cp-norm-badge" title="'+esc(normStr)+'">'+esc(normStr)+'</span>';
                        var row='<tr><td>'+esc(s.pos)+'</td><td>'+esc(s.nr)+'</td><td>'+f+nameStr+'</td><td>'+esc(s.points!=null?s.points.toFixed(1):'-')+'</td>';
                        tbKeys.forEach(function(k){var v=s[k];row+='<td>'+(v===undefined||v===null?'-':(k==='rating'?esc(Math.round(v)):esc(Number(v).toFixed(2))))+'</td>';});
                        stBody.innerHTML+=row+'</tr>';});}
                    var prBody=document.querySelector('#cp-live-pairings tbody');
                    prBody.innerHTML='';
                    if(pr.length===0){prBody.innerHTML='<tr><td colspan="8" class="cp-live-no-data"><?php echo esc_js(__('No pairings', 'chess-podium')); ?></td></tr>';}
                    else{pr.forEach(function(p){var fw=flagImg(p.white_country);
                        var w=fw+(p.white_title?esc(p.white_title)+' ':'')+esc(p.white);var fb=p.is_bye?'':flagImg(p.black_country);
                        var b=p.is_bye?'-':fb+(p.black_title?esc(p.black_title)+' ':'')+esc(p.black);
                        prBody.innerHTML+='<tr><td>'+esc(p.board)+'</td><td>'+w+'</td><td>'+(p.white_pts!==null?'('+esc(p.white_pts)+')':'-')+'</td><td>'+esc(p.white_nr)+'</td><td>'+esc(p.result)+'</td><td>'+(p.black_nr!==null?esc(p.black_nr):'-')+'</td><td>'+(p.black_pts!==null?'('+esc(p.black_pts)+')':'-')+'</td><td>'+b+'</td></tr>';});}
                }).catch(function(){});
            }
            fetchData();
            setInterval(fetchData, 10000);
        })();
        </script>
        </body>
        </html>
        <?php
    }

    public static function enqueue_external_tournaments_scripts(string $hook): void
    {
        if ($hook !== 'chess-podium_page_chess-podium-external-tournaments') {
            return;
        }
        wp_enqueue_media();
        wp_register_script('chess-podium-ext-media', false, ['media-upload'], CHESS_PODIUM_VERSION, true);
        $ext_media_js = "(function(){var btn=document.getElementById('cp_ext_select_media');var input=document.getElementById('ext_preview');if(btn&&input&&typeof wp!=='undefined'&&wp.media){btn.addEventListener('click',function(){var frame=wp.media({title:'" . esc_js(__('Select preview image', 'chess-podium')) . "',library:{type:'image'},multiple:false,button:{text:'" . esc_js(__('Use image', 'chess-podium')) . "'}});frame.on('select',function(){var att=frame.state().get('selection').first().toJSON();if(att&&att.url)input.value=att.url;});frame.open();});}})();";
        wp_add_inline_script('chess-podium-ext-media', $ext_media_js);
        wp_enqueue_script('chess-podium-ext-media');
    }

    public static function admin_menu_icon_style(): void
    {
        wp_register_style('chess-podium-admin-icon', false, []);
        wp_enqueue_style('chess-podium-admin-icon');
        $icon = esc_url(plugins_url('assets/logo-chess-podium.png', CHESS_PODIUM_PLUGIN_FILE));
        wp_add_inline_style('chess-podium-admin-icon', '#toplevel_page_chess-podium .wp-menu-image img{display:none!important}#toplevel_page_chess-podium .wp-menu-image:before{content:""!important;background:url(' . $icon . ') center center no-repeat!important;background-size:20px 20px!important}');
    }

    public static function enqueue_admin_assets(string $hook): void
    {
        $screen = get_current_screen();
        if (!$screen || strpos($screen->id, self::MENU_SLUG) === false) {
            return;
        }
        wp_enqueue_style(
            'chess-podium-admin',
            plugins_url('assets/admin.css', CHESS_PODIUM_PLUGIN_FILE),
            [],
            '0.4.0'
        );
        wp_enqueue_script(
            'chess-podium-admin-tabs',
            plugins_url('assets/js/admin-tabs.js', CHESS_PODIUM_PLUGIN_FILE),
            [],
            CHESS_PODIUM_VERSION,
            true
        );
        wp_localize_script('chess-podium-admin-tabs', 'chessPodiumAdmin', array(
            'copyMsg' => __('Copied to clipboard.', 'chess-podium'),
        ));
        wp_enqueue_script(
            'chess-podium-admin-config-modal',
            plugins_url('assets/js/admin-config-modal.js', CHESS_PODIUM_PLUGIN_FILE),
            ['chess-podium-admin-tabs'],
            CHESS_PODIUM_VERSION,
            true
        );
        wp_enqueue_media();

        // Player manual photo picker (uses WP Media Library).
        wp_register_script('chess-podium-player-photo-admin', false, ['media-upload'], CHESS_PODIUM_VERSION, true);
        wp_localize_script('chess-podium-player-photo-admin', 'cpPlayerPhotoL10n', [
            'title' => __('Select player photo', 'chess-podium'),
            'button' => __('Use photo', 'chess-podium'),
        ]);
        $player_photo_js = "(function(){if(typeof wp==='undefined'||!wp.media)return;var frame=null;function q(s,r){return (r||document).querySelector(s);}function qs(s,r){return Array.prototype.slice.call((r||document).querySelectorAll(s));}function openPicker(btn){var row=btn.closest('tr');if(!row)return;var input=q('input.cp-player-photo-id',row);var img=q('img.cp-player-photo-preview',row);if(!input||!img)return;frame=wp.media({title:(window.cpPlayerPhotoL10n&&cpPlayerPhotoL10n.title)||'Select player photo',library:{type:'image'},multiple:false,button:{text:(window.cpPlayerPhotoL10n&&cpPlayerPhotoL10n.button)||'Use photo'}});frame.on('select',function(){var att=frame.state().get('selection').first().toJSON();if(!att||!att.id)return;input.value=String(att.id);var url=att.sizes&&att.sizes.thumbnail?att.sizes.thumbnail.url:att.url;if(url)img.src=url;img.style.display='inline-block';});frame.open();}function removePhoto(btn){var row=btn.closest('tr');if(!row)return;var input=q('input.cp-player-photo-id',row);var img=q('img.cp-player-photo-preview',row);if(input)input.value='';if(img){img.removeAttribute('src');img.style.display='none';}}document.addEventListener('click',function(e){var t=e.target;if(!t)return;if(t.classList&&t.classList.contains('cp-player-photo-select')){e.preventDefault();openPicker(t);}if(t.classList&&t.classList.contains('cp-player-photo-remove')){e.preventDefault();removePhoto(t);}});})();";
        wp_add_inline_script('chess-podium-player-photo-admin', $player_photo_js);
        wp_enqueue_script('chess-podium-player-photo-admin');
        if ($hook === 'chess-podium_page_chess-podium-sponsors' || ($screen && strpos((string) $screen->id, 'chess-podium-sponsors') !== false)) {
            ChessPodium_Sponsors::enqueue_admin_scripts();
            wp_register_script('chess-podium-sponsor-admin', false, ['jquery'], CHESS_PODIUM_VERSION, true);
            $sponsor_admin_js = "(function(){var modeAll=document.querySelector('input[name=\"sponsor_tournaments_mode\"][value=\"all\"]');var modeSelected=document.querySelector('input[name=\"sponsor_tournaments_mode\"][value=\"selected\"]');var checkboxes=document.querySelectorAll('input[name=\"sponsor_tournaments[]\"]');function toggleCheckboxes(){var disabled=modeAll&&modeAll.checked;checkboxes.forEach(function(cb){cb.disabled=disabled;});}if(modeAll)modeAll.addEventListener('change',toggleCheckboxes);if(modeSelected)modeSelected.addEventListener('change',toggleCheckboxes);toggleCheckboxes();})();";
            wp_add_inline_script('chess-podium-sponsor-admin', $sponsor_admin_js);
            wp_enqueue_script('chess-podium-sponsor-admin');
        }
        // Brochure builder + gallery lightbox (tournament edit page).
        wp_register_script('chess-podium-brochure-admin', false, ['jquery'], CHESS_PODIUM_VERSION, true);
        wp_localize_script('chess-podium-brochure-admin', 'cpBrochureL10n', [
            'general'       => __('General', 'chess-podium'),
            'age'           => __('Age group', 'chess-podium'),
            'rating'       => __('Rating band', 'chess-podium'),
            'position'     => __('Position', 'chess-podium'),
            'ageRange'     => __('e.g. U18', 'chess-podium'),
            'ratingRange'  => __('e.g. U2000', 'chess-podium'),
            'prize'        => __('Prize', 'chess-podium'),
            'deputyArbiter'=> __('Deputy chief arbiter', 'chess-podium'),
            'name'         => __('Name', 'chess-podium'),
            'email'        => __('Email', 'chess-podium'),
            'phone'        => __('Phone', 'chess-podium'),
        ]);
        $brochure_js = "(function(){var L=typeof cpBrochureL10n!=='undefined'?cpBrochureL10n:{};var pi=document.querySelectorAll('#cp-brochure-prizes p').length-2,ci=document.querySelectorAll('#cp-brochure-contacts p').length-1,di=document.querySelectorAll('#cp-brochure-deputy-arbiters p').length-1;document.querySelectorAll('.cp-remove-venue-photo').forEach(function(btn){btn.addEventListener('click',function(){this.closest('.cp-venue-photo-row').remove();});});var addPrize=document.querySelector('.cp-add-prize');if(addPrize)addPrize.addEventListener('click',function(){pi++;var c=document.getElementById('cp-brochure-prizes');var p=document.createElement('p');p.innerHTML='<select name=\"brochure_prize['+pi+'][category]\" style=\"width:100px;\"><option value=\"general\">'+(L.general||'General')+'</option><option value=\"age\">'+(L.age||'Age group')+'</option><option value=\"rating\">'+(L.rating||'Rating band')+'</option></select> <input type=\"text\" name=\"brochure_prize['+pi+'][position]\" placeholder=\"'+(L.position||'Position')+'\" style=\"width:70px;\"> <input type=\"text\" name=\"brochure_prize['+pi+'][age_range]\" placeholder=\"'+(L.ageRange||'e.g. U18')+'\" style=\"width:70px;\"> <input type=\"text\" name=\"brochure_prize['+pi+'][rating_range]\" placeholder=\"'+(L.ratingRange||'e.g. U2000')+'\" style=\"width:80px;\"> <input type=\"text\" name=\"brochure_prize['+pi+'][prize]\" placeholder=\"'+(L.prize||'Prize')+'\" style=\"width:180px;\">';c.insertBefore(p,c.lastElementChild);});var addDep=document.querySelector('.cp-add-deputy-arbiter');if(addDep)addDep.addEventListener('click',function(){di++;var c=document.getElementById('cp-brochure-deputy-arbiters');var p=document.createElement('p');p.innerHTML='<input type=\"text\" name=\"brochure_deputy_arbiter[]\" placeholder=\"'+(L.deputyArbiter||'Deputy chief arbiter')+'\" style=\"width:280px;\">';c.appendChild(p);});var addContact=document.querySelector('.cp-add-contact');if(addContact)addContact.addEventListener('click',function(){ci++;var c=document.getElementById('cp-brochure-contacts');var p=document.createElement('p');p.innerHTML='<input type=\"text\" name=\"brochure_contacts['+ci+'][name]\" placeholder=\"'+(L.name||'Name')+'\" style=\"width:150px;\"> <input type=\"email\" name=\"brochure_contacts['+ci+'][email]\" placeholder=\"'+(L.email||'Email')+'\" style=\"width:180px;\"> <input type=\"text\" name=\"brochure_contacts['+ci+'][phone]\" placeholder=\"'+(L.phone||'Phone')+'\" style=\"width:120px;\">';c.insertBefore(p,c.lastElementChild);});})();";
        wp_add_inline_script('chess-podium-brochure-admin', $brochure_js);
        $lightbox_js = "(function(){var lb=document.getElementById('cp-admin-lightbox');if(!lb)return;var img=document.getElementById('cp-admin-lightbox-img');var cap=document.getElementById('cp-admin-lightbox-caption');var thumbs=document.querySelectorAll('.cp-admin-gallery .cp-gallery-thumb');var items=[];thumbs.forEach(function(el,i){items.push({src:el.dataset.src||el.src,caption:el.dataset.caption||el.alt});el.onclick=function(){open(i);};});var idx=0;function open(i){idx=i;if(img){img.src=items[idx].src;}if(cap){cap.textContent=items[idx].caption;}lb.style.display='flex';document.body.style.overflow='hidden';}function close(){lb.style.display='none';document.body.style.overflow='';}function prev(){idx=(idx-1+items.length)%items.length;open(idx);}function next(){idx=(idx+1)%items.length;open(idx);}var closeBtn=lb.querySelector('.cp-lightbox-close');if(closeBtn)closeBtn.onclick=close;var prevBtn=lb.querySelector('.cp-lightbox-prev');if(prevBtn)prevBtn.onclick=prev;var nextBtn=lb.querySelector('.cp-lightbox-next');if(nextBtn)nextBtn.onclick=next;lb.onclick=function(e){if(e.target===lb)close();};document.addEventListener('keydown',function(e){if(lb.style.display!=='flex')return;if(e.key==='Escape')close();if(e.key==='ArrowLeft')prev();if(e.key==='ArrowRight')next();});})();";
        wp_add_inline_script('chess-podium-brochure-admin', $lightbox_js);
        wp_enqueue_script('chess-podium-brochure-admin');
    }

    public static function maybe_redirect_to_onboarding(): void
    {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }
        if (!get_option(self::ONBOARDING_REDIRECT_OPTION)) {
            return;
        }
        if (wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) {
            return;
        }
        delete_option(self::ONBOARDING_REDIRECT_OPTION);
        if (!empty($_GET['activate-multi'])) {
            return;
        }
        $target = admin_url('admin.php?page=chess-podium-setup-wizard');
        wp_safe_redirect($target);
        exit;
    }

    public static function render_setup_wizard_page(): void
    {
        if (!current_user_can('manage_options')) {
            return;
        }
        $createdTournamentId = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
        $createdPageId = isset($_GET['created_page_id']) ? (int) $_GET['created_page_id'] : 0;
        $wizardI18n = [
            'validationTournamentName' => __('Enter the tournament name.', 'chess-podium'),
            'validationRoundsRange' => __('Rounds must be between 1 and 99.', 'chess-podium'),
            'validationUrl' => __('If provided, venue photo and logo must each use a valid http or https address.', 'chess-podium'),
        ];
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('Chess Podium setup wizard', 'chess-podium'); ?></h1>
            <p><?php echo esc_html__('Set up your first tournament in a few guided steps.', 'chess-podium'); ?></p>

            <?php if ($createdTournamentId > 0): ?>
                <div class="notice notice-success">
                    <p>
                        <?php echo esc_html__('Setup completed. Your tournament is ready.', 'chess-podium'); ?>
                        <a href="<?php echo esc_url(admin_url('admin.php?page=chess-podium&tournament_id=' . $createdTournamentId)); ?>"><?php echo esc_html__('Open tournament', 'chess-podium'); ?></a>
                        <?php if ($createdPageId > 0): ?>
                            | <a href="<?php echo esc_url(get_edit_post_link($createdPageId)); ?>"><?php echo esc_html__('Edit published page', 'chess-podium'); ?></a>
                        <?php endif; ?>
                    </p>
                </div>
            <?php endif; ?>

            <form method="post" id="cp-setup-wizard-form" style="max-width:900px;background:#fff;padding:1rem 1.25rem;border:1px solid #dcdcde;border-radius:8px;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="cp_setup_wizard_create">
                <div style="margin-bottom:1rem;">
                    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.35rem;">
                        <strong><?php echo esc_html__('Progress', 'chess-podium'); ?></strong>
                        <span id="cp-wizard-progress-text">1 / 4</span>
                    </div>
                    <div style="height:8px;background:#e5e7eb;border-radius:999px;overflow:hidden;">
                        <div id="cp-wizard-progress-bar" style="height:8px;width:25%;background:#2563eb;transition:width .2s ease;"></div>
                    </div>
                </div>
                <div id="cp-wizard-validation" class="notice notice-error" style="display:none;margin:0 0 1rem 0;"><p style="margin:.6rem 0;"></p></div>
                <div class="cp-wizard-step" data-step="1">
                    <h2><?php echo esc_html__('Step 1 - Tournament basics', 'chess-podium'); ?></h2>
                    <p><label><strong><?php echo esc_html__('Tournament name', 'chess-podium'); ?></strong><br><input type="text" name="wizard_name" class="regular-text" required></label></p>
                    <p><label><strong><?php echo esc_html__('Rounds', 'chess-podium'); ?></strong><br><input type="number" name="wizard_rounds_total" min="1" max="99" value="7"></label>
                    <span class="description"><?php echo esc_html__('Double RR (even N players): use 2×(N−1), e.g. 8 players → 14 rounds.', 'chess-podium'); ?></span></p>
                    <p><label><strong><?php echo esc_html__('Tournament type', 'chess-podium'); ?></strong><br>
                        <select name="wizard_tournament_type">
                            <option value="swiss"><?php echo esc_html__('Swiss', 'chess-podium'); ?></option>
                            <option value="round_robin"><?php echo esc_html__('Round robin', 'chess-podium'); ?></option>
                            <option value="double_round_robin"><?php echo esc_html__('Double round robin', 'chess-podium'); ?></option>
                        </select>
                    </label></p>
                </div>
                <div class="cp-wizard-step" data-step="2" style="display:none;">
                    <h2><?php echo esc_html__('Step 2 - Venue and branding', 'chess-podium'); ?></h2>
                    <p><label><strong><?php echo esc_html__('Venue photo URL (optional)', 'chess-podium'); ?></strong><br><input type="url" name="wizard_venue_photo" class="regular-text" placeholder="https://..."></label></p>
                    <p><label><strong><?php echo esc_html__('Tournament logo URL (optional)', 'chess-podium'); ?></strong><br><input type="url" name="wizard_logo_url" class="regular-text" placeholder="https://..."></label></p>
                </div>
                <div class="cp-wizard-step" data-step="3" style="display:none;">
                    <h2><?php echo esc_html__('Step 3 - Add first players', 'chess-podium'); ?></h2>
                    <p><?php echo esc_html__('Write one player per line. Optional formats:', 'chess-podium'); ?> <code>Name</code> <?php echo esc_html__('or', 'chess-podium'); ?> <code>Name;Rating;Country;Title</code></p>
                    <textarea name="wizard_players" rows="8" class="large-text" placeholder="Magnus Carlsen;2830;NOR;GM&#10;Fabiano Caruana;2800;USA;GM"></textarea>
                </div>
                <div class="cp-wizard-step" data-step="4" style="display:none;">
                    <h2><?php echo esc_html__('Step 4 - Publish on your site', 'chess-podium'); ?></h2>
                    <p><label><input type="checkbox" name="wizard_create_page" value="1" checked> <?php echo esc_html__('Create a WordPress page for this tournament automatically', 'chess-podium'); ?></label></p>
                </div>
                <p>
                    <button type="button" class="button" id="cp-wizard-prev" style="display:none;"><?php echo esc_html__('Back', 'chess-podium'); ?></button>
                    <button type="button" class="button button-secondary" id="cp-wizard-next"><?php echo esc_html__('Next', 'chess-podium'); ?></button>
                    <button type="submit" class="button button-primary" id="cp-wizard-submit" style="display:none;"><?php echo esc_html__('Create tournament', 'chess-podium'); ?></button>
                </p>
            </form>
            <script>
            (function(){
                var i18n = <?php echo wp_json_encode($wizardI18n); ?>;
                var steps = Array.prototype.slice.call(document.querySelectorAll('.cp-wizard-step'));
                if(!steps.length)return;
                var form = document.getElementById('cp-setup-wizard-form');
                var idx = 0;
                var btnPrev = document.getElementById('cp-wizard-prev');
                var btnNext = document.getElementById('cp-wizard-next');
                var btnSubmit = document.getElementById('cp-wizard-submit');
                var progressBar = document.getElementById('cp-wizard-progress-bar');
                var progressText = document.getElementById('cp-wizard-progress-text');
                var validationBox = document.getElementById('cp-wizard-validation');
                var validationText = validationBox ? validationBox.querySelector('p') : null;
                function setValidation(msg){
                    if(!validationBox||!validationText)return;
                    if(!msg){validationBox.style.display='none';validationText.textContent='';return;}
                    validationText.textContent=msg;
                    validationBox.style.display='block';
                }
                function isValidUrl(v){
                    if(!v)return true;
                    return /^https?:\/\/.+/i.test(v);
                }
                function validateStep(i){
                    setValidation('');
                    if(i===0){
                        var nameInput = document.querySelector('input[name="wizard_name"]');
                        var roundsInput = document.querySelector('input[name="wizard_rounds_total"]');
                        var name = nameInput ? nameInput.value.trim() : '';
                        var rounds = roundsInput ? parseInt(roundsInput.value || '0', 10) : 0;
                        if(!name){ setValidation(i18n.validationTournamentName || ''); return false; }
                        if(!(rounds >= 1 && rounds <= 99)){ setValidation(i18n.validationRoundsRange || ''); return false; }
                    }
                    if(i===1){
                        var venue = document.querySelector('input[name="wizard_venue_photo"]');
                        var logo = document.querySelector('input[name="wizard_logo_url"]');
                        var venueValue = venue ? venue.value.trim() : '';
                        var logoValue = logo ? logo.value.trim() : '';
                        if(!isValidUrl(venueValue) || !isValidUrl(logoValue)){
                            setValidation(i18n.validationUrl || '');
                            return false;
                        }
                    }
                    return true;
                }
                function render(){
                    steps.forEach(function(step, i){ step.style.display = i === idx ? '' : 'none'; });
                    if(btnPrev) btnPrev.style.display = idx > 0 ? '' : 'none';
                    if(btnNext) btnNext.style.display = idx < steps.length - 1 ? '' : 'none';
                    if(btnSubmit) btnSubmit.style.display = idx === steps.length - 1 ? '' : 'none';
                    if(progressBar) progressBar.style.width = ((idx + 1) / steps.length * 100) + '%';
                    if(progressText) progressText.textContent = (idx + 1) + ' / ' + steps.length;
                }
                if(btnPrev) btnPrev.addEventListener('click', function(){ setValidation(''); idx = Math.max(0, idx - 1); render(); });
                if(btnNext) btnNext.addEventListener('click', function(){
                    if(!validateStep(idx)) return;
                    idx = Math.min(steps.length - 1, idx + 1);
                    render();
                });
                if(form){
                    form.addEventListener('submit', function(e){
                        if(!validateStep(idx)){
                            e.preventDefault();
                            return false;
                        }
                    });
                }
                render();
            })();
            </script>
        </div>
        <?php
    }

    public static function render_classic_shortcode_button(): void
    {
        if (!current_user_can('edit_posts')) {
            return;
        }
        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if ($screen && !in_array($screen->base, ['post', 'page'], true)) {
            return;
        }
        echo '<button type="button" class="button" id="cp-insert-shortcode-btn">' . esc_html__('Chess Podium shortcode', 'chess-podium') . '</button>';
    }

    public static function render_classic_shortcode_modal(): void
    {
        if (!current_user_can('edit_posts')) {
            return;
        }
        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if (!$screen || !in_array($screen->base, ['post', 'page'], true)) {
            return;
        }
        $tournaments = self::get_tournaments();
        $items = array_map(static function ($t): array {
            return ['id' => (int) $t->id, 'name' => (string) $t->name];
        }, $tournaments);
        ?>
        <div id="cp-shortcode-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100000;align-items:center;justify-content:center;">
            <div style="background:#fff;max-width:520px;width:92%;padding:1rem;border-radius:8px;">
                <h3 style="margin-top:0;"><?php echo esc_html__('Insert Chess Podium shortcode', 'chess-podium'); ?></h3>
                <p><label><?php echo esc_html__('Tournament', 'chess-podium'); ?><br>
                    <select id="cp-shortcode-tournament" style="width:100%;">
                        <option value="0"><?php echo esc_html__('Current/latest tournament', 'chess-podium'); ?></option>
                        <?php foreach ($items as $it): ?>
                            <option value="<?php echo (int) $it['id']; ?>"><?php echo esc_html($it['name']); ?> (#<?php echo (int) $it['id']; ?>)</option>
                        <?php endforeach; ?>
                    </select>
                </label></p>
                <p>
                    <button type="button" class="button button-primary" id="cp-shortcode-insert"><?php echo esc_html__('Insert shortcode', 'chess-podium'); ?></button>
                    <button type="button" class="button" id="cp-shortcode-close"><?php echo esc_html__('Cancel', 'chess-podium'); ?></button>
                </p>
            </div>
        </div>
        <script>
        (function(){
            var openBtn=document.getElementById('cp-insert-shortcode-btn');
            var modal=document.getElementById('cp-shortcode-modal');
            var closeBtn=document.getElementById('cp-shortcode-close');
            var insertBtn=document.getElementById('cp-shortcode-insert');
            var select=document.getElementById('cp-shortcode-tournament');
            if(!openBtn||!modal||!insertBtn||!select)return;
            openBtn.addEventListener('click',function(){modal.style.display='flex';});
            if(closeBtn)closeBtn.addEventListener('click',function(){modal.style.display='none';});
            modal.addEventListener('click',function(e){if(e.target===modal)modal.style.display='none';});
            insertBtn.addEventListener('click',function(){
                var id=parseInt(select.value||'0',10);
                var shortcode=id>0?'[chess_podium_tournament id="'+id+'"]':'[chess_podium_tournament]';
                if(typeof window.send_to_editor==='function'){window.send_to_editor(shortcode);}
                modal.style.display='none';
            });
        })();
        </script>
        <?php
    }

    public static function enqueue_block_editor_assets(): void
    {
        if (!current_user_can('edit_posts')) {
            return;
        }
        wp_enqueue_script(
            'chess-podium-block-editor',
            plugins_url('assets/js/block-editor.js', CHESS_PODIUM_PLUGIN_FILE),
            ['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor'],
            CHESS_PODIUM_VERSION,
            true
        );
        wp_add_inline_script('chess-podium-block-editor', 'window.chessPodiumBlockData=' . wp_json_encode([
            'tournaments' => array_map(static function ($t): array {
                return ['id' => (int) $t->id, 'name' => (string) $t->name];
            }, self::get_tournaments()),
            'labelCurrent' => __('Current/latest tournament', 'chess-podium'),
            'labelTournament' => __('Tournament', 'chess-podium'),
            'labelRound' => __('Round (optional)', 'chess-podium'),
            'labelBlockTitle' => __('Chess Podium Tournament', 'chess-podium'),
            'labelPanelTitle' => __('Chess Podium', 'chess-podium'),
            'labelAllRounds' => __('All rounds', 'chess-podium'),
            'labelRoundShort' => __('Round', 'chess-podium'),
            'labelTournamentPrefix' => __('Tournament', 'chess-podium'),
            'archiveBlock' => [
                'tournaments' => class_exists('ChessPodium_Tournament_Archive_Card')
                    ? ChessPodium_Tournament_Archive_Card::get_tournaments_for_editor()
                    : [],
                'labelSelect' => __('Select a tournament', 'chess-podium'),
                'labelBlockTitle' => __('Chess Podium Tournament Archive Card', 'chess-podium'),
                'labelPanelTitle' => __('Archive card', 'chess-podium'),
                'labelTournament' => __('Tournament', 'chess-podium'),
                'labelMaxRows' => __('Players in standings preview', 'chess-podium'),
                'labelShowImage' => __('Show cover image', 'chess-podium'),
                'labelLinkMode' => __('Link target', 'chess-podium'),
                'linkAuto' => __('Published archive if available, else live page', 'chess-podium'),
                'linkLive' => __('Live tournament page', 'chess-podium'),
                'linkArchive' => __('Published HTML archive only', 'chess-podium'),
                'labelPreviewHint' => __('Card with standings preview and link to the public tournament page.', 'chess-podium'),
                'labelNone' => __('Choose a tournament in the block settings.', 'chess-podium'),
            ],
        ]) . ';', 'before');
    }

    public static function register_shortcode_block(): void
    {
        if (!function_exists('register_block_type')) {
            return;
        }
        register_block_type('chess-podium/tournament', [
            'api_version' => 3,
            'attributes' => [
                'tournamentId' => ['type' => 'integer', 'default' => 0],
                'round' => ['type' => 'integer', 'default' => 0],
            ],
            'render_callback' => [self::class, 'render_tournament_block'],
        ]);
    }

    public static function render_tournament_block(array $attributes = []): string
    {
        $tid = isset($attributes['tournamentId']) ? (int) $attributes['tournamentId'] : 0;
        $round = isset($attributes['round']) ? (int) $attributes['round'] : 0;
        $shortcode = self::get_tournament_shortcode_for_id($tid, $round);
        return do_shortcode($shortcode);
    }

    private static function get_tournament_shortcode_for_id(int $tournamentId, int $round = 0): string
    {
        $shortcode = '[chess_podium_tournament';
        if ($tournamentId > 0) {
            $shortcode .= ' id="' . (int) $tournamentId . '"';
        }
        if ($round > 0) {
            $shortcode .= ' round="' . (int) $round . '"';
        }
        return $shortcode . ']';
    }

    public static function register_customizer_settings(\WP_Customize_Manager $wpCustomize): void
    {
        $section = 'chess_podium_style';
        $wpCustomize->add_section($section, [
            'title' => __('Chess Podium style', 'chess-podium'),
            'priority' => 160,
        ]);
        $settings = [
            'table_header_bg' => '#e8e8e8',
            'table_header_text' => '#1a1a1a',
            'table_alt_row_bg' => '#f9f9f9',
            'board_light' => '#f0d9b5',
            'board_dark' => '#b58863',
        ];
        foreach ($settings as $key => $default) {
            $wpCustomize->add_setting(self::CUSTOM_STYLE_OPTION . '[' . $key . ']', [
                'default' => $default,
                'type' => 'option',
                'sanitize_callback' => 'sanitize_hex_color',
            ]);
            $wpCustomize->add_control(new \WP_Customize_Color_Control($wpCustomize, 'chess_podium_' . $key, [
                'label' => ucwords(str_replace('_', ' ', $key)),
                'section' => $section,
                'settings' => self::CUSTOM_STYLE_OPTION . '[' . $key . ']',
            ]));
        }
    }

    private static function get_custom_style_css(): string
    {
        $opts = get_option(self::CUSTOM_STYLE_OPTION, []);
        $tableHeaderBg = sanitize_hex_color($opts['table_header_bg'] ?? '#e8e8e8') ?: '#e8e8e8';
        $tableHeaderText = sanitize_hex_color($opts['table_header_text'] ?? '#1a1a1a') ?: '#1a1a1a';
        $tableAltRowBg = sanitize_hex_color($opts['table_alt_row_bg'] ?? '#f9f9f9') ?: '#f9f9f9';
        $boardLight = sanitize_hex_color($opts['board_light'] ?? '#f0d9b5') ?: '#f0d9b5';
        $boardDark = sanitize_hex_color($opts['board_dark'] ?? '#b58863') ?: '#b58863';
        return 'body.cp-tournament-route .cp-tournament-wrap th{background:' . $tableHeaderBg . '!important;color:' . $tableHeaderText . '!important}'
            . 'body.cp-tournament-route .cp-tournament-wrap tr:nth-child(even){background:' . $tableAltRowBg . '!important}'
            . '.cp-live-board .white-1e1d7,.board-b72b1 .white-1e1d7{background:' . $boardLight . '!important}'
            . '.cp-live-board .black-3c85d,.board-b72b1 .black-3c85d{background:' . $boardDark . '!important}';
    }

    public static function load_textdomain(): void
    {
        $langDir = __DIR__ . '/languages';
        $locale = function_exists('determine_locale') ? determine_locale() : get_locale();
        $moFile = $langDir . '/chess-podium-' . $locale . '.mo';
        $poFile = $langDir . '/chess-podium-' . $locale . '.po';
        if (!file_exists($moFile) && file_exists($poFile)) {
            self::compile_po_to_mo($poFile, $moFile);
        }
        // Load explicitly by path so we don't rely on get_locale() (which may be cached by other plugins).
        // When hosted on WordPress.org, translations are auto-loaded; we only need explicit load when .mo exists.
        if (file_exists($moFile)) {
            load_textdomain('chess-podium', $moFile);
        }
    }

    private static function compile_po_to_mo(string $poFile, string $moFile): bool
    {
        $phpMo = dirname($poFile) . '/php-mo.php';
        if (!file_exists($phpMo)) {
            return false;
        }
        require_once $phpMo;
        return function_exists('phpmo_convert') && phpmo_convert($poFile, $moFile);
    }

    public static function render_add_to_page(): void
    {
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('Add tournaments to a page', 'chess-podium'); ?></h1>
            <p><?php echo esc_html__('Use these shortcodes to display content on any page or post:', 'chess-podium'); ?></p>
            <table class="widefat striped" style="max-width:700px;">
                <thead><tr><th><?php esc_html_e('Shortcode', 'chess-podium'); ?></th><th><?php esc_html_e('Description', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                    <tr><td><code>[chess_podium_tornei]</code></td><td><?php esc_html_e('List of published tournaments', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_tournament id="X"]</code></td><td><?php esc_html_e('Single tournament (omit id for current)', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_registration id="X"]</code></td><td><?php esc_html_e('Registration form for a tournament (Pro). Omit id for current tournament. Shows registered players and payment form.', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_registered_players id="X"]</code></td><td><?php esc_html_e('List of registered players only, without form. Use id or tournament_id to specify the tournament.', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_brochure id="X"]</code></td><td><?php esc_html_e('Tournament brochure / announcement', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_events]</code></td><td><?php esc_html_e('Public calendar and tournament list (past, current & upcoming) with gallery thumbnails like the tournament page. Dates from Chess Podium.', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_tournament_archive id="X"]</code></td><td><?php esc_html_e('Archive card: cover image, standings preview, and link to the public tournament (see details below).', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_club_ranking]</code></td><td><?php esc_html_e('Club ranking from all published tournaments', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_grand_prix id="X"]</code></td><td><?php esc_html_e('Grand Prix standings: points and social ELO from selected tournaments', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_player_profile fide_id="12345"]</code></td><td><?php esc_html_e('Player profile and history', 'chess-podium'); ?></td></tr>
                    <tr><td><code>[chess_podium_player_profile name="Player Name"]</code></td><td><?php esc_html_e('Player profile by name', 'chess-podium'); ?></td></tr>
                </tbody>
            </table>

            <h2 style="margin-top:2rem;"><?php esc_html_e('Tournament archive card', 'chess-podium'); ?></h2>
            <p><?php esc_html_e('Use this shortcode or the Gutenberg block “Chess Podium Tournament Archive Card” in posts and articles to highlight a tournament that already has standings: a cover image (gallery, venue photo, or logo), a compact standings table, and a link to the full public tournament page or published HTML export.', 'chess-podium'); ?></p>
            <p><strong><?php esc_html_e('Examples', 'chess-podium'); ?>:</strong></p>
            <ul style="list-style:disc;margin-left:1.5rem;">
                <li><code>[chess_podium_tournament_archive id="12"]</code></li>
                <li><code>[chess_podium_tournament_archive id="12" rows="8"]</code></li>
                <li><code>[chess_podium_tournament_archive id="12" rows="10" show_image="1" link="auto"]</code></li>
            </ul>
            <table class="widefat striped" style="max-width:700px;margin-top:1rem;">
                <thead><tr><th><?php esc_html_e('Parameter', 'chess-podium'); ?></th><th><?php esc_html_e('Description', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                    <tr><td><code>id</code> <?php esc_html_e('or', 'chess-podium'); ?> <code>tournament_id</code></td><td><?php esc_html_e('Required. Tournament ID from Chess Podium (number shown in the admin tournament list).', 'chess-podium'); ?></td></tr>
                    <tr><td><code>rows</code></td><td><?php esc_html_e('Optional. Number of players shown in the standings preview (3–25, default 10).', 'chess-podium'); ?></td></tr>
                    <tr><td><code>show_image</code></td><td><?php esc_html_e('Optional. 1 (default) or 0 — show or hide the cover image.', 'chess-podium'); ?></td></tr>
                    <tr><td><code>link</code></td><td><?php esc_html_e('Optional. auto (default): published HTML export if available, otherwise the live tournament page; live: always the live page; archive: published export only (falls back to live if not published).', 'chess-podium'); ?></td></tr>
                </tbody>
            </table>
            <p class="description" style="margin-top:1rem;"><?php esc_html_e('Gutenberg: search for “Chess Podium Tournament Archive Card” in the block inserter (Widgets category). Choose the tournament, rows, image, and link target in the block sidebar — same options as the shortcode.', 'chess-podium'); ?></p>

            <p><strong><?php echo esc_html__('How to add:', 'chess-podium'); ?></strong></p>
            <ol style="list-style:decimal;margin-left:1.5rem;">
                <li><?php echo esc_html__('Edit the page or post where you want to show the tournaments.', 'chess-podium'); ?></li>
                <li><?php echo esc_html__('Add a "Shortcode" block (search for "Shortcode" in the block inserter), or add the Gutenberg block “Chess Podium Tournament Archive Card” for the archive card.', 'chess-podium'); ?></li>
                <li><?php echo esc_html__('Paste the shortcode above into the block, or configure the block in the sidebar.', 'chess-podium'); ?></li>
                <li><?php echo esc_html__('Save and publish.', 'chess-podium'); ?></li>
            </ol>
            <p>
                <a href="<?php echo esc_url(admin_url('post-new.php?post_type=page')); ?>" class="button button-primary">
                    <?php echo esc_html__('Create new page', 'chess-podium'); ?>
                </a>
            </p>
        </div>
        <?php
    }

    public static function render_external_tournaments_page(): void
    {
        $msg = isset($_GET['cp_msg']) ? sanitize_key(wp_unslash($_GET['cp_msg'])) : '';
        $editId = isset($_GET['edit_ext']) ? sanitize_key(wp_unslash($_GET['edit_ext'])) : '';
        $external = self::get_external_tournaments();
        $editItem = null;
        if ($editId !== '') {
            foreach ($external as $e) {
                if (($e['id'] ?? '') === $editId) {
                    $editItem = $e;
                    break;
                }
            }
        }
        $isEdit = $editItem !== null;
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('External tournaments', 'chess-podium'); ?></h1>
            <p><?php echo esc_html__('Add tournaments created by other software (e.g. Vega, Swiss Manager) that are already hosted on your site. They will appear on the tournaments page with the same layout as Chess Podium tournaments.', 'chess-podium'); ?></p>

            <?php if ($msg === 'ext_tournament_added'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('External tournament added.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'ext_tournament_updated'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('External tournament updated.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'ext_tournament_deleted'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('External tournament removed.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'ext_tournament_error'): ?>
                <div class="notice notice-error is-dismissible"><p><?php echo esc_html__('Error: enter name and URL.', 'chess-podium'); ?></p></div>
            <?php endif; ?>

            <h2><?php echo $isEdit ? esc_html__('Edit external tournament', 'chess-podium') : esc_html__('Add external tournament', 'chess-podium'); ?></h2>
            <form method="post" enctype="multipart/form-data" style="max-width:600px;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="<?php echo esc_attr($isEdit ? 'update_external_tournament' : 'add_external_tournament'); ?>">
                <?php if ($isEdit): ?>
                    <input type="hidden" name="ext_id" value="<?php echo esc_attr($editItem['id'] ?? ''); ?>">
                <?php endif; ?>
                <table class="form-table">
                    <tr>
                        <th><label for="ext_name"><?php echo esc_html__('Tournament name', 'chess-podium'); ?></label></th>
                        <td><input id="ext_name" name="ext_name" type="text" required class="regular-text" placeholder="<?php echo esc_attr__('e.g. Campionato 2024', 'chess-podium'); ?>" value="<?php echo esc_attr($editItem['name'] ?? ''); ?>"></td>
                    </tr>
                    <tr>
                        <th><label for="ext_url"><?php echo esc_html__('URL', 'chess-podium'); ?></label></th>
                        <td><input id="ext_url" name="ext_url" type="url" required class="large-text" placeholder="https://..." value="<?php echo esc_attr($editItem['url'] ?? ''); ?>"></td>
                    </tr>
                    <tr>
                        <th><label for="ext_preview"><?php echo esc_html__('Preview image', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="ext_preview" name="ext_preview" type="url" class="large-text" placeholder="https://..." value="<?php echo esc_attr($editItem['preview_url'] ?? ''); ?>">
                            <p class="description"><?php echo esc_html__('URL or upload below. Optional. If empty, the default logo will be used.', 'chess-podium'); ?></p>
                            <p>
                                <input type="file" name="ext_preview_file" id="ext_preview_file" accept="image/*">
                                <button type="button" class="button" id="cp_ext_select_media"><?php echo esc_html__('Select from Media Library', 'chess-podium'); ?></button>
                            </p>
                            <?php if (!empty($editItem['preview_url'])): ?>
                                <p><img src="<?php echo esc_url($editItem['preview_url']); ?>" alt="" style="max-width:120px;max-height:80px;object-fit:cover;border:1px solid #ccc;"></p>
                            <?php endif; ?>
                        </td>
                    </tr>
                    <tr>
                        <th><label for="ext_rounds"><?php echo esc_html__('Rounds', 'chess-podium'); ?></label></th>
                        <td><input id="ext_rounds" name="ext_rounds" type="number" min="0" value="<?php echo esc_attr((int) ($editItem['rounds'] ?? 0)); ?>" class="small-text"> <span class="description"><?php echo esc_html__('Optional. Used for display (e.g. "5 rounds").', 'chess-podium'); ?></span></td>
                    </tr>
                    <tr>
                        <th><label for="ext_brochure"><?php echo esc_html__('Brochure (PDF)', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="ext_brochure" name="ext_brochure_url" type="url" class="large-text" placeholder="https://..." value="<?php echo esc_attr($editItem['brochure_url'] ?? ''); ?>">
                            <p class="description"><?php echo esc_html__('URL to brochure PDF (e.g. from Vega, Swiss Manager). Use shortcode [chess_podium_brochure id="ext_id"] to display.', 'chess-podium'); ?></p>
                            <p><input type="file" name="ext_brochure_file" accept=".pdf,application/pdf"> <?php echo esc_html__('or upload PDF', 'chess-podium'); ?></p>
                        </td>
                    </tr>
                </table>
                <p>
                    <button class="button button-primary" type="submit"><?php echo $isEdit ? esc_html__('Update tournament', 'chess-podium') : esc_html__('Add tournament', 'chess-podium'); ?></button>
                    <?php if ($isEdit): ?>
                        <a href="<?php echo esc_url(admin_url('admin.php?page=chess-podium-external-tournaments')); ?>" class="button"><?php echo esc_html__('Cancel', 'chess-podium'); ?></a>
                    <?php endif; ?>
                </p>
            </form>

            <h2><?php echo esc_html__('External tournaments list', 'chess-podium'); ?></h2>
            <?php if (empty($external)): ?>
                <p><?php echo esc_html__('No external tournaments added.', 'chess-podium'); ?></p>
            <?php else: ?>
                <table class="widefat striped">
                    <thead><tr><th><?php echo esc_html__('Name', 'chess-podium'); ?></th><th><?php echo esc_html__('URL', 'chess-podium'); ?></th><th><?php echo esc_html__('Preview', 'chess-podium'); ?></th><th><?php echo esc_html__('Rounds', 'chess-podium'); ?></th><th><?php echo esc_html__('Actions', 'chess-podium'); ?></th></tr></thead>
                    <tbody>
                    <?php foreach ($external as $e): ?>
                        <tr>
                            <td><?php echo esc_html($e['name'] ?? ''); ?></td>
                            <td><a href="<?php echo esc_url($e['url'] ?? '#'); ?>" target="_blank" rel="noopener"><?php echo esc_html($e['url'] ?? ''); ?></a></td>
                            <td><?php echo !empty($e['preview_url']) ? '<img src="' . esc_url($e['preview_url']) . '" alt="" style="max-width:80px;max-height:50px;object-fit:cover;">' : '—'; ?></td>
                            <td><?php echo (int) ($e['rounds'] ?? 0); ?></td>
                            <td>
                                <a href="<?php echo esc_url(admin_url('admin.php?page=chess-podium-external-tournaments&edit_ext=' . rawurlencode($e['id'] ?? ''))); ?>" class="button"><?php echo esc_html__('Edit', 'chess-podium'); ?></a>
                                <form method="post" style="display:inline;" onsubmit="return confirm('<?php echo esc_js(__('Remove this external tournament?', 'chess-podium')); ?>');">
                                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                                    <input type="hidden" name="regina_action" value="delete_external_tournament">
                                    <input type="hidden" name="ext_id" value="<?php echo esc_attr($e['id'] ?? ''); ?>">
                                    <button type="submit" class="button button-link-delete"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                                </form>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            <?php endif; ?>
        </div>
        <?php
    }

    public static function render_sponsors_page(): void
    {
        $msg = isset($_GET['cp_msg']) ? sanitize_key(wp_unslash($_GET['cp_msg'])) : '';
        $editId = isset($_GET['edit_sponsor']) ? (int) $_GET['edit_sponsor'] : 0;
        $sponsors = ChessPodium_Sponsors::get_all();
        $editItem = null;
        if ($editId > 0) {
            foreach ($sponsors as $s) {
                if ((int) $s->id === $editId) {
                    $editItem = $s;
                    break;
                }
            }
        }
        $isEdit = $editItem !== null;
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('Sponsors', 'chess-podium'); ?></h1>
            <p><?php echo esc_html__('Sponsor logos appear automatically at the bottom of every published tournament page and in the brochure PDF. Clubs live on sponsors—this feature pays for the Pro license in a day.', 'chess-podium'); ?></p>

            <?php if ($msg === 'sponsor_added'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('Sponsor added.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'sponsor_updated'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('Sponsor updated.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'sponsor_deleted'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('Sponsor removed.', 'chess-podium'); ?></p></div>
            <?php elseif ($msg === 'sponsor_error'): ?>
                <div class="notice notice-error is-dismissible"><p><?php echo esc_html__('Error: enter name and logo URL.', 'chess-podium'); ?></p></div>
            <?php endif; ?>

            <h2><?php echo $isEdit ? esc_html__('Edit sponsor', 'chess-podium') : esc_html__('Add sponsor', 'chess-podium'); ?></h2>
            <form method="post" style="max-width:600px;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="<?php echo esc_attr($isEdit ? 'update_sponsor' : 'add_sponsor'); ?>">
                <?php if ($isEdit): ?>
                    <input type="hidden" name="sponsor_id" value="<?php echo (int) $editItem->id; ?>">
                <?php endif; ?>
                <table class="form-table">
                    <tr>
                        <th><label for="sponsor_name"><?php echo esc_html__('Name', 'chess-podium'); ?></label></th>
                        <td><input id="sponsor_name" name="sponsor_name" type="text" required class="regular-text" placeholder="<?php echo esc_attr__('e.g. Local Bank', 'chess-podium'); ?>" value="<?php echo esc_attr($editItem->name ?? ''); ?>"></td>
                    </tr>
                    <tr>
                        <th><label for="sponsor_logo"><?php echo esc_html__('Logo URL', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="sponsor_logo" name="sponsor_logo" type="url" required class="large-text" placeholder="https://..." value="<?php echo esc_attr($editItem->logo_url ?? ''); ?>">
                            <p class="description"><?php echo esc_html__('URL of the logo image. Use PNG or JPG with transparent or white background.', 'chess-podium'); ?></p>
                            <p><button type="button" class="button" id="cp_sponsor_select_media"><?php echo esc_html__('Select from Media Library', 'chess-podium'); ?></button></p>
                            <?php if (!empty($editItem->logo_url)): ?>
                                <p><img src="<?php echo esc_url($editItem->logo_url); ?>" alt="" style="max-width:120px;max-height:60px;object-fit:contain;border:1px solid #ccc;"></p>
                            <?php endif; ?>
                        </td>
                    </tr>
                    <tr>
                        <th><label for="sponsor_url"><?php echo esc_html__('Website URL', 'chess-podium'); ?></label></th>
                        <td><input id="sponsor_url" name="sponsor_url" type="url" class="large-text" placeholder="https://..." value="<?php echo esc_attr($editItem->url ?? ''); ?>"> <span class="description"><?php echo esc_html__('Optional. When set, the logo links to this URL.', 'chess-podium'); ?></span></td>
                    </tr>
                    <tr>
                        <th><label><?php echo esc_html__('Tournaments', 'chess-podium'); ?></label></th>
                        <td>
                            <?php
                            $tournaments = self::get_tournaments();
                            $selectedIds = [];
                            $applyToAll = true;
                            if ($editItem && !empty($editItem->tournament_ids)) {
                                $decoded = json_decode((string) $editItem->tournament_ids, true);
                                $selectedIds = is_array($decoded) ? array_map('intval', $decoded) : [];
                                $applyToAll = empty($selectedIds);
                            }
                            ?>
                            <p class="description" style="margin-bottom:8px;"><?php echo esc_html__('Choose which tournaments display this sponsor.', 'chess-podium'); ?></p>
                            <label style="display:block;margin:6px 0;font-weight:500;">
                                <input type="radio" name="sponsor_tournaments_mode" value="all" <?php echo $applyToAll ? 'checked' : ''; ?>>
                                <?php echo esc_html__('All tournaments', 'chess-podium'); ?>
                            </label>
                            <?php if (!empty($tournaments)): ?>
                            <label style="display:block;margin:6px 0;font-weight:500;">
                                <input type="radio" name="sponsor_tournaments_mode" value="selected" <?php echo !$applyToAll ? 'checked' : ''; ?>>
                                <?php echo esc_html__('Selected tournaments only:', 'chess-podium'); ?>
                            </label>
                            <fieldset style="max-height:180px;overflow-y:auto;padding:8px;margin:8px 0 0 20px;border:1px solid #8c8f94;border-radius:4px;">
                                <?php foreach ($tournaments as $t): ?>
                                    <label style="display:block;margin:4px 0;">
                                        <input type="checkbox" name="sponsor_tournaments[]" value="<?php echo (int) $t->id; ?>" <?php echo ($applyToAll || in_array((int) $t->id, $selectedIds, true)) ? 'checked' : ''; ?>>
                                        <?php echo esc_html((string) $t->name); ?>
                                    </label>
                                <?php endforeach; ?>
                            </fieldset>
                            <?php endif; ?>
                        </td>
                    </tr>
                </table>
                <p>
                    <button class="button button-primary" type="submit"><?php echo $isEdit ? esc_html__('Update sponsor', 'chess-podium') : esc_html__('Add sponsor', 'chess-podium'); ?></button>
                    <?php if ($isEdit): ?>
                        <a href="<?php echo esc_url(admin_url('admin.php?page=chess-podium-sponsors')); ?>" class="button"><?php echo esc_html__('Cancel', 'chess-podium'); ?></a>
                    <?php endif; ?>
                </p>
            </form>

            <h2><?php echo esc_html__('Sponsors list', 'chess-podium'); ?></h2>
            <?php if (empty($sponsors)): ?>
                <p><?php echo esc_html__('No sponsors added yet.', 'chess-podium'); ?></p>
            <?php else: ?>
                <table class="widefat striped">
                    <thead><tr><th><?php echo esc_html__('Name', 'chess-podium'); ?></th><th><?php echo esc_html__('Logo', 'chess-podium'); ?></th><th><?php echo esc_html__('URL', 'chess-podium'); ?></th><th><?php echo esc_html__('Tournaments', 'chess-podium'); ?></th><th><?php echo esc_html__('Actions', 'chess-podium'); ?></th></tr></thead>
                    <tbody>
                    <?php foreach ($sponsors as $s):
                        $tids = !empty($s->tournament_ids) ? json_decode((string) $s->tournament_ids, true) : null;
                        $tLabel = ($tids === null || !is_array($tids) || empty($tids)) ? esc_html__('All', 'chess-podium') : (count($tids) . ' ' . esc_html__('selected', 'chess-podium'));
                    ?>
                        <tr>
                            <td><?php echo esc_html($s->name ?? ''); ?></td>
                            <td><?php echo !empty($s->logo_url) ? '<img src="' . esc_url($s->logo_url) . '" alt="" style="max-width:80px;max-height:40px;object-fit:contain;">' : '—'; ?></td>
                            <td><?php echo !empty($s->url) ? '<a href="' . esc_url($s->url) . '" target="_blank" rel="noopener">' . esc_html($s->url) . '</a>' : '—'; ?></td>
                            <td><?php echo esc_html($tLabel); ?></td>
                            <td>
                                <a href="<?php echo esc_url(admin_url('admin.php?page=chess-podium-sponsors&edit_sponsor=' . (int) $s->id)); ?>" class="button"><?php echo esc_html__('Edit', 'chess-podium'); ?></a>
                                <form method="post" style="display:inline;" onsubmit="return confirm('<?php echo esc_js(__('Remove this sponsor?', 'chess-podium')); ?>');">
                                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                                    <input type="hidden" name="regina_action" value="delete_sponsor">
                                    <input type="hidden" name="sponsor_id" value="<?php echo (int) $s->id; ?>">
                                    <button type="submit" class="button button-link-delete"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                                </form>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
            <?php endif; ?>
        </div>
        <?php
    }

    public static function render_pairing_settings_page(): void
    {
        $savedMsg = '';
        if (isset($_POST['chess_podium_pairing_save']) && check_admin_referer('chess_podium_pairing_settings')) {
            $engine = sanitize_key(wp_unslash($_POST['pairing_engine'] ?? 'javafo'));
            if (!in_array($engine, ['bbpPairings', 'javafo'], true)) {
                $engine = 'javafo';
            }
            update_option('chess_podium_pairing_engine', $engine);

            $newPath = sanitize_text_field(wp_unslash($_POST['bbp_pairings_path'] ?? ''));
            if (!empty($_FILES['bbp_pairings_file']['tmp_name']) && is_uploaded_file($_FILES['bbp_pairings_file']['tmp_name'])) {
                $mime_filter = function ($mimes) {
                    $mimes['exe'] = 'application/x-msdownload';
                    $mimes['bin'] = 'application/octet-stream';
                    return $mimes;
                };
                add_filter('upload_mimes', $mime_filter, 10, 1);
                $upload = wp_handle_upload($_FILES['bbp_pairings_file'], ['test_form' => false]);
                remove_filter('upload_mimes', $mime_filter, 10);
                if (!is_wp_error($upload) && !empty($upload['file'])) {
                    $uploadDir = wp_upload_dir();
                    $baseDir = $uploadDir['basedir'] . '/chess-podium-bbp';
                    if (!file_exists($baseDir)) {
                        wp_mkdir_p($baseDir);
                    }
                    $ext = strtolower(pathinfo(sanitize_file_name($_FILES['bbp_pairings_file']['name'] ?? ''), PATHINFO_EXTENSION));
                    $destName = ($ext === 'exe') ? 'bbpPairings.exe' : 'bbpPairings';
                    $destPath = $baseDir . '/' . $destName;
                    if (copy($upload['file'], $destPath)) {
                        wp_delete_file($upload['file']);
                        if (PHP_OS_FAMILY !== 'Windows') {
                            @chmod($destPath, 0755);
                        }
                        $newPath = $destPath;
                    }
                }
            }
            update_option('chess_podium_bbp_pairings_path', $newPath);

            $apiUrl = trim(sanitize_text_field(wp_unslash($_POST['pairing_api_url'] ?? '')));
            update_option('chess_podium_bbp_pairings_api_url', $apiUrl);

            $savedMsg = '<div class="notice notice-success"><p>' . esc_html__('Pairing settings saved.', 'chess-podium') . '</p></div>';
        }
        $engine = get_option('chess_podium_pairing_engine', 'javafo');
        $bbpPath = get_option('chess_podium_bbp_pairings_path', '');
        $apiUrl = get_option('chess_podium_bbp_pairings_api_url', '');
        $defaultApiUrl = self::DEFAULT_PAIRING_API_URL;
        $pathExamples = PHP_OS_FAMILY === 'Windows'
            ? __('Examples: C:\\laragon\\bin\\bbpPairings\\bbpPairings.exe or C:\\xampp\\htdocs\\bbpPairings.exe', 'chess-podium')
            : __('Examples: /usr/local/bin/bbpPairings or /home/user/bbpPairings', 'chess-podium');
        ?>
        <div class="wrap">
            <h1><?php esc_html_e('Pairing engines', 'chess-podium'); ?></h1>
            <p><?php esc_html_e('JaVaFo is always used first (required for FIDE homologation). bbpPairings and the built-in engine are fallbacks only when JaVaFo is unavailable.', 'chess-podium'); ?></p>
            <p class="description" style="color:#666;">
                <strong><?php echo esc_html__('Plugin build', 'chess-podium'); ?></strong>:
                <code><?php echo esc_html(gmdate('Y-m-d H:i:s \\U\\T\\C', (int) @filemtime(__FILE__))); ?></code>
            </p>

            <?php echo wp_kses_post($savedMsg); ?>

            <form method="post" enctype="multipart/form-data" style="max-width:700px;">
                <?php wp_nonce_field('chess_podium_pairing_settings'); ?>
                <input type="hidden" name="chess_podium_pairing_save" value="1">
                <table class="form-table">
                    <tr>
                        <th><label for="pairing_api_url"><?php esc_html_e('Pairing API URL', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="pairing_api_url" name="pairing_api_url" type="url" class="large-text" value="<?php echo esc_attr($apiUrl); ?>" placeholder="<?php echo esc_attr($defaultApiUrl); ?>">
                            <p class="description"><?php esc_html_e('URL of pair.php on a server with bbpPairings and/or JaVaFo. Leave empty to use default. Required for JaVaFo (runs only via API).', 'chess-podium'); ?></p>
                        </td>
                    </tr>
                    <tr>
                        <th><label for="pairing_engine"><?php esc_html_e('Pairing engine', 'chess-podium'); ?></label></th>
                        <td>
                            <select id="pairing_engine" name="pairing_engine">
                                <option value="javafo" <?php selected($engine, 'javafo'); ?>><?php esc_html_e('JaVaFo (Swiss Manager, chess-results, Vega)', 'chess-podium'); ?></option>
                                <option value="bbpPairings" <?php selected($engine, 'bbpPairings'); ?>><?php esc_html_e('bbpPairings (Swiss Sys)', 'chess-podium'); ?></option>
                            </select>
                            <p class="description"><?php esc_html_e('JaVaFo is always tried first. bbpPairings and built-in are fallbacks only.', 'chess-podium'); ?></p>
                        </td>
                    </tr>
                    <tr class="pairing-engine-bbp">
                        <th><label for="bbp_pairings_path"><?php esc_html_e('Path to bbpPairings', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="bbp_pairings_path" name="bbp_pairings_path" type="text" class="large-text" value="<?php echo esc_attr($bbpPath); ?>" placeholder="<?php echo esc_attr(PHP_OS_FAMILY === 'Windows' ? 'C:\\path\\to\\bbpPairings.exe' : '/usr/local/bin/bbpPairings'); ?>">
                            <p class="description"><?php echo esc_html($pathExamples); ?></p>
                            <p class="description"><?php esc_html_e('Or upload below. Leave empty to use cloud API.', 'chess-podium'); ?></p>
                        </td>
                    </tr>
                    <tr class="pairing-engine-bbp">
                        <th><label for="bbp_pairings_file"><?php esc_html_e('Upload bbpPairings', 'chess-podium'); ?></label></th>
                        <td>
                            <input id="bbp_pairings_file" name="bbp_pairings_file" type="file">
                            <p class="description"><a href="https://github.com/BieremaBoyzProgramming/bbpPairings/releases" target="_blank" rel="noopener"><?php esc_html_e('bbpPairings releases', 'chess-podium'); ?></a></p>
                        </td>
                    </tr>
                    <tr class="pairing-engine-javafo">
                        <td colspan="2">
                            <p class="description"><?php esc_html_e('JaVaFo runs on the Pairing API server (Java required there). Configure the API URL in Chess Podium main settings if needed. Uses the default cloud API when empty.', 'chess-podium'); ?></p>
                            <?php if (!defined('CHESS_PODIUM_DEBUG_PAIRING') || !CHESS_PODIUM_DEBUG_PAIRING): ?>
                            <p class="description" style="margin-top:0.5em;color:#666;"><?php esc_html_e("To debug why JaVaFo fails: add to wp-config.php: define('CHESS_PODIUM_DEBUG_PAIRING', true); and define('WP_DEBUG_LOG', true); then generate a round and check wp-content/debug.log", 'chess-podium'); ?></p>
                            <?php else: ?>
                            <p class="description" style="margin-top:0.5em;color:#0a0;"><?php esc_html_e('Pairing debug logging is enabled. Check wp-content/debug.log when generating a round.', 'chess-podium'); ?></p>
                            <?php endif; ?>
                        </td>
                    </tr>
                </table>
                <p>
                    <button type="submit" class="button button-primary"><?php esc_html_e('Save', 'chess-podium'); ?></button>
                    <button type="submit" name="chess_podium_test_pairing_api" value="1" class="button"><?php esc_html_e('Test pairing API', 'chess-podium'); ?></button>
                </p>
            </form>
            <script>
            (function() {
                var sel = document.getElementById('pairing_engine');
                var bbpRows = document.querySelectorAll('.pairing-engine-bbp');
                var javafoRows = document.querySelectorAll('.pairing-engine-javafo');
                function toggle() {
                    var isBbp = sel && sel.value === 'bbpPairings';
                    bbpRows.forEach(function(r){ r.style.display = isBbp ? '' : 'none'; });
                    javafoRows.forEach(function(r){ r.style.display = !isBbp ? '' : 'none'; });
                }
                if (sel) { sel.addEventListener('change', toggle); toggle(); }
            })();
            </script>

            <?php
            $testResult = get_transient('chess_podium_pairing_api_test');
            if (is_array($testResult)):
                delete_transient('chess_podium_pairing_api_test');
                $isOk = !empty($testResult['ok']);
                $msg = $testResult['msg'] ?? '';
            ?>
                <div class="notice notice-<?php echo $isOk ? 'success' : 'error'; ?>"><p><?php echo esc_html($msg); ?></p></div>
            <?php endif; ?>
        </div>
        <?php
    }

    private static function get_license_error_message(array $result): string
    {
        $reason = $result['reason'] ?? 'invalid';
        $messages = [
            'invalid_key' => __('Invalid license key. Check the key and try again.', 'chess-podium'),
            'email_mismatch' => __('Email does not match the license. Use the exact email used when purchasing.', 'chess-podium'),
            'domain_mismatch' => __('This license is already activated for another site. One license per domain.', 'chess-podium') . (isset($result['allowed_domain']) ? ' ' . sprintf(__('Allowed domain: %s', 'chess-podium'), $result['allowed_domain']) : ''),
            'expired' => __('License has expired. Renew your subscription.', 'chess-podium'),
            'inactive' => __('License is inactive or revoked.', 'chess-podium'),
            'network_error' => __('Could not reach the license server. Check your connection and try again.', 'chess-podium') . ' ' . ($result['message'] ?? ''),
            'api_error' => __('License server error.', 'chess-podium') . ' ' . ($result['message'] ?? ''),
            'invalid_response' => __('Invalid response from license server.', 'chess-podium'),
        ];
        return $messages[$reason] ?? __('Validation failed. Please check your license key and email.', 'chess-podium');
    }

    public static function maybe_show_demo_notice(): void
    {
        $demoInfo = ChessPodium_License::get_demo_info();
        if (!$demoInfo || $demoInfo['expired']) {
            return;
        }
        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if (!$screen || strpos($screen->id ?? '', 'chess-podium') === false) {
            return;
        }
        $days = $demoInfo['days_remaining'];
        $msg = $days === 1
            ? __('Chess Podium Demo: 1 day remaining. Full Pro features active.', 'chess-podium')
            : sprintf(_n('Chess Podium Demo: %d day remaining.', 'Chess Podium Demo: %d days remaining.', $days, 'chess-podium'), $days);
        echo '<div class="notice notice-info"><p><strong>' . esc_html($msg) . '</strong> <a href="' . esc_url(admin_url('admin.php?page=chess-podium-license')) . '">' . esc_html__('License', 'chess-podium') . '</a></p></div>';
    }

    public static function maybe_show_dgt_upsell_notice(): void
    {
        if (!class_exists('ChessPodium_License') || ChessPodium_License::is_pro()) {
            return;
        }
        $screen = function_exists('get_current_screen') ? get_current_screen() : null;
        if (!$screen || strpos($screen->id ?? '', 'chess-podium') === false) {
            return;
        }
        $dismissed = get_user_meta(get_current_user_id(), 'chess_podium_dgt_upsell_dismissed', true);
        if ($dismissed) {
            return;
        }
        $dismissUrl = wp_nonce_url(
            add_query_arg(['chess_podium_dismiss' => 'dgt_upsell'], admin_url('admin.php?page=chess-podium')),
            'chess_podium_dismiss'
        );
        $learnUrl = 'https://www.chesspodium.com/download/';
        $msg = __('Did you know you can broadcast your boards live? Connect your DGT boards to your site in minutes with Chess Podium Connect.', 'chess-podium');
        echo '<div class="notice notice-info"><p><strong>' . esc_html($msg) . '</strong> <a href="' . esc_url($learnUrl) . '" target="_blank" rel="noopener">' . esc_html__('Learn how to connect your DGT boards', 'chess-podium') . '</a> → <a href="' . esc_url($dismissUrl) . '" style="margin-left:1em;opacity:0.8;">' . esc_html__('Dismiss', 'chess-podium') . '</a></p></div>';
    }

    public static function render_license_page(): void
    {
        $msg = isset($_GET['cp_msg']) ? sanitize_key(wp_unslash($_GET['cp_msg'])) : '';
        $data = ChessPodium_License::get_license_data();
        $isPro = ChessPodium_License::is_pro();
        $demoInfo = ChessPodium_License::get_demo_info();

        $validationError = null;
        if ($msg === 'license_saved' && !$isPro && !empty($data['key']) && !empty($data['email'])) {
            $result = ChessPodium_License::validate_remote_full(trim((string) $data['key']), (string) $data['email']);
            if (!$result['valid'] && isset($result['reason'])) {
                $validationError = self::get_license_error_message($result);
            }
        }
        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('Chess Podium License', 'chess-podium'); ?></h1>

            <?php if ($msg === 'license_saved'): ?>
                <?php if ($isPro): ?>
                    <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('License saved and validated. Pro active.', 'chess-podium'); ?></p></div>
                <?php elseif ($validationError): ?>
                    <div class="notice notice-error is-dismissible"><p><?php echo esc_html($validationError); ?></p></div>
                <?php else: ?>
                    <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('License saved. Validating...', 'chess-podium'); ?></p></div>
                <?php endif; ?>
            <?php elseif ($msg === 'license_removed'): ?>
                <div class="notice notice-success is-dismissible"><p><?php echo esc_html__('License removed.', 'chess-podium'); ?></p></div>
            <?php endif; ?>

            <div style="max-width:600px;margin:1.5rem 0;">
                <?php if ($demoInfo): ?>
                    <?php if ($demoInfo['expired']): ?>
                        <div class="notice notice-warning" style="padding:1rem;">
                            <p><strong><?php echo esc_html__('Demo expired', 'chess-podium'); ?></strong> – <?php echo esc_html__('Your 7-day trial has ended. You\'re now on the free version.', 'chess-podium'); ?></p>
                            <p><?php echo esc_html(sprintf(__('Limit: %d players per tournament.', 'chess-podium'), ChessPodium_License::get_free_player_limit())); ?> <?php echo esc_html__('Upgrade to Pro for unlimited players. €79/year.', 'chess-podium'); ?></p>
                            <p><?php echo wp_kses_post(sprintf(__('Unlock all Chess Podium features with the Pro license. <a href="%s" target="_blank" rel="noopener">Purchase at chesspodium.com</a>', 'chess-podium'), esc_url('https://www.chesspodium.com/pricing/'))); ?></p>
                        </div>
                    <?php else: ?>
                        <div class="notice notice-success" style="padding:1rem;">
                            <p><strong><?php echo esc_html__('Demo (Pro trial)', 'chess-podium'); ?></strong> – <?php echo esc_html(sprintf(_n('%d day remaining.', '%d days remaining.', $demoInfo['days_remaining'], 'chess-podium'), $demoInfo['days_remaining'])); ?> <?php echo esc_html__('Full Pro features active.', 'chess-podium'); ?></p>
                        </div>
                    <?php endif; ?>
                <?php elseif ($isPro): ?>
                    <div class="notice notice-success" style="padding:1rem;">
                        <p><strong><?php echo esc_html__('Chess Podium Pro', 'chess-podium'); ?></strong> – <?php echo esc_html__('Unlimited players. License active.', 'chess-podium'); ?></p>
                    </div>
                <?php else: ?>
                    <div class="notice notice-info" style="padding:1rem;">
                        <p><strong><?php echo esc_html__('Free version', 'chess-podium'); ?></strong> – <?php echo esc_html(sprintf(__('Limit: %d players per tournament.', 'chess-podium'), ChessPodium_License::get_free_player_limit())); ?></p>
                        <p><?php echo esc_html__('Upgrade to Pro for unlimited players, priority support, and more. €79/year.', 'chess-podium'); ?></p>
                        <p><?php echo wp_kses_post(sprintf(__('Unlock all Chess Podium features with the Pro license. <a href="%s" target="_blank" rel="noopener">Purchase at chesspodium.com</a>', 'chess-podium'), esc_url('https://www.chesspodium.com/pricing/'))); ?></p>
                    </div>
                <?php endif; ?>

                <h2><?php echo esc_html__('License key', 'chess-podium'); ?></h2>
                <form method="post">
                    <?php wp_nonce_field('chess_podium_license', 'chess_podium_license_nonce'); ?>
                    <input type="hidden" name="chess_podium_license_action" value="save">
                    <table class="form-table">
                        <tr>
                            <th><label for="license_key"><?php echo esc_html__('License key', 'chess-podium'); ?></label></th>
                            <td><input id="license_key" name="license_key" type="text" class="regular-text" value="<?php echo esc_attr($data['key'] ?? ''); ?>" placeholder="XXXX-XXXX-XXXX-XXXX"></td>
                        </tr>
                        <tr>
                            <th><label for="license_email"><?php echo esc_html__('Email', 'chess-podium'); ?></label></th>
                            <td><input id="license_email" name="license_email" type="email" class="regular-text" value="<?php echo esc_attr($data['email'] ?? ''); ?>"></td>
                        </tr>
                    </table>
                    <p>
                        <button type="submit" class="button button-primary"><?php echo esc_html__('Save license', 'chess-podium'); ?></button>
                        <?php if (!empty($data['key'])): ?>
                            <button type="submit" name="chess_podium_license_action" value="remove" class="button" formnovalidate onclick="return confirm('<?php echo esc_js(__('Remove license?', 'chess-podium')); ?>');"><?php echo esc_html__('Remove license', 'chess-podium'); ?></button>
                        <?php endif; ?>
                    </p>
                </form>
                <p class="description"><?php echo esc_html__('Enter the license key you received after purchasing Chess Podium Pro. One license per domain—it will be locked to this site on first activation.', 'chess-podium'); ?></p>
                <p class="description"><strong><?php echo esc_html__('This site\'s domain:', 'chess-podium'); ?></strong> <code><?php echo esc_html(parse_url(home_url(), PHP_URL_HOST) ?: '-'); ?></code></p>
            </div>
        </div>
        <?php
    }

    public static function register_widgets(): void
    {
        register_widget(ChessPodium_Widget::class);
    }

    public static function enqueue_widget_styles(): void
    {
        if (is_active_widget(false, false, 'chess_podium_tornei', true)) {
            wp_enqueue_style(
                'chess-podium-widget',
                plugins_url('assets/widget.css', __FILE__),
                [],
                '0.4.0'
            );
        }
    }

    public static function enqueue_public_styles(): void
    {
        global $post;
        if (!$post || !is_singular()) {
            return;
        }
        $content = (string) ($post->post_content ?? '');
        $shortcodes = ['chess_podium_club_ranking', 'chess_podium_player_profile', 'chess_podium_brochure', 'chess_podium_grand_prix'];
        foreach ($shortcodes as $sc) {
            if (has_shortcode($content, $sc)) {
                wp_enqueue_style(
                    'chess-podium-public',
                    plugins_url('assets/public.css', CHESS_PODIUM_PLUGIN_FILE),
                    [],
                    '0.4.0'
                );
                break;
            }
        }
        $tornei_shortcodes = ['chess_podium_tornei', 'chess_podium_manager_tornei', 'chess_podium_storico_tornei', 'checkmate_manager_tornei', 'storico_tornei'];
        foreach ($tornei_shortcodes as $sc) {
            if (has_shortcode($content, $sc)) {
                wp_register_style('chess-podium-tornei-grid', false, [], CHESS_PODIUM_VERSION);
                wp_enqueue_style('chess-podium-tornei-grid');
                $tornei_grid_css = '.checkmate-tornei-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } .checkmate-torneo-card { display: block; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; text-decoration: none; color: inherit; transition: box-shadow 0.2s; } .checkmate-torneo-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.15); } .checkmate-torneo-preview { aspect-ratio: 16/10; background: #2c3e50; overflow: hidden; } .checkmate-torneo-preview img { width: 100%; height: 100%; object-fit: cover; } .checkmate-torneo-info { padding: 1rem; } .checkmate-torneo-info h3 { margin: 0 0 0.25rem; font-size: 1.1em; } .checkmate-torneo-meta { font-size: 0.85em; color: #666; }';
                wp_add_inline_style('chess-podium-tornei-grid', $tornei_grid_css);
                break;
            }
        }
    }

    public static function enqueue_tournament_page_assets(): void
    {
        $on_tournament_page = (int) get_query_var('chess_podium_torneo_page') === 1 || (int) get_query_var('chess_podium_live') === 1;
        global $post;
        $has_shortcode = $post && is_singular() && has_shortcode((string) $post->post_content, 'chess_podium_tournament');
        if (!$on_tournament_page && !$has_shortcode) {
            return;
        }
        $base = plugins_url('', CHESS_PODIUM_PLUGIN_FILE);
        wp_enqueue_script('jquery');
        wp_register_script(
            'chess-podium-chess',
            $base . '/assets/js/vendor/chess.min.js',
            [],
            '0.10.3'
        );
        wp_register_script(
            'chess-podium-chessboard',
            $base . '/assets/js/vendor/chessboard-1.0.0.min.js',
            ['jquery', 'chess-podium-chess'],
            '1.0.0'
        );
        wp_enqueue_script('chess-podium-chess', $base . '/assets/js/vendor/chess.min.js', [], '0.10.3', true);
        wp_enqueue_script('chess-podium-chessboard', $base . '/assets/js/vendor/chessboard-1.0.0.min.js', ['jquery', 'chess-podium-chess'], '1.0.0', true);
        wp_enqueue_script(
            'chess-podium-live-boards',
            $base . '/assets/js/live-boards.js',
            ['chess-podium-chessboard'],
            CHESS_PODIUM_VERSION,
            true
        );
        wp_enqueue_style(
            'chess-podium-chessboard',
            $base . '/assets/css/chessboard-1.0.0.min.css',
            [],
            '1.0.0'
        );
        $tournament_css = 'body.cp-tournament-route{font-family:Arial,sans-serif;margin:0;background:#f0f0f0!important;color:#1a1a1a!important}body.cp-tournament-route .cp-tournament-wrap{max-width:1100px;margin:2rem auto;background:#fff!important;padding:1.5rem;border-radius:10px;color:#1a1a1a!important;box-shadow:0 1px 3px rgba(0,0,0,.08)}body.cp-tournament-route .cp-tournament-wrap h1,body.cp-tournament-route .cp-tournament-wrap h2{color:#1a1a1a!important;margin-top:0}body.cp-tournament-route .cp-tournament-wrap table{width:100%;border-collapse:collapse;margin:1rem 0;color:#1a1a1a}body.cp-tournament-route .cp-tournament-wrap th,body.cp-tournament-route .cp-tournament-wrap td{border:1px solid #ddd;padding:.5rem;text-align:left;color:#1a1a1a!important;background-color:inherit}body.cp-tournament-route .cp-tournament-wrap th{background:#e8e8e8!important;color:#1a1a1a!important}body.cp-tournament-route .cp-tournament-wrap a{color:#2563eb!important}body.cp-tournament-route .cp-tournament-wrap a:hover{color:#1d4ed8!important}.muted{color:#666!important}.cp-no-tournament{text-align:center;padding:3rem 1rem;color:#1a1a1a}.cp-flag-img{display:inline-block;vertical-align:middle;margin-right:4px}.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:.85}.cp-sponsor-item a:hover .cp-sponsor-logo{opacity:1}.cp-live-badge,.cp-watch-live-btn{display:inline-block;margin-left:.35em;padding:.15em .5em;font-size:.75em;font-weight:600;color:#fff!important;background:#22c55e!important;border-radius:4px;vertical-align:middle}.cp-watch-live-btn{padding:.35em .75em;text-decoration:none;transition:background .2s}.cp-watch-live-btn:hover{background:#16a34a!important;color:#fff!important}.cp-live-board-clickable:hover,.cp-live-board-link:hover{border-color:#22c55e;box-shadow:0 0 0 2px rgba(34,197,94,.3)}.cp-live-board-block,.cp-live-board-header{color:#1a1a1a!important}.cp-live-board{min-width:280px!important;min-height:280px!important;width:280px!important;height:280px!important;overflow:visible!important;display:block!important}.cp-live-board .board-b72b1,.cp-live-board .square-55d63,.cp-live-board .chessboard-63f37{visibility:visible!important;display:block!important}.cp-norm-badge{display:inline-block;margin-left:.35em;padding:.15em .5em;font-size:.8em;font-weight:600;color:#1a365d;background:#e8f4fc;border:1px solid #2c5282;border-radius:4px;vertical-align:middle}.cp-public-footer{text-align:center;margin-top:2rem;padding-top:1rem;border-top:1px solid #e2e8f0;color:#666;font-size:.9em;line-height:1.6}.cp-public-footer a{color:#2563eb!important}.cp-public-footer a:hover{color:#1d4ed8!important}.cp-public-footer .cp-footer-brand{display:inline-flex;align-items:center;gap:.35em;justify-content:center}.cp-public-footer .cp-footer-logo{vertical-align:middle;opacity:.9}';
        wp_add_inline_style('chess-podium-chessboard', $tournament_css);
        $pgn_nav_css = '.cp-live-board-pgn-nav{font-size:.85em;color:#374151;display:block!important;visibility:visible!important;min-height:36px!important}.cp-live-pgn-controls{display:flex!important;align-items:center;gap:.4rem;margin-bottom:.5rem;flex-wrap:wrap}.cp-live-pgn-controls button{padding:.25rem .5rem;font-size:.9rem;cursor:pointer;background:#e5e7eb;border:1px solid #d1d5db;border-radius:4px}.cp-live-pgn-controls button:hover:not(:disabled){background:#d1d5db}.cp-live-pgn-controls button:disabled{opacity:.5;cursor:not-allowed}.cp-live-pgn-info{min-width:4rem;font-size:.9em;color:#6b7280}.cp-live-pgn-moves{max-height:4.5em;overflow-y:auto;line-height:1.5;word-wrap:break-word}.cp-live-pgn-move{cursor:pointer;padding:.1em .2em;border-radius:2px}.cp-live-pgn-move:hover{background:#e5e7eb}.cp-live-pgn-move-current{background:#22c55e;color:#fff;font-weight:600}.cp-live-pgn-move-current:hover{background:#16a34a}#cp-live-lightbox #cp-live-lightbox-board{width:400px!important;height:400px!important;max-width:100%!important}.cp-navigation-controls,.cp-live-lightbox-move-nav{display:flex!important;justify-content:center!important;gap:10px;margin-top:15px;z-index:10001!important;position:relative;visibility:visible!important}.cp-btn,.cp-live-move-first,.cp-live-move-prev,.cp-live-move-next,.cp-live-move-last{cursor:pointer;padding:10px 20px;background:#e5e7eb;border:1px solid #d1d5db;border-radius:4px;font-size:1rem}';
        wp_add_inline_style('chess-podium-chessboard', $pgn_nav_css);
        wp_add_inline_style('chess-podium-chessboard', self::get_custom_style_css());
        $defaultPieceTheme = $base . '/assets/img/chesspieces/wikipedia/{piece}.png';
        $pieceTheme = apply_filters('chess_podium_piece_theme', $defaultPieceTheme);
        wp_localize_script('chess-podium-chessboard', 'chessPodiumConfig', [
            'pieceTheme' => $pieceTheme,
        ]);
    }

    public static function register_admin_menu(): void
    {
        add_menu_page(
            'Chess Podium',
            'Chess Podium',
            'manage_options',
            self::MENU_SLUG,
            [self::class, 'render_admin_page'],
            plugins_url('assets/logo-chess-podium.png', __FILE__),
            26
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('Add to page', 'chess-podium'),
            __('Add to page', 'chess-podium'),
            'edit_posts',
            'chess-podium-add-to-page',
            [self::class, 'render_add_to_page']
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('Setup wizard', 'chess-podium'),
            __('Setup wizard', 'chess-podium'),
            'manage_options',
            'chess-podium-setup-wizard',
            [self::class, 'render_setup_wizard_page']
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('External tournaments', 'chess-podium'),
            __('External tournaments', 'chess-podium'),
            'manage_options',
            'chess-podium-external-tournaments',
            [self::class, 'render_external_tournaments_page']
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('Pairing', 'chess-podium'),
            __('Pairing', 'chess-podium'),
            'manage_options',
            'chess-podium-pairing',
            [self::class, 'render_pairing_settings_page']
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('Sponsors', 'chess-podium'),
            __('Sponsors', 'chess-podium'),
            'manage_options',
            'chess-podium-sponsors',
            [self::class, 'render_sponsors_page']
        );
        add_submenu_page(
            self::MENU_SLUG,
            __('License', 'chess-podium'),
            __('License', 'chess-podium'),
            'manage_options',
            'chess-podium-license',
            [self::class, 'render_license_page']
        );
        if (ChessPodium_License::is_pro()) {
            add_submenu_page(
                self::MENU_SLUG,
                __('Registration payments', 'chess-podium'),
                __('Registration payments', 'chess-podium'),
                'manage_options',
                'chess-podium-registration',
                [ChessPodium_Registration::class, 'render_settings_page']
            );
        }
    }

    public static function handle_admin_actions(): void
    {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }

        if (isset($_GET['chess_podium_dismiss']) && isset($_GET['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'chess_podium_dismiss')) {
            $dismiss = sanitize_key(wp_unslash($_GET['chess_podium_dismiss']));
            if ($dismiss === 'dgt_upsell') {
                update_user_meta(get_current_user_id(), 'chess_podium_dgt_upsell_dismissed', 1);
                wp_safe_redirect(remove_query_arg(['chess_podium_dismiss', '_wpnonce']));
                exit;
            }
        }

        if (!empty($_POST['chess_podium_license_action'])) {
            check_admin_referer('chess_podium_license', 'chess_podium_license_nonce');
            $licAction = sanitize_text_field(wp_unslash($_POST['chess_podium_license_action']));
            if ($licAction === 'save') {
                $key = isset($_POST['license_key']) ? trim(sanitize_text_field(wp_unslash($_POST['license_key']))) : '';
                $email = isset($_POST['license_email']) ? trim(sanitize_email(wp_unslash($_POST['license_email']))) : '';
                ChessPodium_License::save_license($key, $email);
                wp_safe_redirect(admin_url('admin.php?page=chess-podium-license&cp_msg=license_saved'));
                exit;
            }
            if ($licAction === 'remove') {
                ChessPodium_License::remove_license();
                wp_safe_redirect(admin_url('admin.php?page=chess-podium-license&cp_msg=license_removed'));
                exit;
            }
        }

        if (isset($_POST['chess_podium_test_pairing_api']) && check_admin_referer('chess_podium_pairing_settings')) {
            $apiUrl = self::get_pairing_api_url();
            $result = ['ok' => false, 'msg' => ''];
            if ($apiUrl === '' || !preg_match('#^https?://#i', $apiUrl)) {
                $result['msg'] = __('Pairing API URL not available.', 'chess-podium');
            } else {
                $minimalTrf = ChessPodium_TrfExport::generate_minimal_trf_for_api_test();
                $engine = get_option('chess_podium_pairing_engine', 'javafo');
                $response = wp_remote_post($apiUrl, [
                    'timeout' => 15,
                    'body' => ['trf' => base64_encode($minimalTrf), 'engine' => $engine],
                    'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
                ]);
                if (is_wp_error($response)) {
                    $result['msg'] = __('Connection failed:', 'chess-podium') . ' ' . $response->get_error_message();
                } else {
                    $code = wp_remote_retrieve_response_code($response);
                    $body = wp_remote_retrieve_body($response);
                    $json = json_decode($body, true);
                    if ($code !== 200) {
                        $result['msg'] = sprintf(__('HTTP %d. Response: %s', 'chess-podium'), $code, esc_html(substr($body, 0, 200)));
                    } elseif (!is_array($json)) {
                        $result['msg'] = __('Invalid JSON response:', 'chess-podium') . ' ' . esc_html(substr($body, 0, 150));
                    } elseif (empty($json['ok'])) {
                        $result['msg'] = __('API error:', 'chess-podium') . ' ' . esc_html($json['error'] ?? $body);
                    } elseif (empty($json['pairs']) || !is_array($json['pairs'])) {
                        $result['msg'] = __('API returned no pairings.', 'chess-podium');
                    } else {
                        $result['ok'] = true;
                        $result['msg'] = sprintf(__('API OK. Received %d pairings.', 'chess-podium'), count($json['pairs']));
                    }
                }
            }
            set_transient('chess_podium_pairing_api_test', $result, 60);
            $returnUrl = isset($_POST['chess_podium_test_return']) ? esc_url_raw(wp_unslash($_POST['chess_podium_test_return'])) : '';
            if ($returnUrl !== '' && strpos($returnUrl, admin_url()) === 0) {
                wp_safe_redirect($returnUrl);
            } else {
                wp_safe_redirect(admin_url('admin.php?page=chess-podium-pairing'));
            }
            exit;
        }

        if (empty($_POST['regina_action'])) {
            return;
        }

        check_admin_referer('regina_torneo_action', 'regina_nonce');

        $action = sanitize_text_field(wp_unslash($_POST['regina_action']));
        if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG && $action === 'import_chess_results') {
            error_log('[Chess Podium] handle_admin_actions: action=' . $action);
        }
        $tid = isset($_POST['tournament_id']) ? (int) $_POST['tournament_id'] : 0;
        $redirectArgs = [];
        $redirectToTournament = true;

        switch ($action) {
            case 'create_tournament':
                $name = isset($_POST['name']) ? trim(sanitize_text_field(wp_unslash($_POST['name']))) : '';
                $rounds = isset($_POST['rounds_total']) ? (int) $_POST['rounds_total'] : 5;
                $rounds = max(1, min(self::MAX_ROUNDS_TOTAL, $rounds));
                $type = isset($_POST['tournament_type']) ? sanitize_key(wp_unslash($_POST['tournament_type'])) : 'swiss';
                $byePoints = isset($_POST['bye_points']) ? (float) $_POST['bye_points'] : 1.0;
                $tiebreakers = isset($_POST['tiebreakers']) ? sanitize_text_field(wp_unslash($_POST['tiebreakers'])) : 'buchholz,sb,rating';
                if ($name === '') {
                    $redirectArgs['cp_msg'] = 'create_error_name';
                } else {
                    $newId = self::insert_tournament($name, $rounds, $type, $byePoints, $tiebreakers);
                    $redirectArgs['cp_msg'] = 'create_success';
                    if ($newId > 0) {
                        $redirectArgs['tournament_id'] = $newId;
                    }
                }
                break;

            case 'cp_setup_wizard_create':
                $name = isset($_POST['wizard_name']) ? trim(sanitize_text_field(wp_unslash($_POST['wizard_name']))) : '';
                $rounds = isset($_POST['wizard_rounds_total']) ? (int) $_POST['wizard_rounds_total'] : 7;
                $rounds = max(1, min(self::MAX_ROUNDS_TOTAL, $rounds));
                $type = isset($_POST['wizard_tournament_type']) ? sanitize_key(wp_unslash($_POST['wizard_tournament_type'])) : 'swiss';
                $type = in_array($type, ['swiss', 'round_robin', 'double_round_robin'], true) ? $type : 'swiss';
                $newId = 0;
                if ($name === '') {
                    $redirectArgs['cp_msg'] = 'create_error_name';
                } else {
                    $newId = self::insert_tournament($name, $rounds, $type, 1.0, 'buchholz,sb,rating');
                    if ($newId > 0) {
                        $redirectArgs['tournament_id'] = $newId;
                    }
                }
                if ($newId > 0 && class_exists('ChessPodium_PdfBrochure')) {
                    $venuePhoto = isset($_POST['wizard_venue_photo']) ? esc_url_raw(wp_unslash($_POST['wizard_venue_photo'])) : '';
                    $logoUrl = isset($_POST['wizard_logo_url']) ? esc_url_raw(wp_unslash($_POST['wizard_logo_url'])) : '';
                    if ($venuePhoto !== '') {
                        ChessPodium_PdfBrochure::set_meta($newId, 'brochure_venue_photos', wp_json_encode([$venuePhoto]));
                    }
                    if ($logoUrl !== '') {
                        ChessPodium_PdfBrochure::set_meta($newId, 'brochure_logo', $logoUrl);
                    }
                }
                if ($newId > 0) {
                    $playersRaw = isset($_POST['wizard_players']) ? trim((string) wp_unslash($_POST['wizard_players'])) : '';
                    if ($playersRaw !== '') {
                        $lines = preg_split('/\r\n|\r|\n/', $playersRaw) ?: [];
                        foreach ($lines as $line) {
                            $line = trim((string) $line);
                            if ($line === '') {
                                continue;
                            }
                            $parts = array_map('trim', explode(';', $line));
                            $playerName = sanitize_text_field($parts[0] ?? '');
                            if ($playerName === '') {
                                continue;
                            }
                            $rating = isset($parts[1]) ? max(0, min(3500, (int) $parts[1])) : 0;
                            $country = isset($parts[2]) ? self::sanitize_country_code(sanitize_text_field($parts[2])) : '';
                            $title = isset($parts[3]) ? self::sanitize_fide_title(sanitize_text_field($parts[3])) : '';
                            if ($country === null || $country === '') {
                                $country = 'ITA';
                            }
                            self::insert_player($newId, $playerName, $rating, null, $country, $title !== '' ? $title : null);
                        }
                    }
                }
                $redirectToTournament = false;
                $redirectArgs = ['page' => 'chess-podium-setup-wizard'];
                if ($newId > 0) {
                    update_option(self::ONBOARDING_DONE_OPTION, 1);
                    $redirectArgs['tournament_id'] = $newId;
                    if (!empty($_POST['wizard_create_page'])) {
                        $pageId = wp_insert_post([
                            'post_type' => 'page',
                            'post_status' => 'publish',
                            'post_title' => sprintf(__('Tournament - %s', 'chess-podium'), $name),
                            'post_content' => self::get_tournament_shortcode_for_id($newId),
                        ]);
                        if (!is_wp_error($pageId) && (int) $pageId > 0) {
                            $redirectArgs['created_page_id'] = (int) $pageId;
                        }
                    }
                }
                break;

            case 'create_tournament_from_excel':
                $excelFile = '';
                if (!empty($_FILES['excel_upload']['tmp_name']) && is_uploaded_file($_FILES['excel_upload']['tmp_name'])) {
                    $excelFile = $_FILES['excel_upload']['tmp_name'];
                } elseif (isset($_POST['excel_file'])) {
                    $submitted = sanitize_text_field(wp_unslash($_POST['excel_file']));
                    $pluginDir = CHESS_PODIUM_PLUGIN_DIR;
                    $realPlugin = realpath($pluginDir);
                    $realSubmitted = realpath($submitted);
                    $pluginNorm = $realPlugin ? str_replace('\\', '/', $realPlugin) : '';
                    $dirNorm = $realSubmitted ? str_replace('\\', '/', dirname($realSubmitted)) : '';
                    if ($pluginNorm && $realSubmitted && $dirNorm === $pluginNorm && is_file($realSubmitted)) {
                        $excelFile = $realSubmitted;
                    }
                }
                if ($excelFile !== '') {
                    $data = ChessPodium_ExcelImport::parse($excelFile);
                    if ($data !== null) {
                        $type = isset($_POST['excel_tournament_type']) ? sanitize_key(wp_unslash($_POST['excel_tournament_type'])) : 'swiss';
                        $type = in_array($type, ['swiss', 'round_robin', 'double_round_robin'], true) ? $type : 'swiss';
                        $rounds = isset($_POST['excel_rounds_total']) ? max(1, min(99, (int) $_POST['excel_rounds_total'])) : $data['rounds'];
                        $byePoints = isset($_POST['excel_bye_points']) ? max(0.0, min(1.0, (float) $_POST['excel_bye_points'])) : 1.0;
                        $tiebreakers = isset($_POST['excel_tiebreakers']) ? sanitize_text_field(wp_unslash($_POST['excel_tiebreakers'])) : 'buchholz,sb,rating';
                        $newId = self::insert_tournament($data['name'], $rounds, $type, $byePoints, $tiebreakers);
                        if ($newId > 0) {
                            foreach ($data['players'] as $p) {
                                if (ChessPodium_License::can_add_players(count(self::get_players($newId)), 1)) {
                                    $startNum = isset($p['start_number']) && $p['start_number'] > 0 ? (int) $p['start_number'] : null;
                                    self::insert_player($newId, $p['name'], $p['rating'], $p['fide_id'], $p['country'], $p['title'] ?? null, null, null, $startNum);
                                }
                            }
                            $redirectArgs['tournament_id'] = $newId;
                            $redirectArgs['cp_msg'] = 'excel_import_success';
                            $redirectArgs['cp_imported'] = count($data['players']);
                        } else {
                            $redirectArgs['cp_msg'] = 'excel_import_error';
                        }
                    } else {
                        $redirectArgs['cp_msg'] = 'excel_import_error';
                    }
                } else {
                    $redirectArgs['cp_msg'] = 'excel_import_no_file';
                }
                break;

            case 'save_registration_settings':
                if ($tid > 0 && ChessPodium_License::is_pro()) {
                    global $wpdb;
                    $enabled = isset($_POST['registration_enabled']) ? 1 : 0;
                    $fee = isset($_POST['registration_fee']) ? max(0.0, (float) $_POST['registration_fee']) : 0.0;
                    $currency = isset($_POST['registration_currency']) ? sanitize_text_field(wp_unslash($_POST['registration_currency'])) : 'EUR';
                    $currency = in_array($currency, ['EUR', 'USD', 'GBP'], true) ? $currency : 'EUR';
                    $wpdb->update(
                        self::db_table('regina_tournaments'),
                        ['registration_enabled' => $enabled, 'registration_fee' => $fee, 'registration_currency' => $currency],
                        ['id' => $tid],
                        ['%d', '%f', '%s'],
                        ['%d']
                    );
                    $redirectArgs['cp_msg'] = 'registration_settings_saved';
                }
                break;

            case 'add_player':
                $playerName = isset($_POST['player_name']) ? trim(sanitize_text_field(wp_unslash($_POST['player_name']))) : '';
                $rating = isset($_POST['player_rating']) ? (int) $_POST['player_rating'] : 0;
                $rating = max(0, min(3500, $rating));
                $country = isset($_POST['player_country']) ? self::sanitize_country_code(sanitize_text_field(wp_unslash($_POST['player_country']))) : null;
                $title = isset($_POST['player_title']) ? self::sanitize_fide_title(sanitize_text_field(wp_unslash($_POST['player_title']))) : null;
                $playerFideRaw = isset($_POST['player_fide_id']) ? sanitize_text_field(wp_unslash($_POST['player_fide_id'])) : '';
                $playerFideId = self::sanitize_fide_id($playerFideRaw);
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'add_player_error_tournament';
                } elseif ($playerName === '') {
                    $redirectArgs['cp_msg'] = 'add_player_error_name';
                } elseif ($country === null || $country === '') {
                    $redirectArgs['cp_msg'] = 'add_player_error_country';
                } elseif ($playerFideId !== '' && self::get_player_by_fide_id($tid, $playerFideId)) {
                    $redirectArgs['cp_msg'] = 'fide_exists';
                } elseif (!ChessPodium_License::can_add_players(count(self::get_players($tid)), 1)) {
                    $redirectArgs['cp_msg'] = 'player_limit_reached';
                } else {
                    $uscfId = isset($_POST['uscf_id']) ? self::sanitize_uscf_id(sanitize_text_field(wp_unslash($_POST['uscf_id']))) : null;
                    $ecfId = isset($_POST['ecf_id']) ? self::sanitize_ecf_id(sanitize_text_field(wp_unslash($_POST['ecf_id']))) : null;
                    $fideForInsert = $playerFideId !== '' ? $playerFideId : null;
                    $playerEmail = isset($_POST['player_email']) ? sanitize_email(wp_unslash((string) $_POST['player_email'])) : '';
                    $allowMail = isset($_POST['player_allow_notifications']) ? 1 : 0;
                    self::insert_player($tid, $playerName, $rating, $fideForInsert, $country, $title, $uscfId ?: null, $ecfId ?: null, null, $playerEmail !== '' ? $playerEmail : null, $playerEmail !== '' ? $allowMail : 0);
                    $redirectArgs['cp_msg'] = 'add_player_success';
                }
                break;

            case 'import_fide_player':
                $fideIdRaw = isset($_POST['fide_id']) ? sanitize_text_field(wp_unslash($_POST['fide_id'])) : '';
                $fideId = self::sanitize_fide_id($fideIdRaw);
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'add_player_error_tournament';
                } elseif ($fideId === '') {
                    $redirectArgs['cp_msg'] = 'fide_invalid';
                } else {
                    $existing = self::get_player_by_fide_id($tid, $fideId);
                    if ($existing) {
                        $redirectArgs['cp_msg'] = 'fide_exists';
                    } elseif (!ChessPodium_License::can_add_players(count(self::get_players($tid)), 1)) {
                        $redirectArgs['cp_msg'] = 'player_limit_reached';
                    } else {
                        $fideData = self::fetch_fide_player_data($fideId);
                        if ($fideData !== null) {
                            $country = $fideData['country'] ?? null;
                            $title = $fideData['title'] ?? null;
                            self::insert_player($tid, $fideData['name'], $fideData['rating'], $fideId, $country, $title);
                            $redirectArgs['cp_msg'] = 'fide_imported';
                        } else {
                            $redirectArgs['cp_msg'] = 'fide_not_found';
                        }
                    }
                }
                $redirectArgs['cp_tab'] = 'players';
                break;

            case 'sync_fide_player':
                $playerId = isset($_POST['player_id']) ? (int) $_POST['player_id'] : 0;
                if ($tid <= 0 || $playerId <= 0) {
                    $redirectArgs['cp_msg'] = 'fide_sync_error';
                } else {
                    $ok = self::sync_player_from_fide($tid, $playerId);
                    $redirectArgs['cp_msg'] = $ok ? 'fide_synced' : 'fide_not_found';
                }
                $redirectArgs['cp_tab'] = 'players';
                break;

            case 'sync_fide_all_players':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'fide_bulk_noop';
                } else {
                    $count = self::sync_all_players_from_fide($tid);
                    $redirectArgs['cp_msg'] = $count > 0 ? 'fide_bulk_synced' : 'fide_bulk_noop';
                    $redirectArgs['cp_imported'] = $count;
                }
                $redirectArgs['cp_tab'] = 'players';
                break;

            case 'update_player':
                $playerId = isset($_POST['player_id']) ? (int) $_POST['player_id'] : 0;
                $playerName = isset($_POST['player_name']) ? trim(sanitize_text_field(wp_unslash($_POST['player_name']))) : '';
                $rating = isset($_POST['player_rating']) ? (int) $_POST['player_rating'] : 0;
                $rating = max(0, min(3500, $rating));
                $country = isset($_POST['player_country']) ? self::sanitize_country_code(sanitize_text_field(wp_unslash($_POST['player_country']))) : null;
                $title = isset($_POST['player_title']) ? self::sanitize_fide_title(sanitize_text_field(wp_unslash($_POST['player_title']))) : null;
                if ($tid <= 0 || $playerId <= 0) {
                    $redirectArgs['cp_msg'] = 'update_player_error';
                } elseif ($playerName === '') {
                    $redirectArgs['cp_msg'] = 'update_player_error_name';
                } else {
                    $uscfId = isset($_POST['uscf_id']) ? self::sanitize_uscf_id(sanitize_text_field(wp_unslash($_POST['uscf_id']))) : null;
                    $ecfId = isset($_POST['ecf_id']) ? self::sanitize_ecf_id(sanitize_text_field(wp_unslash($_POST['ecf_id']))) : null;
                    self::update_player($tid, $playerId, $playerName, $rating, $country, $title, $uscfId ?: null, $ecfId ?: null, null, false, null, null);
                    self::bump_cache_version($tid);
                    $redirectArgs['cp_msg'] = 'update_player_success';
                }
                break;

            case 'bulk_update_players':
                $playersData = isset($_POST['players']) && is_array($_POST['players']) ? $_POST['players'] : [];
                $photoFiles = isset($_FILES['player_photo_files']) && is_array($_FILES['player_photo_files']) ? $_FILES['player_photo_files'] : null;
                $updated = 0;
                foreach ($playersData as $rowIndex => $row) {
                    $playerId = isset($row['player_id']) ? (int) $row['player_id'] : 0;
                    if ($playerId <= 0) {
                        continue;
                    }
                    $playerName = isset($row['player_name']) ? trim(sanitize_text_field(wp_unslash($row['player_name']))) : '';
                    if ($playerName === '') {
                        continue;
                    }
                    $rating = isset($row['player_rating']) ? max(0, min(3500, (int) $row['player_rating'])) : 0;
                    $country = isset($row['player_country']) ? self::sanitize_country_code(sanitize_text_field(wp_unslash($row['player_country']))) : null;
                    $title = isset($row['player_title']) ? self::sanitize_fide_title(sanitize_text_field(wp_unslash($row['player_title']))) : null;
                    $uscfId = isset($row['uscf_id']) ? self::sanitize_uscf_id(sanitize_text_field(wp_unslash($row['uscf_id']))) : null;
                    $ecfId = isset($row['ecf_id']) ? self::sanitize_ecf_id(sanitize_text_field(wp_unslash($row['ecf_id']))) : null;
                    $photoAttachmentId = null;
                    if (array_key_exists('photo_attachment_id', $row)) {
                        $rawPhoto = isset($row['photo_attachment_id']) ? trim((string) $row['photo_attachment_id']) : '';
                        $candidate = $rawPhoto !== '' ? (int) $rawPhoto : 0;
                        if ($candidate > 0 && function_exists('wp_attachment_is_image') && wp_attachment_is_image($candidate)) {
                            $photoAttachmentId = $candidate;
                        } else {
                            $photoAttachmentId = 0;
                        }
                    }
                    // If a new file was uploaded for this row, it overrides the selected/removal value.
                    if ($photoFiles && isset($photoFiles['tmp_name']) && is_array($photoFiles['tmp_name'])) {
                        $tmp = $photoFiles['tmp_name'][$rowIndex] ?? '';
                        $err = isset($photoFiles['error'][$rowIndex]) ? (int) $photoFiles['error'][$rowIndex] : UPLOAD_ERR_NO_FILE;
                        if ($err !== UPLOAD_ERR_NO_FILE) {
                            if ($err === UPLOAD_ERR_OK && is_string($tmp) && $tmp !== '' && is_uploaded_file($tmp)) {
                                $file = [
                                    'name' => sanitize_file_name($photoFiles['name'][$rowIndex] ?? ''),
                                    'type' => (string) ($photoFiles['type'][$rowIndex] ?? ''),
                                    'tmp_name' => $tmp,
                                    'error' => $err,
                                    'size' => (int) ($photoFiles['size'][$rowIndex] ?? 0),
                                ];
                                $attachId = self::handle_photo_upload($tid, $file);
                                if ($attachId > 0) {
                                    $photoAttachmentId = $attachId;
                                }
                            }
                        }
                    }
                    $tournament = self::get_tournament($tid);
                    $isRoundRobin = $tournament && self::is_round_robin_family($tournament);
                    $startNum = null;
                    if ($isRoundRobin && array_key_exists('start_number', $row)) {
                        $raw = isset($row['start_number']) ? trim((string) $row['start_number']) : '';
                        $startNum = ($raw !== '' && (int) $raw > 0) ? (int) $raw : null;
                    }
                    $emailRow = isset($row['player_email']) ? sanitize_email(wp_unslash((string) $row['player_email'])) : '';
                    $allowVal = !empty($row['allow_notifications']) ? 1 : 0;
                    self::update_player($tid, $playerId, $playerName, $rating, $country, $title, $uscfId ?: null, $ecfId ?: null, $startNum, $isRoundRobin, $emailRow, $allowVal, $photoAttachmentId);
                    $updated++;
                }
                if ($updated > 0) {
                    self::bump_cache_version($tid);
                }
                $redirectArgs['cp_msg'] = $updated > 0 ? 'bulk_update_success' : 'bulk_update_no_changes';
                $redirectArgs['cp_imported'] = $updated;
                $redirectArgs['cp_tab'] = 'players';
                break;

            case 'randomize_pairing_numbers':
                $tournament = $tid > 0 ? self::get_tournament($tid) : null;
                if ($tid <= 0 || !$tournament || !self::is_round_robin_family($tournament)) {
                    $redirectArgs['cp_msg'] = 'randomize_error';
                } else {
                    $players = self::get_players($tid);
                    if (count($players) < 2) {
                        $redirectArgs['cp_msg'] = 'randomize_error';
                    } else {
                        $nums = range(1, count($players));
                        shuffle($nums);
                        global $wpdb;
                        $playersTable = self::db_table('regina_players');
                        foreach ($players as $i => $p) {
                            $wpdb->update(
                                $playersTable,
                                ['start_number' => $nums[$i]],
                                ['id' => (int) $p->id, 'tournament_id' => $tid],
                                ['%d'],
                                ['%d', '%d']
                            );
                        }
                        $redirectArgs['cp_msg'] = 'randomize_success';
                        $redirectArgs['cp_tab'] = 'players';
                    }
                }
                break;

            case 'delete_player':
                $playerId = isset($_POST['player_id']) ? (int) $_POST['player_id'] : 0;
                if ($tid <= 0 || $playerId <= 0) {
                    $redirectArgs['cp_msg'] = 'delete_player_error';
                } else {
                    $deleted = self::delete_player($tid, $playerId);
                    $redirectArgs['cp_msg'] = $deleted ? 'delete_player_success' : 'delete_player_in_use';
                    if ($deleted) {
                        self::bump_cache_version($tid);
                    }
                }
                break;

            case 'generate_round':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'generate_error';
                } else {
                    $result = self::generate_next_round($tid);
                    $redirectArgs['cp_msg'] = $result ? 'generate_success' : 'generate_failed';
                    if ($result && self::$lastPairingSource !== null) {
                        $redirectArgs['cp_pairing_source'] = self::$lastPairingSource;
                    }
                    if ($result) {
                        self::bump_cache_version($tid);
                    }
                }
                break;

            case 'import_round_from_lichess':
                $lichessUrl = isset($_POST['lichess_round_url']) ? trim(wp_unslash($_POST['lichess_round_url'])) : '';
                if ($lichessUrl !== '' && !preg_match('#^[a-z][a-z0-9+.-]*://#i', $lichessUrl)) {
                    $lichessUrl = 'https://' . ltrim($lichessUrl, '/');
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'lichess_import_error';
                } else {
                    $redirectArgs['cp_tab'] = 'overview';
                    $out = self::import_next_round_from_lichess_pgn_url($tid, $lichessUrl);
                    if (!empty($out['ok'])) {
                        $redirectArgs['cp_msg'] = 'lichess_import_success';
                        $redirectArgs['cp_imported'] = (int) ($out['games'] ?? 0);
                        self::bump_cache_version($tid);
                    } else {
                        $redirectArgs['cp_msg'] = 'lichess_import_error';
                        if (!empty($out['message'])) {
                            $redirectArgs['cp_lichess_detail'] = rawurlencode(substr((string) $out['message'], 0, 800));
                        }
                    }
                }
                break;

            case 'generate_round_print':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'generate_error';
                } else {
                    $result = self::generate_next_round($tid);
                    if ($result) {
                        self::bump_cache_version($tid);
                        $updatedTournament = self::get_tournament($tid);
                        if ($updatedTournament) {
                            $redirectArgs['print_round'] = (int) $updatedTournament->current_round;
                            $redirectArgs['cp_msg'] = 'generate_success';
                            if (self::$lastPairingSource !== null) {
                                $redirectArgs['cp_pairing_source'] = self::$lastPairingSource;
                            }
                        }
                    } else {
                        $redirectArgs['cp_msg'] = 'generate_failed';
                    }
                }
                break;

            case 'rollback_round':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'rollback_error';
                } else {
                    $result = self::rollback_current_round($tid);
                    $redirectArgs['cp_msg'] = $result ? 'rollback_success' : 'rollback_failed';
                    if ($result) {
                        self::bump_cache_version($tid);
                    }
                }
                break;

            case 'swap_round_colors':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'swap_colors_error';
                } else {
                    $result = self::swap_round_colors($tid);
                    $redirectArgs['cp_msg'] = $result ? 'swap_colors_success' : 'swap_colors_failed';
                    if ($result) {
                        self::bump_cache_version($tid);
                    }
                }
                break;

            case 'save_fixed_boards':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'fixed_boards_error';
                } elseif (isset($_POST['remove_fixed_board'])) {
                    $pid = (int) $_POST['remove_fixed_board'];
                    self::remove_fixed_board($tid, $pid);
                    $redirectArgs['cp_msg'] = 'fixed_boards_removed';
                } elseif (!empty($_POST['add_fixed_board']) && !empty($_POST['fixed_board_player_id'])) {
                    $pid = (int) $_POST['fixed_board_player_id'];
                    $board = max(1, (int) ($_POST['fixed_board_number'] ?? 1));
                    $fromRound = max(1, (int) ($_POST['fixed_board_from_round'] ?? 1));
                    $tournament = self::get_tournament($tid);
                    $fromRound = $tournament ? min($fromRound, (int) $tournament->rounds_total) : $fromRound;
                    self::save_fixed_board($tid, $pid, $board, $fromRound);
                    $redirectArgs['cp_msg'] = 'fixed_boards_updated';
                }
                break;

            case 'save_custom_brochure':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'brochure_error';
                } else {
                    $url = isset($_POST['custom_brochure_url']) ? esc_url_raw(trim(wp_unslash($_POST['custom_brochure_url']))) : '';
                    if (!empty($_FILES['custom_brochure_file']['tmp_name']) && is_uploaded_file($_FILES['custom_brochure_file']['tmp_name'])) {
                        $ext = strtolower(pathinfo(sanitize_file_name($_FILES['custom_brochure_file']['name'] ?? ''), PATHINFO_EXTENSION));
                        if ($ext === 'pdf') {
                            if (!function_exists('wp_handle_upload')) {
                                require_once ABSPATH . 'wp-admin/includes/file.php';
                            }
                            $upload = wp_handle_upload($_FILES['custom_brochure_file'], ['test_form' => false]);
                            if (!isset($upload['error']) && !empty($upload['url'])) {
                                $url = $upload['url'];
                            }
                        }
                    }
                    ChessPodium_PdfBrochure::set_meta($tid, 'custom_brochure_pdf_url', $url);
                    $redirectArgs['cp_msg'] = 'brochure_saved';
                    $redirectArgs['cp_tab'] = 'content';
                }
                break;

            case 'save_brochure':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'brochure_error';
                } else {
                    $data = [
                        'venue_name' => isset($_POST['brochure_venue_name']) ? sanitize_text_field(wp_unslash($_POST['brochure_venue_name'])) : '',
                        'venue_address' => isset($_POST['brochure_venue_address']) ? sanitize_textarea_field(wp_unslash($_POST['brochure_venue_address'])) : '',
                        'venue_map_url' => isset($_POST['brochure_venue_map_url']) ? esc_url_raw(wp_unslash($_POST['brochure_venue_map_url'])) : '',
                        'start_date' => isset($_POST['brochure_start_date']) ? sanitize_text_field(wp_unslash($_POST['brochure_start_date'])) : '',
                        'tournament_rules' => isset($_POST['brochure_tournament_rules']) ? sanitize_textarea_field(wp_unslash($_POST['brochure_tournament_rules'])) : '',
                        'tournament_description' => isset($_POST['brochure_tournament_description']) ? sanitize_textarea_field(wp_unslash($_POST['brochure_tournament_description'])) : '',
                        'prize_table' => [],
                        'contacts' => [],
                    ];
                    $tournament = self::get_tournament($tid);
                    $roundsTotal = $tournament ? (int) $tournament->rounds_total : 0;
                    if ($roundsTotal > 0 && !empty($_POST['brochure_round_date']) && is_array($_POST['brochure_round_date'])) {
                        $roundDates = [];
                        for ($r = 1; $r <= $roundsTotal; $r++) {
                            $d = isset($_POST['brochure_round_date'][$r]) ? sanitize_text_field(wp_unslash($_POST['brochure_round_date'][$r])) : '';
                            $roundDates[$r] = $d;
                        }
                        $data['round_dates'] = $roundDates;
                    }
                    if ($roundsTotal > 0 && !empty($_POST['brochure_round_time']) && is_array($_POST['brochure_round_time'])) {
                        $roundTimes = [];
                        for ($r = 1; $r <= $roundsTotal; $r++) {
                            $t = isset($_POST['brochure_round_time'][$r]) ? sanitize_text_field(wp_unslash($_POST['brochure_round_time'][$r])) : '';
                            $roundTimes[$r] = $t;
                        }
                        $data['round_times'] = $roundTimes;
                    }
                    $data['time_control'] = isset($_POST['brochure_time_control']) ? sanitize_text_field(wp_unslash($_POST['brochure_time_control'])) : '';
                    $data['time_control_type'] = isset($_POST['brochure_time_control_type']) && in_array($_POST['brochure_time_control_type'], ['delay', 'increment'], true) ? sanitize_text_field(wp_unslash($_POST['brochure_time_control_type'])) : '';
                    $data['uscf_td_code'] = isset($_POST['brochure_uscf_td_code']) ? sanitize_text_field(wp_unslash($_POST['brochure_uscf_td_code'])) : '';
                    $data['uscf_affiliate'] = isset($_POST['brochure_uscf_affiliate']) ? sanitize_text_field(wp_unslash($_POST['brochure_uscf_affiliate'])) : '';
                    $data['uscf_city'] = isset($_POST['brochure_uscf_city']) ? sanitize_text_field(wp_unslash($_POST['brochure_uscf_city'])) : '';
                    $data['uscf_state'] = isset($_POST['brochure_uscf_state']) ? sanitize_text_field(wp_unslash($_POST['brochure_uscf_state'])) : '';
                    $data['uscf_zip'] = isset($_POST['brochure_uscf_zip']) ? sanitize_text_field(wp_unslash($_POST['brochure_uscf_zip'])) : '';
                    $data['end_date'] = isset($_POST['brochure_end_date']) ? sanitize_text_field(wp_unslash($_POST['brochure_end_date'])) : '';
                    $data['fee_titled'] = isset($_POST['brochure_fee_titled']) ? sanitize_text_field(wp_unslash($_POST['brochure_fee_titled'])) : '';
                    $data['fee_normal'] = isset($_POST['brochure_fee_normal']) ? sanitize_text_field(wp_unslash($_POST['brochure_fee_normal'])) : '';
                    $data['fee_under18'] = isset($_POST['brochure_fee_under18']) ? sanitize_text_field(wp_unslash($_POST['brochure_fee_under18'])) : '';
                    $data['brochure_logo'] = isset($_POST['brochure_logo_url']) ? esc_url_raw(wp_unslash($_POST['brochure_logo_url'])) : '';
                    if (!empty($_FILES['brochure_logo_file']['tmp_name']) && is_uploaded_file($_FILES['brochure_logo_file']['tmp_name'])) {
                        $logoFile = [
                            'name' => sanitize_file_name($_FILES['brochure_logo_file']['name'] ?? ''),
                            'type' => (wp_check_filetype($_FILES['brochure_logo_file']['tmp_name'], null)['type']) ?: '',
                            'tmp_name' => $_FILES['brochure_logo_file']['tmp_name'],
                            'error' => (int) ($_FILES['brochure_logo_file']['error'] ?? 0),
                            'size' => (int) ($_FILES['brochure_logo_file']['size'] ?? 0),
                        ];
                        $attachId = self::handle_photo_upload($tid, $logoFile);
                        if ($attachId > 0) {
                            $data['brochure_logo'] = wp_get_attachment_url($attachId);
                        }
                    }
                    $venuePhotos = [];
                    if (!empty($_POST['brochure_venue_photos_urls']) && is_array($_POST['brochure_venue_photos_urls'])) {
                        foreach ($_POST['brochure_venue_photos_urls'] as $url) {
                            $u = esc_url_raw(wp_unslash($url));
                            if ($u !== '') {
                                $venuePhotos[] = $u;
                            }
                        }
                    }
                    if (!empty($_FILES['brochure_venue_photos']['tmp_name']) && is_array($_FILES['brochure_venue_photos']['tmp_name'])) {
                        foreach ($_FILES['brochure_venue_photos']['tmp_name'] as $i => $tmpName) {
                            if (empty($tmpName) || !is_uploaded_file($tmpName)) {
                                continue;
                            }
                            $name = sanitize_file_name($_FILES['brochure_venue_photos']['name'][$i] ?? '');
                            $checked = wp_check_filetype($name, null);
                            $file = [
                                'name' => $name,
                                'type' => $checked['type'] ?: '',
                                'tmp_name' => $tmpName,
                                'error' => (int) ($_FILES['brochure_venue_photos']['error'][$i] ?? 0),
                                'size' => (int) ($_FILES['brochure_venue_photos']['size'][$i] ?? 0),
                            ];
                            $attachId = self::handle_photo_upload($tid, $file);
                            if ($attachId > 0) {
                                $venuePhotos[] = wp_get_attachment_url($attachId);
                            }
                        }
                    }
                    $data['brochure_venue_photos'] = $venuePhotos;
                    if (!empty($_POST['brochure_prize']) && is_array($_POST['brochure_prize'])) {
                        foreach ($_POST['brochure_prize'] as $row) {
                            if (!empty($row['position']) || !empty($row['prize']) || !empty($row['category'])) {
                                $data['prize_table'][] = [
                                    'position' => sanitize_text_field($row['position'] ?? ''),
                                    'prize' => sanitize_text_field($row['prize'] ?? ''),
                                    'category' => sanitize_key($row['category'] ?? 'general'),
                                    'age_range' => sanitize_text_field($row['age_range'] ?? ''),
                                    'rating_range' => sanitize_text_field($row['rating_range'] ?? ''),
                                ];
                            }
                        }
                    }
                    $data['chief_arbiter'] = isset($_POST['brochure_chief_arbiter']) ? sanitize_text_field(wp_unslash($_POST['brochure_chief_arbiter'])) : '';
                    $deputyArbiters = [];
                    if (!empty($_POST['brochure_deputy_arbiter']) && is_array($_POST['brochure_deputy_arbiter'])) {
                        foreach ($_POST['brochure_deputy_arbiter'] as $name) {
                            $n = sanitize_text_field(wp_unslash($name));
                            if ($n !== '') {
                                $deputyArbiters[] = $n;
                            }
                        }
                    }
                    $data['deputy_arbiters'] = $deputyArbiters;
                    $data['tournament_director'] = isset($_POST['brochure_tournament_director']) ? sanitize_text_field(wp_unslash($_POST['brochure_tournament_director'])) : '';
                    $data['fide_approved'] = !empty($_POST['brochure_fide_approved']) ? '1' : '0';
                    $data['fide_game_type'] = isset($_POST['brochure_fide_game_type']) && in_array($_POST['brochure_fide_game_type'], ['standard', 'rapid', 'blitz'], true) ? sanitize_key(wp_unslash($_POST['brochure_fide_game_type'])) : '';
                    $data['wildcards_count'] = isset($_POST['brochure_wildcards_count']) ? max(0, (int) $_POST['brochure_wildcards_count']) : 0;
                    if (!empty($_POST['brochure_contacts']) && is_array($_POST['brochure_contacts'])) {
                        foreach ($_POST['brochure_contacts'] as $row) {
                            if (!empty($row['name']) || !empty($row['email']) || !empty($row['phone'])) {
                                $data['contacts'][] = [
                                    'name' => sanitize_text_field($row['name'] ?? ''),
                                    'email' => sanitize_email($row['email'] ?? ''),
                                    'phone' => sanitize_text_field($row['phone'] ?? ''),
                                ];
                            }
                        }
                    }
                    ChessPodium_PdfBrochure::save_brochure($tid, $data);
                    $deputyFirst = !empty($deputyArbiters) ? $deputyArbiters[0] : '';
                    self::update_tournament_officials($tid, $data['chief_arbiter'] ?? '', $deputyFirst);
                    if (class_exists('ChessPodium_Events_Calendar')) {
                        ChessPodium_Events_Calendar::apply_brochure_save_to_tournament($tid, $data, wp_unslash($_POST));
                    }
                    $redirectArgs['cp_msg'] = 'brochure_saved';
                    $redirectArgs['cp_tab'] = 'content';
                }
                break;

            case 'save_tournament_event_schedule':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } elseif (class_exists('ChessPodium_Events_Calendar')) {
                    $st = isset($_POST['event_start_date']) ? sanitize_text_field(wp_unslash($_POST['event_start_date'])) : '';
                    $en = isset($_POST['event_end_date']) ? sanitize_text_field(wp_unslash($_POST['event_end_date'])) : '';
                    $vis = !empty($_POST['event_calendar_visible']) ? 1 : 0;
                    ChessPodium_Events_Calendar::update_tournament_schedule_row($tid, $st, $en, $vis);
                    if (class_exists('ChessPodium_PdfBrochure')) {
                        ChessPodium_PdfBrochure::set_meta($tid, 'start_date', $st);
                        ChessPodium_PdfBrochure::set_meta($tid, 'end_date', $en);
                    }
                    $redirectArgs['cp_msg'] = 'event_schedule_saved';
                    $redirectArgs['cp_tab'] = 'overview';
                } else {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                }
                break;

            case 'add_budget_entry':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'budget_error';
                } else {
                    $date = isset($_POST['budget_date']) ? sanitize_text_field(wp_unslash($_POST['budget_date'])) : '';
                    $desc = isset($_POST['budget_description']) ? sanitize_text_field(wp_unslash($_POST['budget_description'])) : '';
                    $amount = isset($_POST['budget_amount']) ? (float) $_POST['budget_amount'] : 0.0;
                    $type = isset($_POST['budget_type']) && $_POST['budget_type'] === 'income' ? 'income' : 'expense';
                    if ($date !== '' && $desc !== '') {
                        self::add_budget_entry($tid, $date, $desc, $amount, $type);
                        $redirectArgs['cp_msg'] = 'budget_added';
                        $redirectArgs['cp_tab'] = 'budget';
                    }
                }
                break;

            case 'delete_budget_entry':
                $entryId = isset($_POST['budget_entry_id']) ? (int) $_POST['budget_entry_id'] : 0;
                if ($tid > 0 && $entryId > 0) {
                    self::delete_budget_entry($tid, $entryId);
                    $redirectArgs['cp_msg'] = 'budget_deleted';
                    $redirectArgs['cp_tab'] = 'budget';
                }
                break;

            case 'add_partner_hotel':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'hotel_error';
                } else {
                    $name = isset($_POST['hotel_name']) ? trim(sanitize_text_field(wp_unslash($_POST['hotel_name']))) : '';
                    $city = isset($_POST['hotel_city']) ? trim(sanitize_text_field(wp_unslash($_POST['hotel_city']))) : '';
                    $stars = isset($_POST['hotel_stars']) ? max(0, min(5, (int) $_POST['hotel_stars'])) : 0;
                    $rate = isset($_POST['hotel_rate_per_presence_per_day']) ? (float) $_POST['hotel_rate_per_presence_per_day'] : 0.0;
                    $rate = max(0.0, min(9999.0, $rate));
                    $notes = isset($_POST['hotel_notes']) ? trim(sanitize_text_field(wp_unslash($_POST['hotel_notes']))) : '';
                    if ($name !== '') {
                        self::add_partner_hotel($tid, $name, $city !== '' ? $city : null, $stars > 0 ? $stars : null, $rate, $notes !== '' ? $notes : null);
                        $redirectArgs['cp_msg'] = 'hotel_added';
                        $redirectArgs['cp_tab'] = 'budget';
                    } else {
                        $redirectArgs['cp_msg'] = 'hotel_error';
                        $redirectArgs['cp_tab'] = 'budget';
                    }
                }
                break;

            case 'delete_partner_hotel':
                $hotelId = isset($_POST['hotel_id']) ? (int) $_POST['hotel_id'] : 0;
                if ($tid > 0 && $hotelId > 0) {
                    self::delete_partner_hotel($tid, $hotelId);
                    $redirectArgs['cp_msg'] = 'hotel_deleted';
                    $redirectArgs['cp_tab'] = 'budget';
                }
                break;

            case 'save_player_hotel_stays':
                $stays = isset($_POST['hotel_stays']) && is_array($_POST['hotel_stays']) ? $_POST['hotel_stays'] : [];
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'hotel_stays_error';
                } else {
                    $saved = self::save_bulk_player_hotel_stays($tid, $stays);
                    $redirectArgs['cp_msg'] = $saved ? 'hotel_stays_saved' : 'hotel_stays_error';
                    $redirectArgs['cp_tab'] = 'budget';
                }
                break;

            case 'save_player_statuses':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'status_save_error';
                } else {
                    $roundNo = isset($_POST['status_round']) ? max(1, (int) $_POST['status_round']) : 0;
                    $statuses = isset($_POST['player_status']) && is_array($_POST['player_status']) ? $_POST['player_status'] : [];
                    if ($roundNo > 0 && !empty($statuses)) {
                        $t = self::get_tournament($tid);
                        $roundsTotal = $t ? (int) ($t->rounds_total ?? 9) : 0;
                        ChessPodium_PlayerStatus::save_bulk_statuses($tid, $roundNo, $statuses, $roundsTotal);
                        $redirectArgs['cp_msg'] = 'status_save_success';
                    }
                }
                break;

            case 'publish_tournament':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'publish_error';
                } else {
                    $result = self::publish_tournament_to_folder($tid);
                    $redirectArgs['cp_msg'] = $result['success'] ? 'publish_success' : 'publish_error';
                    if (!empty($result['url'])) {
                        $redirectArgs['cp_publish_url'] = $result['url'];
                    }
                }
                break;

            case 'add_photo':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'photo_error';
                } elseif (!empty($_POST['photo_url'])) {
                    $url = esc_url_raw(wp_unslash($_POST['photo_url']));
                    $caption = isset($_POST['photo_caption']) ? sanitize_text_field(wp_unslash($_POST['photo_caption'])) : '';
                    if ($url !== '') {
                        self::insert_photo($tid, $url, $caption);
                        $redirectArgs['cp_msg'] = 'photo_added';
                    }
                } elseif (!empty($_FILES['photo_file']['tmp_name']) && is_uploaded_file($_FILES['photo_file']['tmp_name'])) {
                    $photoFile = [
                        'name' => sanitize_file_name($_FILES['photo_file']['name'] ?? ''),
                        'type' => (wp_check_filetype(sanitize_file_name($_FILES['photo_file']['name'] ?? ''), null)['type']) ?: '',
                        'tmp_name' => $_FILES['photo_file']['tmp_name'],
                        'error' => (int) ($_FILES['photo_file']['error'] ?? 0),
                        'size' => (int) ($_FILES['photo_file']['size'] ?? 0),
                    ];
                    $attachId = self::handle_photo_upload($tid, $photoFile);
                    if ($attachId > 0) {
                        $caption = isset($_POST['photo_caption']) ? sanitize_text_field(wp_unslash($_POST['photo_caption'])) : '';
                        $url = wp_get_attachment_url($attachId);
                        self::insert_photo($tid, $url, $caption);
                        $redirectArgs['cp_msg'] = 'photo_added';
                    } else {
                        $redirectArgs['cp_msg'] = 'photo_upload_error';
                    }
                } elseif (!empty($_FILES['photo_files']['tmp_name']) && is_array($_FILES['photo_files']['tmp_name'])) {
                    $caption = isset($_POST['photo_caption']) ? sanitize_text_field(wp_unslash($_POST['photo_caption'])) : '';
                    $count = self::handle_bulk_photo_upload($tid, $_FILES['photo_files'], $caption);
                    $redirectArgs['cp_msg'] = $count > 0 ? 'photo_added' : 'photo_upload_error';
                    if ($count > 0) {
                        $redirectArgs['cp_imported'] = $count;
                    }
                }
                break;

            case 'delete_photo':
                $photoId = isset($_POST['photo_id']) ? (int) $_POST['photo_id'] : 0;
                if ($tid > 0 && $photoId > 0) {
                    self::delete_photo($tid, $photoId);
                    $redirectArgs['cp_msg'] = 'photo_deleted';
                }
                break;

            case 'add_pgn':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'pgn_error';
                } elseif (!empty($_POST['pgn_content'])) {
                    $content = wp_unslash($_POST['pgn_content']);
                    $round = isset($_POST['pgn_round']) ? max(1, (int) $_POST['pgn_round']) : 1;
                    $white = isset($_POST['pgn_white']) ? sanitize_text_field(wp_unslash($_POST['pgn_white'])) : null;
                    $black = isset($_POST['pgn_black']) ? sanitize_text_field(wp_unslash($_POST['pgn_black'])) : null;
                    $result = isset($_POST['pgn_result']) ? sanitize_text_field(wp_unslash($_POST['pgn_result'])) : null;
                    if (trim($content) !== '') {
                        self::insert_pgn($tid, $round, $content, $white, $black, $result);
                        $redirectArgs['cp_msg'] = 'pgn_added';
                    }
                } elseif (!empty($_FILES['pgn_file']['tmp_name']) && is_uploaded_file($_FILES['pgn_file']['tmp_name'])) {
                    $round = isset($_POST['pgn_round']) ? max(1, (int) $_POST['pgn_round']) : 1;
                    $imported = self::import_pgn_file($tid, $_FILES['pgn_file']['tmp_name'], $round);
                    $redirectArgs['cp_msg'] = $imported > 0 ? 'pgn_added' : 'pgn_error';
                    $redirectArgs['cp_imported'] = $imported;
                } elseif (!empty($_FILES['pgn_files']['tmp_name']) && is_array($_FILES['pgn_files']['tmp_name'])) {
                    $round = isset($_POST['pgn_round']) ? max(1, (int) $_POST['pgn_round']) : 1;
                    $imported = self::handle_bulk_pgn_upload($tid, $_FILES['pgn_files'], $round);
                    $redirectArgs['cp_msg'] = $imported > 0 ? 'pgn_added' : 'pgn_error';
                    $redirectArgs['cp_imported'] = $imported;
                }
                break;

            case 'delete_pgn':
                $pgnId = isset($_POST['pgn_id']) ? (int) $_POST['pgn_id'] : 0;
                if ($tid > 0 && $pgnId > 0) {
                    self::delete_pgn($tid, $pgnId);
                    $redirectArgs['cp_msg'] = 'pgn_deleted';
                }
                break;

            case 'update_pgn_round':
                $pgnId = isset($_POST['pgn_id']) ? (int) $_POST['pgn_id'] : 0;
                $round = isset($_POST['pgn_round']) ? max(1, (int) $_POST['pgn_round']) : 1;
                if ($tid > 0 && $pgnId > 0) {
                    self::update_pgn_round($tid, $pgnId, $round);
                    $redirectArgs['cp_msg'] = 'pgn_round_updated';
                }
                break;

            case 'bulk_update_pgn_rounds':
                if ($tid > 0 && !empty($_POST['pgn_rounds']) && is_array($_POST['pgn_rounds'])) {
                    $updated = self::bulk_update_pgn_rounds($tid, $_POST['pgn_rounds']);
                    $redirectArgs['cp_msg'] = 'pgn_rounds_updated';
                    $redirectArgs['cp_imported'] = $updated;
                }
                break;

            case 'delete_all_pgns':
                if ($tid > 0) {
                    $deleted = self::delete_all_pgns($tid);
                    $redirectArgs['cp_msg'] = 'pgn_all_deleted';
                    $redirectArgs['cp_imported'] = $deleted;
                }
                break;

            case 'add_pgn_source':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'pgn_source_error';
                } elseif (!empty($_POST['pgn_source_url'])) {
                    $url = esc_url_raw(wp_unslash($_POST['pgn_source_url']));
                    $round = isset($_POST['pgn_source_round']) ? max(1, (int) $_POST['pgn_source_round']) : 1;
                    if ($url !== '') {
                        ChessPodium_PgnLive::add_source($tid, $url, $round, 5);
                        $redirectArgs['cp_msg'] = 'pgn_source_added';
                    }
                }
                $redirectArgs['cp_tab'] = 'overview';
                if (!empty($_POST['edit_round'])) {
                    $redirectArgs['edit_round'] = max(1, (int) $_POST['edit_round']);
                }
                break;

            case 'delete_pgn_source':
                $srcId = isset($_POST['pgn_source_id']) ? (int) $_POST['pgn_source_id'] : 0;
                if ($srcId > 0) {
                    ChessPodium_PgnLive::delete_source($srcId);
                    $redirectArgs['cp_msg'] = 'pgn_source_deleted';
                }
                $redirectArgs['cp_tab'] = 'overview';
                if (!empty($_POST['edit_round'])) {
                    $redirectArgs['edit_round'] = max(1, (int) $_POST['edit_round']);
                }
                break;

            case 'fetch_pgn_sources_now':
                if ($tid > 0) {
                    delete_transient('chess_podium_pgn_fetch_' . $tid);
                    $count = ChessPodium_PgnLive::fetch_sources_for_tournament($tid);
                    $redirectArgs['cp_msg'] = $count > 0 ? 'pgn_fetched' : 'pgn_fetch_done';
                }
                $redirectArgs['cp_tab'] = 'overview';
                if (!empty($_POST['edit_round'])) {
                    $redirectArgs['edit_round'] = max(1, (int) $_POST['edit_round']);
                }
                break;

            case 'delete_tournament':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'delete_tournament_error';
                } else {
                    $deleted = self::delete_tournament($tid);
                    $redirectArgs['cp_msg'] = $deleted ? 'delete_tournament_success' : 'delete_tournament_error';
                    if ($deleted) {
                        $redirectToTournament = false;
                    }
                }
                break;

            case 'update_tournament_name':
                $newName = isset($_POST['tournament_name']) ? trim(sanitize_text_field(wp_unslash($_POST['tournament_name']))) : '';
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_name_error';
                } elseif ($newName === '') {
                    $redirectArgs['cp_msg'] = 'update_name_empty';
                } else {
                    $updated = self::update_tournament_name($tid, $newName);
                    $redirectArgs['cp_msg'] = $updated ? 'update_name_success' : 'update_name_error';
                }
                break;

            case 'update_tournament_rating_settings':
                $ratingSystem = isset($_POST['rating_system']) ? sanitize_text_field(wp_unslash($_POST['rating_system'])) : '';
                $countryCode = isset($_POST['country_code']) ? self::sanitize_country_code(sanitize_text_field(wp_unslash($_POST['country_code']))) : null;
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_rating_settings($tid, $ratingSystem, $countryCode);
                    $redirectArgs['cp_msg'] = $updated ? 'update_rating_settings_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_fair_play':
                $fpPublic = !empty($_POST['fair_play_public']);
                $fpSlugs = [];
                if (!empty($_POST['fair_play_measures']) && is_array($_POST['fair_play_measures'])) {
                    $fpSlugs = array_map('sanitize_key', wp_unslash($_POST['fair_play_measures']));
                }
                $fpOfficer = isset($_POST['fair_play_officer']) ? sanitize_text_field(wp_unslash($_POST['fair_play_officer'])) : '';
                $fpComplaint = isset($_POST['fair_play_complaint_url']) ? trim(wp_unslash($_POST['fair_play_complaint_url'])) : '';
                $fpComplaint = $fpComplaint !== '' ? esc_url_raw($fpComplaint) : '';
                if ($fpComplaint !== '' && !preg_match('#^https?://#i', $fpComplaint)) {
                    $fpComplaint = '';
                }
                $fpAdditional = isset($_POST['fair_play_additional']) ? wp_kses_post(wp_unslash($_POST['fair_play_additional'])) : '';
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_fair_play($tid, $fpPublic, $fpSlugs, $fpOfficer, $fpComplaint, $fpAdditional);
                    $redirectArgs['cp_msg'] = $updated ? 'update_fair_play_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_tiebreakers':
                $tiebreakers = isset($_POST['tiebreakers']) ? sanitize_text_field(wp_unslash($_POST['tiebreakers'])) : '';
                $tiebreakersList = array_map('trim', explode(',', strtolower($tiebreakers)));
                $hasAro = in_array('aro', $tiebreakersList, true);
                $aroCutStrongest = $hasAro && isset($_POST['aro_cut_strongest']) ? max(0, min(2, (int) $_POST['aro_cut_strongest'])) : 0;
                $aroCutWeakest = $hasAro && isset($_POST['aro_cut_weakest']) ? max(0, min(2, (int) $_POST['aro_cut_weakest'])) : 0;
                $aroMinRating = $hasAro && isset($_POST['aro_min_rating']) ? max(0, min(3000, (int) $_POST['aro_min_rating'])) : 1400;
                $buchholzCutStrongest = isset($_POST['buchholz_cut_strongest']) ? max(0, min(2, (int) $_POST['buchholz_cut_strongest'])) : 0;
                $buchholzCutWeakest = isset($_POST['buchholz_cut_weakest']) ? max(0, min(2, (int) $_POST['buchholz_cut_weakest'])) : 0;
                $sbCutStrongest = isset($_POST['sb_cut_strongest']) ? max(0, min(2, (int) $_POST['sb_cut_strongest'])) : 0;
                $sbCutWeakest = isset($_POST['sb_cut_weakest']) ? max(0, min(2, (int) $_POST['sb_cut_weakest'])) : 0;
                $pointsSystem = isset($_POST['points_system']) ? sanitize_key(wp_unslash($_POST['points_system'])) : 'standard';
                if (!in_array($pointsSystem, ['standard', '310'], true)) {
                    $pointsSystem = 'standard';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_tiebreakers_error';
                } else {
                    $updated = self::update_tournament_tiebreakers($tid, $tiebreakers, $hasAro ? $aroCutStrongest : null, $hasAro ? $aroCutWeakest : null, $hasAro ? $aroMinRating : null, $buchholzCutStrongest, $buchholzCutWeakest, $sbCutStrongest, $sbCutWeakest, $pointsSystem);
                    $redirectArgs['cp_msg'] = $updated ? 'update_tiebreakers_success' : 'update_tiebreakers_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_rounds_total':
                $roundsTotal = isset($_POST['rounds_total']) ? (int) $_POST['rounds_total'] : 0;
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_rounds_error';
                } else {
                    $updated = self::update_tournament_rounds_total($tid, $roundsTotal);
                    $redirectArgs['cp_msg'] = $updated ? 'update_rounds_success' : 'update_rounds_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_type':
                $newType = isset($_POST['tournament_type']) ? sanitize_key(wp_unslash($_POST['tournament_type'])) : 'swiss';
                $newType = in_array($newType, ['swiss', 'round_robin', 'double_round_robin'], true) ? $newType : 'swiss';
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_type_error';
                } else {
                    $updated = self::update_tournament_type($tid, $newType);
                    $redirectArgs['cp_msg'] = $updated ? 'update_type_success' : 'update_type_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_pairing_engine':
                $engine = sanitize_key(wp_unslash($_POST['pairing_engine'] ?? ''));
                if (!in_array($engine, ['bbpPairings', 'javafo'], true)) {
                    $engine = '';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_pairing_engine($tid, $engine);
                    $redirectArgs['cp_msg'] = $updated ? 'update_pairing_engine_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_round2_pairing_tiebreak':
                $tiebreak = sanitize_key(wp_unslash($_POST['round2_pairing_tiebreak'] ?? ''));
                if (!in_array($tiebreak, ['nr', 'rating'], true)) {
                    $tiebreak = 'nr';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_round2_pairing_tiebreak($tid, $tiebreak);
                    $redirectArgs['cp_msg'] = $updated ? 'update_round2_tiebreak_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_round1_pairing_mode':
                $mode = sanitize_key(wp_unslash($_POST['round1_pairing_mode'] ?? ''));
                if (!in_array($mode, ['dutch', 'split'], true)) {
                    $mode = 'dutch';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_round1_pairing_mode($tid, $mode);
                    $redirectArgs['cp_msg'] = $updated ? 'update_round1_pairing_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_team_tournament_settings':
                $isTeam = !empty($_POST['is_team_tournament']) ? 1 : 0;
                $teamBoards = isset($_POST['team_boards']) ? max(1, min(20, (int) $_POST['team_boards'])) : 4;
                $teamScoring = sanitize_key(wp_unslash($_POST['team_scoring'] ?? 'mp'));
                if (!in_array($teamScoring, ['mp', 'bp'], true)) {
                    $teamScoring = 'mp';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_team_tournament_settings($tid, $isTeam === 1, $teamBoards, $teamScoring);
                    $redirectArgs['cp_msg'] = $updated ? 'update_team_settings_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'update_tournament_pairing_acceleration':
                $accel = sanitize_key(wp_unslash($_POST['pairing_acceleration'] ?? ''));
                if (!in_array($accel, ['none', 'baku', 'fide_accel'], true)) {
                    $accel = 'none';
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'update_settings_error';
                } else {
                    $updated = self::update_tournament_pairing_acceleration($tid, $accel);
                    $redirectArgs['cp_msg'] = $updated ? 'update_pairing_acceleration_success' : 'update_settings_error';
                }
                $redirectArgs['cp_tab'] = 'settings';
                break;

            case 'bulk_import_csv':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'add_player_error_tournament';
                } elseif (empty($_FILES['csv_file']['tmp_name']) || !is_uploaded_file($_FILES['csv_file']['tmp_name'])) {
                    $redirectArgs['cp_msg'] = 'bulk_import_no_file';
                } else {
                    $result = self::bulk_import_players_file($tid, $_FILES['csv_file']['tmp_name'], sanitize_file_name($_FILES['csv_file']['name'] ?? ''));
                    $redirectArgs['cp_msg'] = $result['success'] ? 'bulk_import_success' : 'bulk_import_error';
                    if (isset($result['count'])) {
                        $redirectArgs['cp_imported'] = $result['count'];
                    }
                }
                break;

            case 'simulate_live_test':
                $redirectArgs['cp_tab'] = 'overview';
                $redirectArgs['edit_round'] = isset($_POST['edit_round']) ? (int) $_POST['edit_round'] : 0;
                if ($tid > 0 && $redirectArgs['edit_round'] > 0) {
                    $pairings = self::get_pairings_for_round($tid, $redirectArgs['edit_round']);
                    $firstNonBye = null;
                    foreach ($pairings as $p) {
                        if ((int) $p->is_bye === 0) {
                            $firstNonBye = $p;
                            break;
                        }
                    }
                    if ($firstNonBye) {
                        global $wpdb;
                        $samplePgn = "[Event \"Test\"]\n[White \"White\"]\n[Black \"Black\"]\n\n1. e4 e5 2. Nf3 Nc6 3. Bb5";
                        $sampleFen = 'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3';
                        $wpdb->update(
                            self::db_table('regina_pairings'),
                            ['is_live' => 1, 'live_pgn' => $samplePgn, 'live_fen' => $sampleFen],
                            ['id' => (int) $firstNonBye->id],
                            ['%d', '%s', '%s'],
                            ['%d']
                        );
                        set_transient('chess_podium_live_test_mode', 1, HOUR_IN_SECONDS);
                        $redirectArgs['cp_msg'] = 'simulate_live_ok';
                    }
                }
                break;

            case 'save_results':
                $editRound = isset($_POST['edit_round']) ? (int) $_POST['edit_round'] : 0;
                $live = isset($_POST['live']) && is_array($_POST['live']) ? $_POST['live'] : [];
                $results = isset($_POST['results']) && is_array($_POST['results']) ? $_POST['results'] : [];
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'save_results_error';
                } elseif (empty($results) && empty($live)) {
                    $redirectArgs['cp_msg'] = 'save_results_no_data';
                } else {
                    $saved = self::save_results_for_tournament($tid, $results, $live, $editRound);
                    $redirectArgs['cp_msg'] = $saved ? 'save_results_success' : 'save_results_error';
                }
                if ($editRound > 0) {
                    $redirectArgs['edit_round'] = $editRound;
                }
                $redirectArgs['cp_tab'] = 'overview';
                break;

            case 'import_chess_results':
                $redirectArgs['cp_tab'] = 'overview';
                $editRoundImport = isset($_POST['edit_round']) ? (int) $_POST['edit_round'] : 0;
                if ($editRoundImport > 0) {
                    $redirectArgs['edit_round'] = $editRoundImport;
                }
                if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                    error_log('[Chess Podium] import_chess_results: tid=' . $tid . ' files=' . (isset($_FILES['chess_results_excel']) ? 'yes' : 'no') . ' tmp=' . (isset($_FILES['chess_results_excel']['tmp_name']) ? $_FILES['chess_results_excel']['tmp_name'] : ''));
                }
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'import_cr_error';
                } elseif (empty($_FILES['chess_results_excel']['tmp_name']) || !is_uploaded_file($_FILES['chess_results_excel']['tmp_name'])) {
                    $redirectArgs['cp_msg'] = 'import_cr_no_file';
                } else {
                    $result = self::import_results_from_chess_results_excel($tid, $_FILES['chess_results_excel']['tmp_name'], $editRoundImport);
                    if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                        error_log('[Chess Podium] import_chess_results result: success=' . ($result['success'] ? '1' : '0') . ' error=' . ($result['error'] ?? '') . ' count=' . ($result['count'] ?? 0));
                    }
                    $redirectArgs['cp_msg'] = $result['success'] ? 'import_cr_success' : ($result['error'] ?? 'import_cr_error');
                    if (isset($result['count'])) {
                        $redirectArgs['cp_imported'] = $result['count'];
                    }
                }
                break;

            case 'save_pairings':
                if ($tid <= 0) {
                    $redirectArgs['cp_msg'] = 'save_pairings_error';
                } elseif (!isset($_POST['pairings']) || !is_array($_POST['pairings'])) {
                    $redirectArgs['cp_msg'] = 'save_pairings_no_data';
                } else {
                    $editRound = isset($_POST['edit_round']) ? (int) $_POST['edit_round'] : 0;
                    $addByes = [];
                    if (!empty($_POST['add_bye_players']) && is_array($_POST['add_bye_players'])) {
                        foreach ($_POST['add_bye_players'] as $pid => $checked) {
                            if ((int) $checked !== 1) continue;
                            $pid = (int) $pid;
                            if ($pid <= 0) continue;
                            $pts = isset($_POST['add_bye_points'][$pid]) ? max(0.5, min(1.0, (float) $_POST['add_bye_points'][$pid])) : 0.5;
                            $addByes[$pid] = $pts;
                        }
                    }
                    $saved = self::save_pairings_for_round($tid, $editRound, $_POST['pairings'], $addByes);
                    $redirectArgs['cp_msg'] = $saved ? 'save_pairings_success' : 'save_pairings_error';
                    if ($saved) {
                        self::bump_cache_version($tid);
                    }
                    if ($editRound > 0) {
                        $redirectArgs['edit_round'] = $editRound;
                        $redirectArgs['edit_pairings'] = 1;
                    }
                }
                break;

            case 'add_external_tournament':
                $extName = isset($_POST['ext_name']) ? trim(sanitize_text_field(wp_unslash($_POST['ext_name']))) : '';
                $extUrl = isset($_POST['ext_url']) ? esc_url_raw(trim(wp_unslash($_POST['ext_url']))) : '';
                $extPreview = self::get_external_preview_url_from_post();
                $extRounds = isset($_POST['ext_rounds']) ? max(0, (int) $_POST['ext_rounds']) : 0;
                $extBrochure = self::get_external_brochure_url_from_post();
                if ($extName !== '' && $extUrl !== '') {
                    self::add_external_tournament($extName, $extUrl, $extPreview ?: null, $extRounds, $extBrochure !== '' ? $extBrochure : null);
                    $redirectArgs['cp_msg'] = 'ext_tournament_added';
                } else {
                    $redirectArgs['cp_msg'] = 'ext_tournament_error';
                }
                $redirect = admin_url('admin.php?page=chess-podium-external-tournaments');
                if (!empty($redirectArgs)) {
                    $redirect = add_query_arg($redirectArgs, $redirect);
                }
                wp_safe_redirect($redirect);
                exit;

            case 'update_external_tournament':
                $extId = isset($_POST['ext_id']) ? sanitize_key(wp_unslash($_POST['ext_id'])) : '';
                $extName = isset($_POST['ext_name']) ? trim(sanitize_text_field(wp_unslash($_POST['ext_name']))) : '';
                $extUrl = isset($_POST['ext_url']) ? esc_url_raw(trim(wp_unslash($_POST['ext_url']))) : '';
                $extPreview = self::get_external_preview_url_from_post();
                $extRounds = isset($_POST['ext_rounds']) ? max(0, (int) $_POST['ext_rounds']) : 0;
                $extBrochure = self::get_external_brochure_url_from_post();
                if ($extId !== '' && $extName !== '' && $extUrl !== '') {
                    self::update_external_tournament($extId, $extName, $extUrl, $extPreview ?: null, $extRounds, $extBrochure !== '' ? $extBrochure : null);
                    $redirectArgs['cp_msg'] = 'ext_tournament_updated';
                } else {
                    $redirectArgs['cp_msg'] = 'ext_tournament_error';
                }
                $redirect = admin_url('admin.php?page=chess-podium-external-tournaments');
                if (!empty($redirectArgs)) {
                    $redirect = add_query_arg($redirectArgs, $redirect);
                }
                wp_safe_redirect($redirect);
                exit;

            case 'delete_external_tournament':
                $extId = isset($_POST['ext_id']) ? sanitize_key(wp_unslash($_POST['ext_id'])) : '';
                if ($extId !== '') {
                    self::delete_external_tournament($extId);
                    $redirectArgs['cp_msg'] = 'ext_tournament_deleted';
                }
                $redirect = admin_url('admin.php?page=chess-podium-external-tournaments');
                if (!empty($redirectArgs)) {
                    $redirect = add_query_arg($redirectArgs, $redirect);
                }
                wp_safe_redirect($redirect);
                exit;

            case 'add_sponsor':
                $spName = isset($_POST['sponsor_name']) ? trim(sanitize_text_field(wp_unslash($_POST['sponsor_name']))) : '';
                $spLogo = isset($_POST['sponsor_logo']) ? esc_url_raw(trim(wp_unslash($_POST['sponsor_logo']))) : '';
                $spUrl = isset($_POST['sponsor_url']) ? esc_url_raw(trim(wp_unslash($_POST['sponsor_url']))) : '';
                $spTids = self::parse_sponsor_tournaments_post();
                if ($spName !== '' && $spLogo !== '') {
                    ChessPodium_Sponsors::add($spName, $spLogo, $spUrl, $spTids);
                    $redirectArgs['cp_msg'] = 'sponsor_added';
                } else {
                    $redirectArgs['cp_msg'] = 'sponsor_error';
                }
                wp_safe_redirect(add_query_arg($redirectArgs, admin_url('admin.php?page=chess-podium-sponsors')));
                exit;

            case 'update_sponsor':
                $spId = isset($_POST['sponsor_id']) ? (int) $_POST['sponsor_id'] : 0;
                $spName = isset($_POST['sponsor_name']) ? trim(sanitize_text_field(wp_unslash($_POST['sponsor_name']))) : '';
                $spLogo = isset($_POST['sponsor_logo']) ? esc_url_raw(trim(wp_unslash($_POST['sponsor_logo']))) : '';
                $spUrl = isset($_POST['sponsor_url']) ? esc_url_raw(trim(wp_unslash($_POST['sponsor_url']))) : '';
                $spTids = self::parse_sponsor_tournaments_post();
                if ($spId > 0 && $spName !== '' && $spLogo !== '') {
                    ChessPodium_Sponsors::update($spId, $spName, $spLogo, $spUrl, $spTids);
                    $redirectArgs['cp_msg'] = 'sponsor_updated';
                } else {
                    $redirectArgs['cp_msg'] = 'sponsor_error';
                }
                wp_safe_redirect(add_query_arg($redirectArgs, admin_url('admin.php?page=chess-podium-sponsors')));
                exit;

            case 'delete_sponsor':
                $spId = isset($_POST['sponsor_id']) ? (int) $_POST['sponsor_id'] : 0;
                if ($spId > 0) {
                    ChessPodium_Sponsors::delete($spId);
                    $redirectArgs['cp_msg'] = 'sponsor_deleted';
                }
                wp_safe_redirect(add_query_arg($redirectArgs, admin_url('admin.php?page=chess-podium-sponsors')));
                exit;

            case 'cp_comm_extra_add':
            case 'cp_comm_extra_bulk':
            case 'cp_comm_extra_import':
            case 'cp_comm_extra_remove':
            case 'cp_comm_extra_remove_all':
                $redirectArgs['cp_tab'] = 'communications';
                if ($tid <= 0 || !class_exists('ChessPodium_Tournament_Communications')) {
                    $redirectArgs['cp_msg'] = 'cp_extra_error';
                    set_transient('chess_podium_cp_extra_msg_' . get_current_user_id(), __('Invalid tournament.', 'chess-podium'), 120);
                    break;
                }
                $result = ChessPodium_Tournament_Communications::process_extra_recipients_post($action, $tid, $_POST, $_FILES);
                if (!empty($result['success'])) {
                    $redirectArgs['cp_msg'] = 'cp_extra_success';
                    if (!empty($result['message'])) {
                        set_transient('chess_podium_cp_extra_msg_' . get_current_user_id(), $result['message'], 120);
                    }
                } else {
                    $redirectArgs['cp_msg'] = 'cp_extra_error';
                    if (!empty($result['message'])) {
                        set_transient('chess_podium_cp_extra_msg_' . get_current_user_id(), $result['message'], 120);
                    }
                }
                break;

            case 'cp_queue_tournament_notifications_general':
            case 'cp_queue_tournament_notifications_pairings':
            case 'cp_send_tournament_notification_test_general':
            case 'cp_send_tournament_notification_test_pairings':
                $redirectArgs['cp_tab'] = 'communications';
                if ($tid <= 0 || !class_exists('ChessPodium_Tournament_Communications')) {
                    $redirectArgs['cp_msg'] = 'cp_mail_error';
                    break;
                }
                $mailActionMap = [
                    'cp_queue_tournament_notifications_general' => [false, 'general'],
                    'cp_queue_tournament_notifications_pairings' => [false, 'pairings'],
                    'cp_send_tournament_notification_test_general' => [true, 'general'],
                    'cp_send_tournament_notification_test_pairings' => [true, 'pairings'],
                ];
                [$isTestMail, $mailKind] = $mailActionMap[$action] ?? [false, 'general'];
                $subject = isset($_POST['cp_mail_subject']) ? sanitize_text_field(wp_unslash($_POST['cp_mail_subject'])) : '';
                $body = isset($_POST['cp_mail_body']) ? sanitize_textarea_field(wp_unslash($_POST['cp_mail_body'])) : '';
                if ($isTestMail) {
                    $testTo = isset($_POST['cp_mail_test_to']) ? sanitize_email(wp_unslash((string) $_POST['cp_mail_test_to'])) : '';
                    $result = ChessPodium_Tournament_Communications::send_test_email($tid, $testTo, $subject, $body, $mailKind);
                    if ($result['success']) {
                        $redirectArgs['cp_msg'] = 'cp_mail_test_sent';
                    } else {
                        $redirectArgs['cp_msg'] = 'cp_mail_test_error';
                        if (!empty($result['message'])) {
                            set_transient('chess_podium_cp_mail_err_' . get_current_user_id(), $result['message'], 120);
                        }
                    }
                } else {
                    $t = self::get_tournament($tid);
                    $roundFor = ($mailKind === 'pairings' && $t) ? (int) $t->current_round : 0;
                    $result = ChessPodium_Tournament_Communications::start_queue($tid, $subject, $body, $mailKind, $roundFor);
                    if ($result['success']) {
                        $redirectArgs['cp_msg'] = 'cp_mail_queued';
                        if (!empty($result['queued'])) {
                            $redirectArgs['cp_imported'] = (int) $result['queued'];
                        }
                    } else {
                        $redirectArgs['cp_msg'] = 'cp_mail_error';
                        if (!empty($result['message'])) {
                            set_transient('chess_podium_cp_mail_err_' . get_current_user_id(), $result['message'], 120);
                        }
                    }
                }
                break;

            case 'cp_team_add':
            case 'cp_team_delete':
            case 'cp_team_assign_save':
            case 'cp_team_match_add':
            case 'cp_team_match_save':
            case 'cp_team_generate_round':
                $redirectArgs['cp_tab'] = 'teams';
                if ($tid <= 0 || !class_exists('ChessPodium_TeamTournaments')) {
                    $redirectArgs['cp_msg'] = 'cp_team_error';
                    break;
                }
                if ($action === 'cp_team_add') {
                    $name = isset($_POST['cp_team_name']) ? (string) wp_unslash($_POST['cp_team_name']) : '';
                    $fed = isset($_POST['cp_team_fed']) ? (string) wp_unslash($_POST['cp_team_fed']) : '';
                    $sn = isset($_POST['cp_team_start_number']) ? (int) $_POST['cp_team_start_number'] : 0;
                    $res = ChessPodium_TeamTournaments::add_team($tid, $name, $fed, $sn);
                } elseif ($action === 'cp_team_delete') {
                    $teamId = isset($_POST['team_id']) ? (int) $_POST['team_id'] : 0;
                    $res = ChessPodium_TeamTournaments::delete_team($tid, $teamId);
                } elseif ($action === 'cp_team_match_add') {
                    $roundNo = isset($_POST['cp_team_round']) ? max(1, (int) $_POST['cp_team_round']) : 1;
                    $homeId = isset($_POST['cp_match_home_team']) ? (int) $_POST['cp_match_home_team'] : 0;
                    $awayId = isset($_POST['cp_match_away_team']) ? (int) $_POST['cp_match_away_team'] : 0;
                    $t = self::get_tournament($tid);
                    $boardsPerMatch = $t && isset($t->team_boards) ? (int) $t->team_boards : 4;
                    $res = ChessPodium_TeamTournaments::add_match($tid, $roundNo, $homeId, $awayId, $boardsPerMatch);
                    if ($roundNo > 0) {
                        $redirectArgs['cp_team_round'] = $roundNo;
                    }
                } elseif ($action === 'cp_team_match_save') {
                    $roundNo = isset($_POST['cp_team_round']) ? max(1, (int) $_POST['cp_team_round']) : 1;
                    $matchId = isset($_POST['match_id']) ? (int) $_POST['match_id'] : 0;
                    $rows = isset($_POST['cp_match_boards']) && is_array($_POST['cp_match_boards']) ? $_POST['cp_match_boards'] : [];
                    $res = ChessPodium_TeamTournaments::save_match_boards($tid, $matchId, $rows);
                    if ($roundNo > 0) {
                        $redirectArgs['cp_team_round'] = $roundNo;
                    }
                } elseif ($action === 'cp_team_generate_round') {
                    $roundNo = isset($_POST['cp_team_round']) ? max(1, (int) $_POST['cp_team_round']) : 1;
                    $t = self::get_tournament($tid);
                    $boardsPerMatch = $t && isset($t->team_boards) ? (int) $t->team_boards : 4;
                    $primary = $t && isset($t->team_scoring) ? (string) $t->team_scoring : 'mp';
                    $res = ChessPodium_TeamTournaments::generate_round_pairings($tid, $roundNo, $boardsPerMatch, $primary);
                    if ($roundNo > 0) {
                        $redirectArgs['cp_team_round'] = $roundNo;
                    }
                } else {
                    $rows = isset($_POST['cp_team_assign']) && is_array($_POST['cp_team_assign']) ? $_POST['cp_team_assign'] : [];
                    $res = ChessPodium_TeamTournaments::save_player_assignments($tid, $rows);
                }
                if (!empty($res['success'])) {
                    $redirectArgs['cp_msg'] = 'cp_team_ok';
                    if (!empty($res['message'])) {
                        set_transient('chess_podium_cp_team_msg_' . get_current_user_id(), (string) $res['message'], 120);
                    }
                } else {
                    $redirectArgs['cp_msg'] = 'cp_team_error';
                    if (!empty($res['message'])) {
                        set_transient('chess_podium_cp_team_msg_' . get_current_user_id(), (string) $res['message'], 120);
                    }
                }
                break;
        }

        $redirect = admin_url('admin.php?page=' . self::MENU_SLUG);
        if ($redirectToTournament) {
            $tidToAdd = isset($redirectArgs['tournament_id']) ? (int) $redirectArgs['tournament_id'] : $tid;
            if ($tidToAdd > 0) {
                $redirect = add_query_arg('tournament_id', $tidToAdd, $redirect);
            }
            $validTabs = ['overview', 'players', 'rounds', 'settings', 'content', 'budget', 'publish', 'communications'];
            if (!isset($redirectArgs['cp_tab']) && isset($_POST['cp_tab'])) {
                $tab = sanitize_key(wp_unslash($_POST['cp_tab']));
                if (in_array($tab, $validTabs, true)) {
                    $redirectArgs['cp_tab'] = $tab;
                }
            }
        }
        if (!empty($redirectArgs)) {
            $redirect = add_query_arg($redirectArgs, $redirect);
        }
        wp_safe_redirect($redirect);
        exit;
    }

    /** Parses sponsor_tournaments_mode and sponsor_tournaments[] from POST. Returns [] for "all", or array of tournament IDs. */
    private static function parse_sponsor_tournaments_post(): array
    {
        $mode = isset($_POST['sponsor_tournaments_mode']) ? sanitize_key(wp_unslash($_POST['sponsor_tournaments_mode'])) : 'all';
        if ($mode !== 'selected') {
            return [];
        }
        $raw = isset($_POST['sponsor_tournaments']) && is_array($_POST['sponsor_tournaments']) ? $_POST['sponsor_tournaments'] : [];
        return array_values(array_unique(array_map('intval', array_filter($raw))));
    }

    public static function render_admin_page(): void
    {
        $selectedTournamentId = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
        $printRound = isset($_GET['print_round']) ? (int) $_GET['print_round'] : 0;
        $printStandings = isset($_GET['print_standings']) ? (int) $_GET['print_standings'] : 0;
        $msg = isset($_GET['cp_msg']) ? sanitize_key(wp_unslash($_GET['cp_msg'])) : '';
        $importedCount = isset($_GET['cp_imported']) ? (int) $_GET['cp_imported'] : 0;
        $tournaments = self::get_tournaments();
        $selectedTournament = $selectedTournamentId > 0 ? self::get_tournament($selectedTournamentId) : null;
        ?>
        <div class="wrap cp-admin-wrap">
            <h1><?php echo esc_html__('Chess Podium', 'chess-podium'); ?></h1>
            <p class="cp-subtitle"><?php echo esc_html__('Create tournament, add players, generate rounds and enter results.', 'chess-podium'); ?></p>
            <?php if ($msg !== ''): ?>
                <?php self::render_admin_notice($msg, $importedCount); ?>
            <?php endif; ?>

            <div class="cp-create-tournament">
                <h2 style="margin-top:0;"><?php echo esc_html__('Create new tournament', 'chess-podium'); ?></h2>
                <form method="post">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="create_tournament">
                    <table class="form-table">
                        <tr>
                            <th><label for="name"><?php echo esc_html__('Name', 'chess-podium'); ?></label></th>
                            <td><input id="name" name="name" type="text" required class="regular-text" placeholder="<?php echo esc_attr__('e.g. Campionato 2025', 'chess-podium'); ?>"></td>
                        </tr>
                        <tr>
                            <th><label for="rounds_total"><?php echo esc_html__('Rounds', 'chess-podium'); ?></label></th>
                            <td><input id="rounds_total" name="rounds_total" type="number" min="1" max="99" value="5" style="width:70px;"> <span class="description"><?php echo esc_html__('Swiss, round robin, or double RR (e.g. 14 rounds for 8 players).', 'chess-podium'); ?></span></td>
                        </tr>
                        <tr>
                            <th><label for="tournament_type"><?php echo esc_html__('Type', 'chess-podium'); ?></label></th>
                            <td>
                                <select id="tournament_type" name="tournament_type" style="width:180px;">
                                    <option value="swiss"><?php echo esc_html__('Swiss', 'chess-podium'); ?></option>
                                    <option value="round_robin"><?php echo esc_html__('Round Robin', 'chess-podium'); ?></option>
                                    <option value="double_round_robin"><?php echo esc_html__('Double Round Robin', 'chess-podium'); ?></option>
                                </select>
                                <select id="bye_points" name="bye_points" style="width:80px;">
                                    <option value="0">0</option>
                                    <option value="0.5">0.5</option>
                                    <option value="1" selected>1</option>
                                </select> <?php echo esc_html__('BYE pts', 'chess-podium'); ?>
                            </td>
                        </tr>
                        <tr>
                            <th><label for="tiebreakers"><?php echo esc_html__('Tiebreakers', 'chess-podium'); ?></label></th>
                            <td>
                                <input id="tiebreakers" name="tiebreakers" type="text" value="buchholz,sb,rating" class="regular-text" style="max-width:280px;" placeholder="buchholz,buchholz1,sb,rating,aro,direct,win,bwg,ks,rep">
                                <p class="description"><?php echo esc_html__('Comma-separated order. See legend below.', 'chess-podium'); ?></p>
                                <?php self::render_tiebreaker_legend(); ?>
                            </td>
                        </tr>
                    </table>
                    <p><button class="button button-primary" type="submit"><?php echo esc_html__('Create tournament', 'chess-podium'); ?></button></p>
                </form>

                <h3 style="margin-top:2rem;"><?php echo esc_html__('Import from Excel', 'chess-podium'); ?></h3>
                <p class="description"><?php echo esc_html__('Create a tournament from an Excel file (.xlsx). Set tournament options below, then upload the player list. Format: player list (Nr., Nome, FideID, paese, EloI) or pairing export (White, Black, Elo). Place the file in the chess-podium plugin folder or upload it below.', 'chess-podium'); ?></p>
                <form method="post" enctype="multipart/form-data">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="create_tournament_from_excel">
                    <table class="form-table">
                        <tr>
                            <th><label for="excel_tournament_type"><?php echo esc_html__('Type', 'chess-podium'); ?></label></th>
                            <td>
                                <select id="excel_tournament_type" name="excel_tournament_type" style="width:180px;">
                                    <option value="swiss"><?php echo esc_html__('Swiss', 'chess-podium'); ?></option>
                                    <option value="round_robin"><?php echo esc_html__('Round Robin', 'chess-podium'); ?></option>
                                    <option value="double_round_robin"><?php echo esc_html__('Double Round Robin', 'chess-podium'); ?></option>
                                </select>
                            </td>
                        </tr>
                        <tr>
                            <th><label for="excel_rounds_total"><?php echo esc_html__('Rounds', 'chess-podium'); ?></label></th>
                            <td>
                                <input id="excel_rounds_total" name="excel_rounds_total" type="number" min="1" max="99" value="5" style="width:70px;">
                                <span class="description"><?php echo esc_html__('Swiss: number of rounds. Round Robin: typically N-1 for N players.', 'chess-podium'); ?></span>
                            </td>
                        </tr>
                        <tr>
                            <th><label for="excel_bye_points"><?php echo esc_html__('BYE pts', 'chess-podium'); ?></label></th>
                            <td>
                                <select id="excel_bye_points" name="excel_bye_points" style="width:80px;">
                                    <option value="0">0</option>
                                    <option value="0.5">0.5</option>
                                    <option value="1" selected>1</option>
                                </select>
                                <span class="description"><?php echo esc_html__('Points for bye (Swiss only)', 'chess-podium'); ?></span>
                            </td>
                        </tr>
                        <tr>
                            <th><label for="excel_tiebreakers"><?php echo esc_html__('Tiebreakers', 'chess-podium'); ?></label></th>
                            <td>
                                <input id="excel_tiebreakers" name="excel_tiebreakers" type="text" value="buchholz,sb,rating" class="regular-text" style="max-width:280px;" placeholder="buchholz,buchholz1,sb,rating,aro,direct,win,bwg,ks,rep">
                                <p class="description"><?php echo esc_html__('Comma-separated order. See legend below.', 'chess-podium'); ?></p>
                                <?php self::render_tiebreaker_legend(); ?>
                            </td>
                        </tr>
                        <tr>
                            <th><label for="excel_file"><?php echo esc_html__('Excel file', 'chess-podium'); ?></label></th>
                            <td>
                                <?php
                                $excelFiles = ChessPodium_ExcelImport::list_excel_in_plugin_dir();
                                if (!empty($excelFiles)):
                                ?>
                                    <p>
                                        <label for="excel_file"><?php echo esc_html__('File in plugin folder', 'chess-podium'); ?>:</label>
                                        <select id="excel_file" name="excel_file" style="max-width:400px;">
                                            <option value=""><?php echo esc_html__('— Select —', 'chess-podium'); ?></option>
                                            <?php foreach ($excelFiles as $path): ?>
                                                <option value="<?php echo esc_attr($path); ?>"><?php echo esc_html(basename($path)); ?></option>
                                            <?php endforeach; ?>
                                        </select>
                                    </p>
                                    <p class="description"><?php echo esc_html__('Or upload a file', 'chess-podium'); ?>:</p>
                                <?php endif; ?>
                                <p>
                                    <input type="file" name="excel_upload" accept=".xlsx,.XLSX">
                                </p>
                            </td>
                        </tr>
                    </table>
                    <p><button class="button button-primary" type="submit"><?php echo esc_html__('Create tournament from Excel', 'chess-podium'); ?></button></p>
                </form>
            </div>

            <?php if (!empty($tournaments)): ?>
            <p><strong><?php echo esc_html__('Select tournament', 'chess-podium'); ?>:</strong></p>
            <div class="cp-tournament-list">
                <?php foreach ($tournaments as $t): ?>
                    <a href="<?php echo esc_url(admin_url('admin.php?page=' . self::MENU_SLUG . '&tournament_id=' . (int) $t->id)); ?>" class="<?php echo $selectedTournamentId === (int) $t->id ? 'cp-current' : ''; ?>">
                        <?php echo esc_html($t->name); ?> (<?php echo (int) $t->current_round; ?>/<?php echo (int) $t->rounds_total; ?>)
                    </a>
                <?php endforeach; ?>
            </div>
            <?php endif; ?>

            <?php if ($selectedTournament): ?>
                <?php if ($printRound > 0): ?>
                    <?php self::render_print_pairings_block($selectedTournament, $printRound); ?>
                <?php endif; ?>
                <?php if ($printStandings > 0): ?>
                    <?php self::render_print_standings_block($selectedTournament); ?>
                <?php endif; ?>
                <?php self::render_tournament_admin_block($selectedTournament); ?>
            <?php endif; ?>
        </div>
        <?php
    }

    private static function render_tournament_admin_block(object $tournament): void
    {
        $tid = (int) $tournament->id;
        $players = self::get_players($tid);
        $standings = self::compute_standings($tid);
        $roundPairings = self::get_pairings_for_round($tid, (int) $tournament->current_round);
        $exportTurnsUrl = self::build_export_url($tid, 'turns');
        $exportStandingsUrl = self::build_export_url($tid, 'standings');
        $exportTrfUrl = self::build_export_url($tid, 'trf');
        $exportUscfUrl = self::build_export_url($tid, 'uscf');
        $exportEcfUrl = self::build_export_url($tid, 'ecf');
        $badgesPdfUrl = self::build_export_url($tid, 'badges_pdf');
        $placecardsPdfUrl = self::build_export_url($tid, 'placecards_pdf');
        $boardcardsPdfUrl = self::build_export_url($tid, 'boardcards_pdf');
        $photos = self::get_photos($tid);
        $pgns = self::get_pgns($tid);
        $publishUrl = ChessPodium_ExportEngine::get_export_url_base() . '/' . ChessPodium_ExportEngine::get_tournament_slug($tournament) . '/index.html';
        $activeTab = isset($_GET['cp_tab']) ? sanitize_key($_GET['cp_tab']) : 'overview';
        $validTabs = ['overview', 'players', 'rounds', 'settings', 'content', 'budget', 'publish', 'communications'];
        $isTeam = !empty($tournament->is_team_tournament);
        if ($isTeam) {
            $validTabs[] = 'teams';
        }
        if (!in_array($activeTab, $validTabs, true)) {
            $activeTab = 'overview';
        }
        $baseUrl = admin_url('admin.php?page=' . self::MENU_SLUG . '&tournament_id=' . $tid);
        ?>
        <div class="cp-tournament-header">
            <form method="post" class="cp-inline-form" style="flex:1;min-width:200px;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="update_tournament_name">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <input type="text" name="tournament_name" value="<?php echo esc_attr($tournament->name); ?>" class="regular-text" style="max-width:300px;">
                <button type="submit" class="button"><?php echo esc_html__('Rename', 'chess-podium'); ?></button>
            </form>
            <div class="cp-tournament-meta">
                <span><?php echo esc_html__('Round', 'chess-podium'); ?> <?php echo (int) $tournament->current_round; ?>/<?php echo (int) $tournament->rounds_total; ?></span>
                <span><?php echo esc_html(self::tournament_type_label($tournament)); ?></span>
                <span><?php echo count($players); ?> <?php echo esc_html__('Players', 'chess-podium'); ?></span>
            </div>
            <form method="post" style="display:inline;" onsubmit="return confirm('<?php echo esc_js(__('Permanently delete this tournament?', 'chess-podium')); ?>');">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="delete_tournament">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <button type="submit" class="button button-link-delete"><?php echo esc_html__('Delete', 'chess-podium'); ?></button>
            </form>
        </div>

        <div class="cp-admin-tabs">
            <nav class="cp-tab-nav" role="tablist">
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'overview' ? 'cp-tab-active' : ''; ?>" data-tab="overview"><?php echo esc_html__('Overview', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'players' ? 'cp-tab-active' : ''; ?>" data-tab="players"><?php echo esc_html__('Players', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'rounds' ? 'cp-tab-active' : ''; ?>" data-tab="rounds"><?php echo esc_html__('Rounds', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'settings' ? 'cp-tab-active' : ''; ?>" data-tab="settings"><?php echo esc_html__('Settings', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'content' ? 'cp-tab-active' : ''; ?>" data-tab="content"><?php echo esc_html__('Content', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'budget' ? 'cp-tab-active' : ''; ?>" data-tab="budget"><?php echo esc_html__('Budget', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'publish' ? 'cp-tab-active' : ''; ?>" data-tab="publish"><?php echo esc_html__('Publish', 'chess-podium'); ?></button>
                <button type="button" class="cp-tab-btn <?php echo $activeTab === 'communications' ? 'cp-tab-active' : ''; ?>" data-tab="communications"><?php echo esc_html__('Communications', 'chess-podium'); ?></button>
                <?php if ($isTeam): ?>
                    <button type="button" class="cp-tab-btn <?php echo $activeTab === 'teams' ? 'cp-tab-active' : ''; ?>" data-tab="teams"><?php echo esc_html__('Teams', 'chess-podium'); ?></button>
                <?php endif; ?>
            </nav>
            <div class="cp-tab-panels">

        <div class="cp-tab-panel <?php echo $activeTab === 'overview' ? 'cp-tab-active' : ''; ?>" data-panel="overview">
        <h3><?php echo esc_html__('Overview', 'chess-podium'); ?></h3>
        <?php
        $isLastRound = (int) $tournament->current_round >= (int) $tournament->rounds_total;
        $exportDir = ChessPodium_ExportEngine::get_export_dir($tournament);
        $isPublished = file_exists($exportDir . '/index.html');
        if ($isPublished && $isLastRound) {
            $tournamentLink = $publishUrl;
            $tournamentLinkDesc = __('Published tournament page.', 'chess-podium');
        } else {
            $tournamentLink = self::get_live_url($tid);
            $tournamentLinkDesc = __('Share with players for live standings.', 'chess-podium');
        }
        ?>
        <p class="description"><a href="<?php echo esc_url($tournamentLink); ?>" target="_blank"><?php echo esc_html($tournamentLink); ?></a> &ndash; <?php echo esc_html($tournamentLinkDesc); ?></p>
        <div class="cp-quick-actions">
            <form method="post" style="display:inline;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="generate_round">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <button class="button button-primary" type="submit" <?php disabled((int) $tournament->current_round >= (int) $tournament->rounds_total || count($players) < 2); ?>>
                    <?php echo esc_html__('Generate next round', 'chess-podium'); ?>
                </button>
            </form>
            <form method="post" style="display:inline;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="generate_round_print">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <button class="button" type="submit" <?php disabled((int) $tournament->current_round >= (int) $tournament->rounds_total || count($players) < 2); ?>>
                    <?php echo esc_html__('Generate & print', 'chess-podium'); ?>
                </button>
            </form>
            <a class="button" href="<?php echo esc_url(add_query_arg('print_standings', 1, $baseUrl)); ?>"><?php echo esc_html__('Print standings', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($badgesPdfUrl); ?>"><?php echo esc_html__('Badges (PDF)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($placecardsPdfUrl); ?>"><?php echo esc_html__('Placecards (PDF)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($boardcardsPdfUrl); ?>"><?php echo esc_html__('Board cards (PDF)', 'chess-podium'); ?></a>
            <?php if ((int) $tournament->current_round > 0): ?>
                <?php for ($r = 1; $r <= (int) $tournament->current_round; $r++): ?>
                    <a class="button" href="<?php echo esc_url(add_query_arg('print_round', $r, $baseUrl)); ?>"><?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), $r)); ?></a>
                <?php endfor; ?>
            <?php endif; ?>
        </div>
        <?php
        $eventStartDisp = '';
        if (!empty($tournament->start_date) && (string) $tournament->start_date !== '0000-00-00') {
            $eventStartDisp = (string) $tournament->start_date;
        } elseif (class_exists('ChessPodium_PdfBrochure')) {
            $broS = ChessPodium_PdfBrochure::get_meta($tid, 'start_date');
            $eventStartDisp = $broS ? (string) $broS : '';
        }
        $eventEndDisp = '';
        if (!empty($tournament->end_date) && (string) $tournament->end_date !== '0000-00-00') {
            $eventEndDisp = (string) $tournament->end_date;
        } elseif (class_exists('ChessPodium_PdfBrochure')) {
            $broE = ChessPodium_PdfBrochure::get_meta($tid, 'end_date');
            $eventEndDisp = $broE ? (string) $broE : '';
        }
        $calVisRow = isset($tournament->calendar_visible) ? (int) $tournament->calendar_visible : 1;
        ?>
        <h3><?php esc_html_e('Public events calendar', 'chess-podium'); ?></h3>
        <p class="description"><?php esc_html_e('Set the tournament dates for the site calendar shortcode [chess_podium_events]. You can also set dates and visibility from Content → brochure.', 'chess-podium'); ?></p>
        <form method="post" class="cp-event-schedule-form" style="max-width:640px;margin-bottom:1.5rem;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_tournament_event_schedule">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="overview">
            <table class="form-table">
                <tr>
                    <th><label for="event_start_date"><?php esc_html_e('Event start date', 'chess-podium'); ?></label></th>
                    <td><input type="date" id="event_start_date" name="event_start_date" class="regular-text" value="<?php echo esc_attr($eventStartDisp); ?>"></td>
                </tr>
                <tr>
                    <th><label for="event_end_date"><?php esc_html_e('Event end date', 'chess-podium'); ?></label></th>
                    <td><input type="date" id="event_end_date" name="event_end_date" class="regular-text" value="<?php echo esc_attr($eventEndDisp); ?>">
                    <p class="description"><?php esc_html_e('Optional. For multi-day tournaments. Leave empty for a single-day event.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><?php esc_html_e('Visibility', 'chess-podium'); ?></th>
                    <td><label><input type="checkbox" name="event_calendar_visible" value="1" <?php checked($calVisRow, 1); ?>> <?php esc_html_e('Show on public events calendar', 'chess-podium'); ?></label></td>
                </tr>
            </table>
            <p><button type="submit" class="button button-primary"><?php esc_html_e('Save event dates', 'chess-podium'); ?></button></p>
        </form>
        <p class="description" style="margin-top:1rem;"><strong><?php echo esc_html__('Lichess round import', 'chess-podium'); ?></strong> — <?php echo esc_html__('Paste a Lichess broadcast round URL or the round .pgn API link. The next round’s pairings are built from that file (no pairing engine).', 'chess-podium'); ?></p>
        <form method="post" class="cp-import-lichess-round" style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;max-width:960px;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="import_round_from_lichess">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="overview">
            <label for="lichess_round_url" class="screen-reader-text"><?php echo esc_html__('Lichess round URL', 'chess-podium'); ?></label>
            <input type="text" name="lichess_round_url" id="lichess_round_url" class="regular-text" style="flex:1;min-width:240px;" placeholder="https://lichess.org/broadcast/…/roundId" autocomplete="off" <?php disabled((int) $tournament->current_round >= (int) $tournament->rounds_total || count($players) < 2); ?>>
            <button class="button" type="submit" <?php disabled((int) $tournament->current_round >= (int) $tournament->rounds_total || count($players) < 2); ?>><?php echo esc_html__('Import next round from Lichess', 'chess-podium'); ?></button>
        </form>

        <?php
        $editRound = isset($_GET['edit_round']) ? (int) $_GET['edit_round'] : (int) $tournament->current_round;
        $editPairingsMode = isset($_GET['edit_pairings']) && (int) $_GET['edit_pairings'] === 1;
        $editRoundPairings = $editRound > 0 ? self::get_pairings_for_round((int) $tournament->id, $editRound) : [];
        ?>
        <?php if ((int) $tournament->current_round > 0 && !empty($editRoundPairings)): ?>
        <h3><?php echo esc_html__('Enter results', 'chess-podium'); ?></h3>
        <?php if (class_exists('ChessPodium_License') && ChessPodium_License::is_pro()): ?>
        <p style="margin-bottom:1rem;">
            <button type="button" class="button button-secondary cp-generate-config-btn" data-tid="<?php echo (int) $tid; ?>" data-round="<?php echo (int) $editRound; ?>" data-api-url="<?php echo esc_attr(rest_url('chess-podium/v1/update-live-move')); ?>"><?php echo esc_html__('Generate Config', 'chess-podium'); ?></button>
            <a href="<?php echo esc_url(self::get_public_tournament_view_url((int) $tid)); ?>" target="_blank" rel="noopener" class="button button-secondary"><?php echo esc_html__('View live page', 'chess-podium'); ?> →</a>
            <span class="description"><?php echo esc_html__('Copy parameters for CP Connect (DGT live). Board number = table position.', 'chess-podium'); ?></span>
        </p>
        <?php endif; ?>
        <p>
            <?php for ($r = 1; $r <= (int) $tournament->current_round; $r++):
                $roundUrl = add_query_arg(['edit_round' => $r, 'cp_tab' => 'overview'], $baseUrl);
                if ($editPairingsMode) {
                    $roundUrl = add_query_arg('edit_pairings', 1, $roundUrl);
                } else {
                    $roundUrl = remove_query_arg('edit_pairings', $roundUrl);
                }
            ?>
                <a class="button button-small <?php echo $editRound === $r ? 'button-primary' : ''; ?>" href="<?php echo esc_url($roundUrl); ?>"><?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), $r)); ?></a>
            <?php endfor; ?>
            <?php if ($editPairingsMode): ?>
                <a class="button button-primary" href="<?php echo esc_url(remove_query_arg('edit_pairings', add_query_arg(['edit_round' => $editRound, 'cp_tab' => 'overview'], $baseUrl))); ?>"><?php echo esc_html__('View only', 'chess-podium'); ?></a>
            <?php else: ?>
                <a class="button button-small" href="<?php echo esc_url(add_query_arg(['edit_round' => $editRound, 'cp_tab' => 'overview', 'edit_pairings' => 1], $baseUrl)); ?>"><?php echo esc_html__('Edit pairings', 'chess-podium'); ?></a>
            <?php endif; ?>
        </p>
        <?php if ($editPairingsMode): ?>
        <?php
        $pairedPlayerIds = [];
        foreach ($editRoundPairings as $p) {
            $w = (int) $p->white_player_id;
            $b = (int) $p->black_player_id;
            if ($w > 0) $pairedPlayerIds[$w] = true;
            if ($b > 0) $pairedPlayerIds[$b] = true;
        }
        $unpairedPlayers = array_filter($players, static function ($p) use ($pairedPlayerIds) {
            return !isset($pairedPlayerIds[(int) $p->id]);
        });
        ?>
        <form method="post" class="cp-edit-pairings-form">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_pairings">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
            <p class="description"><?php echo esc_html__('Change White/Black players to match the tournament played. Each player must appear only once per round.', 'chess-podium'); ?></p>
            <table class="widefat striped" style="max-width:800px;">
                <thead><tr><th><?php echo esc_html__('Board', 'chess-podium'); ?></th><th><?php echo esc_html__('White', 'chess-podium'); ?></th><th><?php echo esc_html__('Black', 'chess-podium'); ?></th><th><?php echo esc_html__('Result', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($editRoundPairings as $tableNum => $pairing):
                    $whiteId = (int) $pairing->white_player_id;
                    $blackId = (int) $pairing->black_player_id;
                    $isBye = (int) $pairing->is_bye === 1;
                ?>
                    <tr>
                        <td><?php echo (int) ($tableNum + 1); ?></td>
                        <td>
                            <?php if ($isBye): ?>
                                <select name="pairings[<?php echo (int) $pairing->id; ?>][white]">
                                    <?php foreach ($players as $p): ?>
                                        <option value="<?php echo (int) $p->id; ?>" <?php selected($whiteId, (int) $p->id); ?>><?php echo esc_html($p->name); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            <?php else: ?>
                                <select name="pairings[<?php echo (int) $pairing->id; ?>][white]">
                                    <option value="">—</option>
                                    <?php foreach ($players as $p): ?>
                                        <option value="<?php echo (int) $p->id; ?>" <?php selected($whiteId, (int) $p->id); ?>><?php echo esc_html($p->name); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            <?php endif; ?>
                        </td>
                        <td>
                            <?php if ($isBye): ?>
                                <span class="description">BYE</span>
                                <input type="hidden" name="pairings[<?php echo (int) $pairing->id; ?>][black]" value="0">
                            <?php else: ?>
                                <select name="pairings[<?php echo (int) $pairing->id; ?>][black]">
                                    <option value="">—</option>
                                    <option value="bye"><?php echo esc_html__('— BYE (0.5 pts for White) —', 'chess-podium'); ?></option>
                                    <?php foreach ($players as $p): ?>
                                        <option value="<?php echo (int) $p->id; ?>" <?php selected($blackId, (int) $p->id); ?>><?php echo esc_html($p->name); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            <?php endif; ?>
                        </td>
                        <td>
                            <?php if ($isBye): ?>BYE
                            <?php else:
                                $dispRes = self::normalize_result((string) ($pairing->result ?? ''));
                            ?>
                                <select name="pairings[<?php echo (int) $pairing->id; ?>][result]">
                                    <option value="" <?php selected($dispRes, ''); ?>><?php echo esc_html__('—', 'chess-podium'); ?></option>
                                    <option value="1-0" <?php selected($dispRes, '1-0'); ?>><?php echo esc_html__('1-0', 'chess-podium'); ?></option>
                                    <option value="0-1" <?php selected($dispRes, '0-1'); ?>><?php echo esc_html__('0-1', 'chess-podium'); ?></option>
                                    <option value="1/2-1/2" <?php selected($dispRes, '1/2-1/2'); ?>><?php echo esc_html__('1/2-1/2', 'chess-podium'); ?></option>
                                    <option value="1F" <?php selected($dispRes, '1F'); ?>><?php echo esc_html__('1-0F (White wins by forfeit)', 'chess-podium'); ?></option>
                                    <option value="0F" <?php selected($dispRes, '0F'); ?>><?php echo esc_html__('0-1F (Black wins by forfeit)', 'chess-podium'); ?></option>
                                </select>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php if (!empty($unpairedPlayers)): ?>
            <p class="description" style="margin-top:1rem;"><?php echo esc_html__('Add bye for unpaired players (check each player who gets a bye):', 'chess-podium'); ?></p>
            <table class="widefat striped" style="max-width:500px;margin-top:0.5rem;">
                <thead><tr><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Points', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($unpairedPlayers as $up): ?>
                    <tr>
                        <td><label><input type="checkbox" name="add_bye_players[<?php echo (int) $up->id; ?>]" value="1"> <?php echo esc_html($up->name); ?></label></td>
                        <td>
                            <select name="add_bye_points[<?php echo (int) $up->id; ?>]">
                                <option value="0.5"><?php echo esc_html__('0.5 pts', 'chess-podium'); ?></option>
                                <option value="1"><?php echo esc_html__('1 pt', 'chess-podium'); ?></option>
                            </select>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php endif; ?>
            <p><button class="button button-primary" type="submit"><?php echo esc_html__('Save pairings', 'chess-podium'); ?></button></p>
        </form>
        <?php else: ?>
        <div class="cp-simulate-live" style="margin-bottom:1.5rem;padding:12px;background:#f0fdf4;border:1px solid #86efac;border-radius:4px;">
            <p style="margin:0 0 8px 0;"><strong><?php echo esc_html__('Test live boards interface', 'chess-podium'); ?></strong></p>
            <p class="description" style="margin:0 0 10px 0;"><?php echo esc_html__('Simulate a live transmission to preview the live boards section on the tournament page. No DGT hardware needed.', 'chess-podium'); ?></p>
            <form method="post" action="<?php echo esc_url(add_query_arg(['cp_tab' => 'overview', 'edit_round' => $editRound], $baseUrl)); ?>" style="display:inline;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="simulate_live_test">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tid; ?>">
                <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
                <button type="submit" class="button button-secondary"><?php echo esc_html__('Simulate live (test)', 'chess-podium'); ?></button>
            </form>
        </div>
        <div class="cp-import-chess-results" style="margin-bottom:1.5rem;padding:12px;background:#f0f6fc;border:1px solid #c3c4c7;border-radius:4px;">
            <p style="margin:0 0 8px 0;"><strong><?php echo esc_html__('Import from chess-results.com', 'chess-podium'); ?></strong></p>
            <p class="description" style="margin:0 0 10px 0;"><?php echo esc_html__('Export the round from chess-results (Excel-Export .xlsx) and upload it here to bulk import all results.', 'chess-podium'); ?></p>
            <form method="post" action="<?php echo esc_url(add_query_arg(['cp_tab' => 'overview', 'edit_round' => $editRound], $baseUrl)); ?>" enctype="multipart/form-data" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="import_chess_results">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
                <input type="file" name="chess_results_excel" accept=".xlsx" required>
                <button type="submit" class="button button-secondary"><?php echo esc_html__('Import results', 'chess-podium'); ?></button>
            </form>
        </div>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_results">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
            <table class="widefat striped cp-results-table" style="max-width:800px;" data-tid="<?php echo (int) $tid; ?>" data-round="<?php echo (int) $editRound; ?>">
                <thead><tr><th><?php echo esc_html__('Board', 'chess-podium'); ?></th><th><?php echo esc_html__('ID', 'chess-podium'); ?></th><th><?php echo esc_html__('White', 'chess-podium'); ?></th><th><?php echo esc_html__('Black', 'chess-podium'); ?></th><th><?php echo esc_html__('Result', 'chess-podium'); ?></th><?php if (class_exists('ChessPodium_License') && ChessPodium_License::is_pro()): ?><th><?php echo esc_html__('Live', 'chess-podium'); ?></th><?php endif; ?></tr></thead>
                <tbody>
                <?php foreach ($editRoundPairings as $tableNum => $pairing):
                    $dispRes = self::normalize_result((string) ($pairing->result ?? ''));
                    $isLive = !empty($pairing->is_live);
                    $boardNo = (int) ($tableNum + 1);
                ?>
                    <tr data-board="<?php echo esc_attr((string) $boardNo); ?>" data-pairing-id="<?php echo esc_attr((string) $pairing->id); ?>" data-is-bye="<?php echo esc_attr((string) (int) $pairing->is_bye); ?>">
                        <td><strong><?php echo esc_html((string) $boardNo); ?></strong></td>
                        <td><code class="cp-pairing-id"><?php echo (int) $pairing->id; ?></code></td>
                        <td><?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag($pairing->white_country ?? '', $pairing->white_country ?? '')); ?> <?php echo esc_html($pairing->white_name); ?></td>
                        <td><?php echo (int) $pairing->is_bye === 1 ? ($pairing->result === 'NOT_PAIRED' ? '(' . esc_html__('not paired', 'chess-podium') . ')' : '-') : (wp_kses_post(ChessPodium_CountryHelper::render_flag($pairing->black_country ?? '', $pairing->black_country ?? '')) . ' ' . esc_html($pairing->black_name)); ?></td>
                        <td>
                            <?php if ((int) $pairing->is_bye === 1): ?><?php echo $pairing->result === 'NOT_PAIRED' ? esc_html__('not paired', 'chess-podium') : 'BYE'; ?>
                            <?php else: ?>
                            <select name="results[<?php echo (int) $pairing->id; ?>]">
                                <option value="" <?php selected($dispRes, ''); ?>><?php echo esc_html__('—', 'chess-podium'); ?></option>
                                <option value="1-0" <?php selected($dispRes, '1-0'); ?>><?php echo esc_html__('1-0', 'chess-podium'); ?></option>
                                <option value="0-1" <?php selected($dispRes, '0-1'); ?>><?php echo esc_html__('0-1', 'chess-podium'); ?></option>
                                <option value="1/2-1/2" <?php selected($dispRes, '1/2-1/2'); ?>><?php echo esc_html__('1/2-1/2', 'chess-podium'); ?></option>
                                <option value="1F" <?php selected($dispRes, '1F'); ?>><?php echo esc_html__('1-0F (White wins by forfeit)', 'chess-podium'); ?></option>
                                <option value="0F" <?php selected($dispRes, '0F'); ?>><?php echo esc_html__('0-1F (Black wins by forfeit)', 'chess-podium'); ?></option>
                            </select>
                            <?php endif; ?>
                        </td>
                        <?php if (class_exists('ChessPodium_License') && ChessPodium_License::is_pro()): ?>
                        <td>
                            <?php if ((int) $pairing->is_bye === 1): ?>—
                            <?php else: ?>
                            <label><input type="checkbox" name="live[<?php echo (int) $pairing->id; ?>]" value="1" <?php checked($isLive); ?>> <?php echo esc_html__('DGT live', 'chess-podium'); ?></label>
                            <?php endif; ?>
                        </td>
                        <?php endif; ?>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <p><button class="button button-primary" type="submit"><?php echo esc_html__('Save results', 'chess-podium'); ?></button></p>
        </form>
        <div class="cp-pgn-live-sources" style="margin-top:1.5rem;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;">
            <h4 style="margin-top:0;"><?php echo esc_html__('PGN Live (URL)', 'chess-podium'); ?></h4>
            <p class="description" style="margin-bottom:10px;"><?php echo esc_html__('Add a URL that returns PGN content (e.g. Lichess broadcast). Fetched on tournament page load and via cron. Games are matched by FIDE ID (when present in PGN) or White/Black names. Set Round to the round number of the feed (e.g. 3 for round 3).', 'chess-podium'); ?></p>
            <p style="margin-bottom:10px;">
                <a href="<?php echo esc_url(self::get_public_tournament_view_url((int) $tid)); ?>" target="_blank" rel="noopener" class="button button-secondary">
                    <?php echo esc_html__('View live tournament page', 'chess-podium'); ?> →
                </a>
                <span class="description" style="margin-left:8px;"><?php echo esc_html__('Share this link on your site. Or use shortcode', 'chess-podium'); ?> <code>[chess_podium_tournament id="<?php echo (int) $tid; ?>"]</code> <?php echo esc_html__('on any page.', 'chess-podium'); ?></span>
            </p>
            <?php $pgnSources = ChessPodium_PgnLive::get_sources_for_tournament($tid); ?>
            <p style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px;">
                <form method="post" style="display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="add_pgn_source">
                    <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                    <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
                    <input type="url" name="pgn_source_url" placeholder="https://lichess.org/api/broadcast/round/XXX.pgn" class="regular-text" style="min-width:280px;" required>
                    <label><?php echo esc_html__('Round', 'chess-podium'); ?>: <input type="number" name="pgn_source_round" min="1" value="<?php echo (int) $editRound; ?>" style="width:60px;"></label>
                    <button class="button" type="submit"><?php echo esc_html__('Add source', 'chess-podium'); ?></button>
                </form>
            </p>
            <?php if (!empty($pgnSources)): ?>
            <ul style="margin:10px 0 0 0;padding-left:20px;">
                <?php foreach ($pgnSources as $src): ?>
                <li style="margin-bottom:6px;">
                    <code style="font-size:0.9em;"><?php echo esc_html($src->source_url); ?></code> (<?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), (int) $src->round_no)); ?>)
                    <?php if ($src->last_fetched): ?><span class="description"><?php echo esc_html(sprintf(__('Last fetch: %s', 'chess-podium'), $src->last_fetched)); ?></span><?php endif; ?>
                    <form method="post" style="display:inline;">
                        <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                        <input type="hidden" name="regina_action" value="delete_pgn_source">
                        <input type="hidden" name="pgn_source_id" value="<?php echo (int) $src->id; ?>">
                        <input type="hidden" name="edit_round" value="<?php echo (int) $editRound; ?>">
                        <button class="button button-small button-link-delete" type="submit"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                    </form>
                </li>
                <?php endforeach; ?>
            </ul>
            <?php endif; ?>
        </div>
        <?php if (class_exists('ChessPodium_License') && ChessPodium_License::is_pro()): ?>
        <div id="cp-config-modal" class="cp-config-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:100000;align-items:center;justify-content:center;">
            <div class="cp-config-modal-inner" style="background:#fff;border-radius:8px;padding:1.5rem;max-width:520px;width:90%;max-height:85vh;overflow:auto;box-shadow:0 4px 20px rgba(0,0,0,0.2);">
                <h3 style="margin-top:0;"><?php echo esc_html__('CP Connect – Parameters', 'chess-podium'); ?></h3>
                <p class="description"><?php echo esc_html__('Use these values in Chess Podium Connect. Board number = table position (1, 2, 3…). No config file needed—just paste URL, API Key, and select the PGN file.', 'chess-podium'); ?></p>
                <table class="widefat striped" style="margin:1rem 0;">
                    <tr><th style="width:140px;"><?php echo esc_html__('Parameter', 'chess-podium'); ?></th><th><?php echo esc_html__('Value', 'chess-podium'); ?></th></tr>
                    <tr><td><strong>API URL</strong></td><td><code id="cp-config-api-url" style="word-break:break-all;"></code></td></tr>
                    <tr><td><strong>tournament_id</strong></td><td><code id="cp-config-tid"></code></td></tr>
                    <tr><td><strong>round_no</strong></td><td><code id="cp-config-round"></code></td></tr>
                    <tr><td><strong>Board mapping</strong></td><td><code id="cp-config-mapping" style="font-size:0.9em;word-break:break-all;"></code></td></tr>
                </table>
                <p><button type="button" class="button button-primary cp-copy-config-btn"><?php echo esc_html__('Copy all', 'chess-podium'); ?></button>
                <button type="button" class="button cp-config-close-btn"><?php echo esc_html__('Close', 'chess-podium'); ?></button></p>
            </div>
        </div>
        <?php endif; ?>
        <?php endif; ?>
        <?php endif; ?>

        <h3><?php echo esc_html__('Standings', 'chess-podium'); ?></h3>
        <?php if (empty($standings)): ?>
            <p><?php echo esc_html__('No standings yet.', 'chess-podium'); ?></p>
        <?php else: ?>
            <table class="widefat striped" style="max-width:600px;">
                <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><th><?php echo esc_html__('Buchholz', 'chess-podium'); ?></th><th><?php echo esc_html__('FIDE Buchholz Cut 1', 'chess-podium'); ?></th><th><?php echo esc_html__('SB', 'chess-podium'); ?></th><th><?php echo esc_html__('ARO', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach (array_slice($standings, 0, 15) as $pos => $s):
                    $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, isset($s['title']) ? (string) $s['title'] : null);
                    }
                    ?>
                    <tr>
                        <td><?php echo (int) ($pos + 1); ?></td>
                        <td><?php echo wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) ? (int) $s['rating'] : null, !empty($norms) ? $norms : null)); ?></td>
                        <td><?php echo esc_html(number_format((float) $s['points'], 1)); ?></td>
                        <td><?php echo esc_html(number_format((float) $s['buchholz'], 2)); ?></td>
                        <td><?php echo esc_html(number_format((float) ($s['buchholz1'] ?? 0), 2)); ?></td>
                        <td><?php echo esc_html(number_format((float) $s['sb'], 2)); ?></td>
                        <td><?php echo esc_html(number_format((float) ($s['aro'] ?? 0), 2)); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php if (count($standings) > 15): ?><p class="description"><?php echo esc_html(sprintf(__('%d players total. See Players tab for full list.', 'chess-podium'), count($standings))); ?></p><?php endif; ?>
        <?php endif; ?>

        <h3><?php echo esc_html__('Fixed board assignments', 'chess-podium'); ?></h3>
        <p class="description"><?php echo esc_html__('Assign a fixed board to a player from a round (e.g. for wheelchair access). The pairing will place that player at the specified board.', 'chess-podium'); ?></p>
        <?php
        $fixedBoards = self::get_all_fixed_board_assignments((int) $tournament->id);
        $fixedBoardsByPlayer = [];
        foreach ($fixedBoards as $fb) {
            $fixedBoardsByPlayer[(int) $fb['player_id']] = true;
        }
        ?>
        <form method="post" class="cp-fixed-boards-form">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_fixed_boards">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <table class="widefat striped" style="max-width:600px;">
                <thead><tr><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Board', 'chess-podium'); ?></th><th><?php echo esc_html__('From round', 'chess-podium'); ?></th><th></th></tr></thead>
                <tbody>
                <?php foreach ($fixedBoards as $fb): ?>
                    <tr>
                        <td><?php echo esc_html($fb['player_name'] ?? ''); ?></td>
                        <td><?php echo (int) $fb['board_number']; ?></td>
                        <td><?php echo (int) $fb['from_round']; ?></td>
                        <td><button type="submit" name="remove_fixed_board" value="<?php echo (int) $fb['player_id']; ?>" class="button button-small"><?php echo esc_html__('Remove', 'chess-podium'); ?></button></td>
                    </tr>
                <?php endforeach; ?>
                <tr>
                    <td>
                        <select name="fixed_board_player_id">
                            <option value=""><?php echo esc_html__('— Select player —', 'chess-podium'); ?></option>
                            <?php foreach ($players as $p): ?>
                                <?php if (!isset($fixedBoardsByPlayer[(int) $p->id])): ?>
                                    <option value="<?php echo (int) $p->id; ?>"><?php echo esc_html($p->name); ?></option>
                                <?php endif; ?>
                            <?php endforeach; ?>
                        </select>
                    </td>
                    <td><input type="number" name="fixed_board_number" min="1" max="200" value="1" style="width:60px;"></td>
                    <td><input type="number" name="fixed_board_from_round" min="1" max="<?php echo (int) $tournament->rounds_total; ?>" value="1" style="width:60px;"></td>
                    <td><button type="submit" name="add_fixed_board" value="1" class="button"><?php echo esc_html__('Add', 'chess-podium'); ?></button></td>
                </tr>
                </tbody>
            </table>
        </form>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'settings' ? 'cp-tab-active' : ''; ?>" data-panel="settings">
        <h3><?php echo esc_html__('Settings', 'chess-podium'); ?></h3>
        <h4><?php echo esc_html__('Tournament type', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Swiss uses Dutch pairings; round robin uses Berger tables; double round robin repeats the same schedule with colours reversed. If you change type after pairings were generated, review existing rounds and adjust total rounds if needed (e.g. double RR with N players: 2×(N−1) for even N).', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_type">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <p>
                <label for="settings_tournament_type"><?php echo esc_html__('Type', 'chess-podium'); ?></label><br>
                <select id="settings_tournament_type" name="tournament_type" style="min-width:200px;">
                    <?php
                    $curType = isset($tournament->tournament_type) ? (string) $tournament->tournament_type : 'swiss';
                    ?>
                    <option value="swiss" <?php selected($curType, 'swiss'); ?>><?php echo esc_html__('Swiss', 'chess-podium'); ?></option>
                    <option value="round_robin" <?php selected($curType, 'round_robin'); ?>><?php echo esc_html__('Round Robin', 'chess-podium'); ?></option>
                    <option value="double_round_robin" <?php selected($curType, 'double_round_robin'); ?>><?php echo esc_html__('Double Round Robin', 'chess-podium'); ?></option>
                </select>
            </p>
            <p><button type="submit" class="button"><?php echo esc_html__('Save tournament type', 'chess-podium'); ?></button></p>
        </form>

        <h4><?php echo esc_html__('Team tournament', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Enable team mode to manage teams, board order and team standings. Use the Teams tab to enter team matches and board results.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_team_tournament_settings">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <p>
                <label>
                    <input type="checkbox" name="is_team_tournament" value="1" <?php checked(!empty($tournament->is_team_tournament)); ?>>
                    <?php echo esc_html__('This is a team tournament', 'chess-podium'); ?>
                </label>
            </p>
            <p style="display:flex;gap:14px;flex-wrap:wrap;align-items:flex-end;">
                <span>
                    <label for="team_boards"><strong><?php echo esc_html__('Boards per match', 'chess-podium'); ?></strong></label><br>
                    <input id="team_boards" name="team_boards" type="number" min="1" max="20" value="<?php echo esc_attr((string) ((int) ($tournament->team_boards ?? 4) ?: 4)); ?>" style="width:90px;">
                </span>
                <span>
                    <label for="team_scoring"><strong><?php echo esc_html__('Standings primary score', 'chess-podium'); ?></strong></label><br>
                    <?php $ts = isset($tournament->team_scoring) ? (string) $tournament->team_scoring : 'mp'; ?>
                    <select id="team_scoring" name="team_scoring">
                        <option value="mp" <?php selected($ts, 'mp'); ?>><?php echo esc_html__('Match points (MP)', 'chess-podium'); ?></option>
                        <option value="bp" <?php selected($ts, 'bp'); ?>><?php echo esc_html__('Board points (BP)', 'chess-podium'); ?></option>
                    </select>
                </span>
                <span><button type="submit" class="button"><?php echo esc_html__('Save team settings', 'chess-podium'); ?></button></span>
            </p>
        </form>
        <h4><?php echo esc_html__('Number of rounds', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('You can change the total rounds even after the tournament has started. The new value cannot be less than the current round.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_rounds_total">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <p>
                <label for="settings_rounds_total"><?php echo esc_html__('Total rounds', 'chess-podium'); ?></label><br>
                <input id="settings_rounds_total" name="rounds_total" type="number" min="<?php echo (int) $tournament->current_round; ?>" max="99" value="<?php echo (int) $tournament->rounds_total; ?>" style="width:70px;">
                <span class="description"><?php echo esc_html(sprintf(__('Minimum %d (current round).', 'chess-podium'), (int) $tournament->current_round)); ?></span>
            </p>
            <p><button type="submit" class="button"><?php echo esc_html__('Save rounds', 'chess-podium'); ?></button></p>
        </form>
        <h4><?php echo esc_html__('Rating system & country', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Affects terminology (Rank/Standing, Round/Rd) and date format. Rating system influences which federation ID is preferred for tiebreaks.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_rating_settings">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_rating_system"><?php echo esc_html__('Rating system', 'chess-podium'); ?></label><br>
                <select id="settings_rating_system" name="rating_system">
                    <option value="fide" <?php selected($tournament->rating_system ?? '', 'fide'); ?>><?php echo esc_html__('FIDE', 'chess-podium'); ?></option>
                    <option value="uscf" <?php selected($tournament->rating_system ?? '', 'uscf'); ?>><?php echo esc_html__('USCF', 'chess-podium'); ?></option>
                    <option value="ecf" <?php selected($tournament->rating_system ?? '', 'ecf'); ?>><?php echo esc_html__('ECF', 'chess-podium'); ?></option>
                </select>
            </p>
            <p>
                <label for="settings_country_code"><?php echo esc_html__('Country', 'chess-podium'); ?></label><br>
                <input id="settings_country_code" name="country_code" type="text" value="<?php echo esc_attr($tournament->country_code ?? ''); ?>" maxlength="2" style="width:60px;" placeholder="US">
                <span class="description"><?php echo esc_html__('ISO 2-letter (e.g. US, GB). Affects date format and terminology.', 'chess-podium'); ?></span>
            </p>
            <p><button type="submit" class="button"><?php echo esc_html__('Save', 'chess-podium'); ?></button></p>
        </form>
        <h4><?php echo esc_html__('Fair play & anti-cheating (FIDE)', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Summarize fair play measures for players, aligned with themes from FIDE Handbook §09 (Fair Play / Anti-Cheating). Shown on the live tournament page and in static HTML export when enabled.', 'chess-podium'); ?></p>
        <?php
        $fpMeasureDefs = class_exists('ChessPodium_FairPlayDisplay') ? ChessPodium_FairPlayDisplay::measure_definitions() : [];
        $fpChecked = class_exists('ChessPodium_FairPlayDisplay') ? ChessPodium_FairPlayDisplay::decode_measures(isset($tournament->fair_play_measures) ? (string) $tournament->fair_play_measures : '') : [];
        $fpPublic = !empty($tournament->fair_play_public);
        $fpOfficer = isset($tournament->fair_play_officer) ? (string) $tournament->fair_play_officer : '';
        $fpComplaint = isset($tournament->fair_play_complaint_url) ? (string) $tournament->fair_play_complaint_url : '';
        $fpAdditional = isset($tournament->fair_play_additional) ? (string) $tournament->fair_play_additional : '';
        ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_fair_play">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label>
                    <input type="checkbox" name="fair_play_public" value="1" <?php checked($fpPublic); ?>>
                    <?php echo esc_html__('Show fair play section on live page and static export', 'chess-podium'); ?>
                </label>
            </p>
            <p><strong><?php echo esc_html__('Standard measures (tick all that apply)', 'chess-podium'); ?></strong></p>
            <ul style="list-style:none;margin:0 0 1rem;padding:0;max-width:640px;">
                <?php foreach ($fpMeasureDefs as $slug => $label): ?>
                    <li style="margin-bottom:0.5rem;">
                        <label>
                            <input type="checkbox" name="fair_play_measures[]" value="<?php echo esc_attr($slug); ?>" <?php checked(in_array($slug, $fpChecked, true)); ?>>
                            <?php echo esc_html($label); ?>
                        </label>
                    </li>
                <?php endforeach; ?>
            </ul>
            <p>
                <label for="fair_play_officer"><?php echo esc_html__('Fair play contact / officer (optional)', 'chess-podium'); ?></label><br>
                <input id="fair_play_officer" name="fair_play_officer" type="text" class="regular-text" style="max-width:420px;" value="<?php echo esc_attr($fpOfficer); ?>" placeholder="<?php echo esc_attr__('e.g. Chief Arbiter / Fair Play Officer name', 'chess-podium'); ?>">
            </p>
            <p>
                <label for="fair_play_complaint_url"><?php echo esc_html__('Complaints or info URL (optional, https)', 'chess-podium'); ?></label><br>
                <input id="fair_play_complaint_url" name="fair_play_complaint_url" type="url" class="regular-text" style="max-width:420px;" value="<?php echo esc_attr($fpComplaint); ?>" placeholder="https://">
            </p>
            <p>
                <label for="fair_play_additional"><?php echo esc_html__('Additional protocols & local rules (optional)', 'chess-podium'); ?></label><br>
                <textarea id="fair_play_additional" name="fair_play_additional" rows="5" class="large-text" style="max-width:640px;"><?php echo esc_textarea($fpAdditional); ?></textarea>
                <span class="description"><?php echo esc_html__('Short bullet points or paragraphs. Basic formatting allowed.', 'chess-podium'); ?></span>
            </p>
            <p><button type="submit" class="button button-primary"><?php echo esc_html__('Save fair play settings', 'chess-podium'); ?></button></p>
        </form>
        <h4><?php echo esc_html__('Grand Prix', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Include this tournament in a Grand Prix. When finished, it will automatically contribute points and social ELO to the GP standings.', 'chess-podium'); ?></p>
        <?php
        $allGps = class_exists('ChessPodium_GrandPrix') ? ChessPodium_GrandPrix::get_all() : [];
        $tournamentGpIds = class_exists('ChessPodium_GrandPrix') ? ChessPodium_GrandPrix::get_grand_prix_ids_for_tournament($tid) : [];
        ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_grand_prix">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tid; ?>">
            <?php if (empty($allGps)): ?>
                <p class="description"><?php echo esc_html__('No Grand Prix yet. Create one in Chess Podium → Grand Prix.', 'chess-podium'); ?></p>
            <?php else: ?>
                <p>
                    <?php foreach ($allGps as $gp): ?>
                        <label style="display:block; margin-bottom:0.4rem;">
                            <input type="checkbox" name="grand_prix_ids[]" value="<?php echo (int) $gp->id; ?>" <?php checked(in_array((int) $gp->id, $tournamentGpIds, true)); ?>>
                            <?php echo esc_html($gp->name); ?>
                        </label>
                    <?php endforeach; ?>
                </p>
                <p><button type="submit" class="button"><?php echo esc_html__('Save Grand Prix', 'chess-podium'); ?></button></p>
            <?php endif; ?>
        </form>
        <h4><?php echo esc_html__('Pairing engine', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('JaVaFo is always used first (FIDE homologation). bbpPairings and built-in are fallbacks only.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_pairing_engine">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_pairing_engine"><?php echo esc_html__('Engine', 'chess-podium'); ?></label><br>
                <select id="settings_pairing_engine" name="pairing_engine">
                    <option value="" <?php selected(($tournament->pairing_engine ?? ''), ''); ?>><?php echo esc_html__('Use global default', 'chess-podium'); ?></option>
                    <option value="javafo" <?php selected($tournament->pairing_engine ?? '', 'javafo'); ?>><?php echo esc_html__('JaVaFo (Swiss Manager, chess-results, Vega)', 'chess-podium'); ?></option>
                    <option value="bbpPairings" <?php selected($tournament->pairing_engine ?? '', 'bbpPairings'); ?>><?php echo esc_html__('bbpPairings (Swiss Sys)', 'chess-podium'); ?></option>
                </select>
                <span class="description"><?php echo esc_html__('Global default is set in Chess Podium → Pairing.', 'chess-podium'); ?></span>
            </p>
            <p>
                <button type="submit" class="button"><?php echo esc_html__('Save pairing engine', 'chess-podium'); ?></button>
            </p>
        </form>
        <h4><?php echo esc_html__('Round 2+ pairing tiebreak', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Within each score group, how to order players for pairing. Swiss Manager uses "Rating". FIDE strict uses "Nr" (pairing number).', 'chess-podium'); ?></p>
        <p class="description"><?php echo esc_html__('Requires JaVaFo (Swiss Manager, chess-results, Vega). If pairing uses bbpPairings fallback, this option may not apply.', 'chess-podium'); ?></p>
        <?php $round2Tiebreak = ($tournament->round2_pairing_tiebreak ?? 'nr'); ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_round2_pairing_tiebreak">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_round2_pairing_tiebreak"><?php echo esc_html__('Tiebreak', 'chess-podium'); ?></label><br>
                <select id="settings_round2_pairing_tiebreak" name="round2_pairing_tiebreak">
                    <option value="nr" <?php selected($round2Tiebreak, 'nr'); ?>><?php echo esc_html__('Nr (pairing number, FIDE strict)', 'chess-podium'); ?></option>
                    <option value="rating" <?php selected($round2Tiebreak, 'rating'); ?>><?php echo esc_html__('Rating (Swiss Manager style)', 'chess-podium'); ?></option>
                </select>
            </p>
            <p>
                <button type="submit" class="button"><?php echo esc_html__('Save tiebreak', 'chess-podium'); ?></button>
            </p>
        </form>
        <h4><?php echo esc_html__('Pairing acceleration', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Accelerated pairings add virtual points to top-ranked players in early rounds so they meet sooner. FIDE rules: C.04.7 (from Feb 2026).', 'chess-podium'); ?></p>
        <?php $pairingAccel = ($tournament->pairing_acceleration ?? 'none'); ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_pairing_acceleration">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_pairing_acceleration"><?php echo esc_html__('Acceleration', 'chess-podium'); ?></label><br>
                <select id="settings_pairing_acceleration" name="pairing_acceleration">
                    <option value="none" <?php selected($pairingAccel, 'none'); ?>><?php echo esc_html__('None (standard Swiss)', 'chess-podium'); ?></option>
                    <option value="fide_accel" <?php selected($pairingAccel, 'fide_accel'); ?>><?php echo esc_html__('Accelerated (FIDE C.04.7)', 'chess-podium'); ?></option>
                    <option value="baku" <?php selected($pairingAccel, 'baku'); ?>><?php echo esc_html__('Baku Accelerated', 'chess-podium'); ?></option>
                </select>
            </p>
            <p>
                <button type="submit" class="button"><?php echo esc_html__('Save acceleration', 'chess-podium'); ?></button>
            </p>
        </form>
        <h4><?php echo esc_html__('Round 1 pairing mode', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Choose how round 1 is paired. Set before generating the first round; cannot be changed afterwards.', 'chess-podium'); ?></p>
        <?php $round1Mode = ($tournament->round1_pairing_mode ?? 'dutch'); $canChangeRound1 = (int) $tournament->current_round === 0; ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_round1_pairing_mode">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_round1_pairing_mode"><?php echo esc_html__('Mode', 'chess-podium'); ?></label><br>
                <select id="settings_round1_pairing_mode" name="round1_pairing_mode" <?php disabled(!$canChangeRound1); ?>>
                    <option value="dutch" <?php selected($round1Mode, 'dutch'); ?>><?php echo esc_html__('Standard Dutch (1 vs 39, 2 vs 40, …)', 'chess-podium'); ?></option>
                    <option value="split" <?php selected($round1Mode, 'split'); ?>><?php echo esc_html__('Split (Swiss Manager style: 1 vs 20, 40 vs 59, …)', 'chess-podium'); ?></option>
                </select>
                <?php if (!$canChangeRound1): ?>
                    <span class="description"><?php echo esc_html__('Round 1 already generated; mode is fixed.', 'chess-podium'); ?></span>
                <?php endif; ?>
            </p>
            <?php if ($canChangeRound1): ?>
            <p>
                <button type="submit" class="button"><?php echo esc_html__('Save round 1 mode', 'chess-podium'); ?></button>
            </p>
            <?php endif; ?>
        </form>
        <form method="post" style="margin-top:8px;">
            <?php wp_nonce_field('chess_podium_pairing_settings'); ?>
            <input type="hidden" name="chess_podium_test_pairing_api" value="1">
            <input type="hidden" name="chess_podium_test_return" value="<?php echo esc_attr(admin_url('admin.php?page=' . self::MENU_SLUG . '&tournament_id=' . $tid . '&cp_tab=settings')); ?>">
            <button type="submit" class="button"><?php echo esc_html__('Test pairing API', 'chess-podium'); ?></button>
        </form>
        <?php
        $testResult = get_transient('chess_podium_pairing_api_test');
        if (is_array($testResult)):
            delete_transient('chess_podium_pairing_api_test');
            $isOk = !empty($testResult['ok']);
            $msg = $testResult['msg'] ?? '';
        ?>
            <div class="notice notice-<?php echo $isOk ? 'success' : 'error'; ?> inline" style="margin-top:8px;"><p><?php echo esc_html($msg); ?></p></div>
        <?php endif; ?>
        <p class="description"><?php echo esc_html__('Test the pairing API to verify it responds correctly (JaVaFo requires the API server to have Java and javafo.jar).', 'chess-podium'); ?></p>
        <h4><?php echo esc_html__('Standings tiebreakers', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Order of tiebreakers for standings. Can be changed at any time; only affects display order of tied players, not pairings.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="update_tournament_tiebreakers">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label for="settings_tiebreakers"><?php echo esc_html__('Tiebreakers', 'chess-podium'); ?></label><br>
                <input id="settings_tiebreakers" name="tiebreakers" type="text" value="<?php echo esc_attr($tournament->tiebreakers ?? 'buchholz,sb,rating'); ?>" class="regular-text" style="max-width:320px;" placeholder="buchholz,buchholz1,sb,rating,aro,direct,win,bwg,ks,rep">
                <span class="description"><?php echo esc_html__('Comma-separated order. See legend below.', 'chess-podium'); ?></span>
            </p>
            <?php
            $tiebreakersList = array_map('trim', explode(',', (string) ($tournament->tiebreakers ?? 'buchholz,sb,rating')));
            $hasAro = in_array('aro', $tiebreakersList, true);
            $aroCutStrongest = isset($tournament->aro_cut_strongest) ? (int) $tournament->aro_cut_strongest : 0;
            $aroCutWeakest = isset($tournament->aro_cut_weakest) ? (int) $tournament->aro_cut_weakest : 0;
            $aroMinRating = isset($tournament->aro_min_rating) ? (int) $tournament->aro_min_rating : 1400;
            ?>
            <?php if ($hasAro): ?>
            <fieldset style="margin-top:1rem;padding:1rem;border:1px solid #c3c4c7;border-radius:4px;max-width:400px;">
                <legend><?php echo esc_html__('ARO (Average Rating of Opponents) — optional parameters', 'chess-podium'); ?></legend>
                <p class="description"><?php echo esc_html__('Align with Swiss Manager. Exclude strongest/weakest opponent ratings from the average.', 'chess-podium'); ?></p>
                <p>
                    <label><?php echo esc_html__('Exclude strongest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="aro_cut_strongest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($aroCutStrongest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
                <p>
                    <label><?php echo esc_html__('Exclude weakest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="aro_cut_weakest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($aroCutWeakest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
                <p>
                    <label for="aro_min_rating"><?php echo esc_html__('Minimum Elo for unrated players', 'chess-podium'); ?></label><br>
                    <input id="aro_min_rating" name="aro_min_rating" type="number" min="0" max="3000" value="<?php echo esc_attr($aroMinRating); ?>" style="width:80px;">
                    <span class="description"><?php echo esc_html__('Used when opponent has no rating (default 1400).', 'chess-podium'); ?></span>
                </p>
            </fieldset>
            <?php endif; ?>
            <?php
            $hasBuchholz = in_array('buchholz', $tiebreakersList, true);
            $hasSb = in_array('sb', $tiebreakersList, true);
            $buchholzCutStrongest = isset($tournament->buchholz_cut_strongest) ? (int) $tournament->buchholz_cut_strongest : 0;
            $buchholzCutWeakest = isset($tournament->buchholz_cut_weakest) ? (int) $tournament->buchholz_cut_weakest : 0;
            $sbCutStrongest = isset($tournament->sb_cut_strongest) ? (int) $tournament->sb_cut_strongest : 0;
            $sbCutWeakest = isset($tournament->sb_cut_weakest) ? (int) $tournament->sb_cut_weakest : 0;
            $pointsSystem = isset($tournament->points_system) ? strtolower((string) $tournament->points_system) : 'standard';
            ?>
            <?php if (!$hasBuchholz): ?>
            <input type="hidden" name="buchholz_cut_strongest" value="0">
            <input type="hidden" name="buchholz_cut_weakest" value="0">
            <?php endif; ?>
            <?php if ($hasBuchholz): ?>
            <fieldset style="margin-top:1rem;padding:1rem;border:1px solid #c3c4c7;border-radius:4px;max-width:400px;">
                <legend><?php echo esc_html__('Buchholz — optional cut parameters', 'chess-podium'); ?></legend>
                <p class="description"><?php echo esc_html__('Exclude strongest/weakest opponent scores from the sum (Swiss Manager style).', 'chess-podium'); ?></p>
                <p>
                    <label><?php echo esc_html__('Exclude strongest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="buchholz_cut_strongest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($buchholzCutStrongest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
                <p>
                    <label><?php echo esc_html__('Exclude weakest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="buchholz_cut_weakest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($buchholzCutWeakest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
            </fieldset>
            <?php endif; ?>
            <?php if (!$hasSb): ?>
            <input type="hidden" name="sb_cut_strongest" value="0">
            <input type="hidden" name="sb_cut_weakest" value="0">
            <?php endif; ?>
            <?php if ($hasSb): ?>
            <fieldset style="margin-top:1rem;padding:1rem;border:1px solid #c3c4c7;border-radius:4px;max-width:400px;">
                <legend><?php echo esc_html__('Sonneborn–Berger — optional cut parameters', 'chess-podium'); ?></legend>
                <p class="description"><?php echo esc_html__('Exclude strongest/weakest opponent contributions from the SB sum (Swiss Manager style).', 'chess-podium'); ?></p>
                <p>
                    <label><?php echo esc_html__('Exclude strongest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="sb_cut_strongest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($sbCutStrongest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
                <p>
                    <label><?php echo esc_html__('Exclude weakest result(s)', 'chess-podium'); ?></label><br>
                    <?php foreach ([0, 1, 2] as $n): ?>
                        <label><input type="radio" name="sb_cut_weakest" value="<?php echo esc_attr((string) $n); ?>" <?php checked($sbCutWeakest, $n); ?>><?php echo esc_html((string) (int) $n); ?></label>
                    <?php endforeach; ?>
                </p>
            </fieldset>
            <?php endif; ?>
            <fieldset style="margin-top:1rem;padding:1rem;border:1px solid #c3c4c7;border-radius:4px;max-width:400px;">
                <legend><?php echo esc_html__('Points system', 'chess-podium'); ?></legend>
                <p class="description"><?php echo esc_html__('Standard: 1–0.5–0. Alternative: 3–1–0 (Swiss Manager style).', 'chess-podium'); ?></p>
                <p>
                    <label><input type="radio" name="points_system" value="standard" <?php checked($pointsSystem, 'standard'); ?>><?php echo esc_html__('Standard (1, 0.5, 0)', 'chess-podium'); ?></label><br>
                    <label><input type="radio" name="points_system" value="310" <?php checked($pointsSystem, '310'); ?>><?php echo esc_html__('3–1–0 (win, draw, loss)', 'chess-podium'); ?></label>
                </p>
            </fieldset>
            <p><button type="submit" class="button"><?php echo esc_html__('Save tiebreakers', 'chess-podium'); ?></button></p>
        </form>
        <?php self::render_tiebreaker_legend(); ?>
        <h4><?php echo esc_html__('Online registration', 'chess-podium'); ?></h4>
        <?php if (!ChessPodium_License::is_pro()): ?>
            <p class="notice notice-info inline"><?php echo esc_html__('Online registration with Stripe and PayPal is a Pro feature. Upgrade to Chess Podium Pro to enable it.', 'chess-podium'); ?></p>
        <?php else:
        $regEnabled = isset($tournament->registration_enabled) && (int) $tournament->registration_enabled === 1;
        $regFee = isset($tournament->registration_fee) ? (float) $tournament->registration_fee : 0.0;
        $regCurrency = isset($tournament->registration_currency) ? (string) $tournament->registration_currency : 'EUR';
        ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_registration_settings">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <p>
                <label><input type="checkbox" name="registration_enabled" value="1" <?php checked($regEnabled); ?>>
                    <?php esc_html_e('Enable online registration', 'chess-podium'); ?>
                </label>
            </p>
            <p class="description"><?php esc_html_e('While this option stays on, the public registration form stops accepting new sign-ups automatically as soon as round 1 is published (the tournament has started).', 'chess-podium'); ?></p>
            <p>
                <label><?php esc_html_e('Registration fee', 'chess-podium'); ?></label><br>
                <input type="number" name="registration_fee" step="0.01" min="0" value="<?php echo esc_attr($regFee); ?>" style="width:100px;"> 
                <select name="registration_currency">
                    <option value="EUR" <?php selected($regCurrency, 'EUR'); ?>>EUR</option>
                    <option value="USD" <?php selected($regCurrency, 'USD'); ?>>USD</option>
                    <option value="GBP" <?php selected($regCurrency, 'GBP'); ?>>GBP</option>
                </select>
                <span class="description"><?php esc_html_e('Set fee to 0 for free registration (no Stripe/PayPal needed).', 'chess-podium'); ?></span>
            </p>
            <p><button type="submit" class="button"><?php esc_html_e('Save registration settings', 'chess-podium'); ?></button></p>
        </form>
        <?php if ($regEnabled): ?>
            <p class="description">
                <?php esc_html_e('Registration page shortcode:', 'chess-podium'); ?>
                <code>[chess_podium_registration id="<?php echo (int) $tournament->id; ?>"]</code><br>
                <?php esc_html_e('Create a page and add this shortcode. The list of registered players is shown automatically above the form. Configure Stripe and/or PayPal in Chess Podium → Registration payments.', 'chess-podium'); ?>
            </p>
            <p class="description">
                <?php esc_html_e('Standalone list of registered players:', 'chess-podium'); ?>
                <code>[chess_podium_registered_players id="<?php echo (int) $tournament->id; ?>"]</code><br>
                <?php esc_html_e('Use this shortcode on any page to show the list of players without the registration form.', 'chess-podium'); ?>
            </p>
        <?php endif; ?>
        <?php endif; ?>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'rounds' ? 'cp-tab-active' : ''; ?>" data-panel="rounds">
        <h3><?php echo esc_html__('Rounds', 'chess-podium'); ?></h3>
        <?php if (ChessPodium_License::is_pro() && (int) $tournament->current_round > 0): ?>
            <p style="margin:0 0 1rem;">
                <a class="button" href="<?php echo esc_url(add_query_arg(['cp_tab' => 'communications', 'cp_mail_prefill' => 'pairings'], $baseUrl)); ?>"><?php echo esc_html__('Notify players (email)', 'chess-podium'); ?></a>
                <span class="description" style="margin-left:8px;"><?php echo esc_html__('Opens Communications with a pre-filled message for the current round.', 'chess-podium'); ?></span>
            </p>
        <?php endif; ?>
        <?php
        $nextRoundForStatus = (int) $tournament->current_round + 1;
        $canSetStatus = $nextRoundForStatus <= (int) $tournament->rounds_total && count($players) >= 2;
        if ($canSetStatus):
            $statusForRound = ChessPodium_PlayerStatus::get_all_for_round((int) $tournament->id, $nextRoundForStatus);
            $withdrawnBeforeRound = ChessPodium_PlayerStatus::get_withdrawn_before_round((int) $tournament->id, $nextRoundForStatus);
        ?>
        <h3><?php echo esc_html__('Bye & Withdrawals (next round)', 'chess-podium'); ?></h3>
        <p class="description"><?php echo esc_html__('Set player availability for round', 'chess-podium'); ?> <?php echo (int) $nextRoundForStatus; ?>. <?php echo esc_html__('Absent = 0.5 bye, opponent gets 1F. Bye requested = 0.5 pts. Withdrawn = excluded from pairings (persists for all future rounds).', 'chess-podium'); ?></p>
        <form method="post" class="cp-status-form">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_player_statuses">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <input type="hidden" name="status_round" value="<?php echo (int) $nextRoundForStatus; ?>">
            <table class="widefat striped" style="max-width:600px;">
                <thead><tr><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Status for round', 'chess-podium'); ?> <?php echo (int) $nextRoundForStatus; ?></th></tr></thead>
                <tbody>
                <?php foreach ($players as $p): ?>
                    <?php
                    $pid = (int) $p->id;
                    $currentStatus = $statusForRound[$pid] ?? ChessPodium_PlayerStatus::STATUS_PRESENT;
                    if (in_array($pid, $withdrawnBeforeRound, true)) {
                        $currentStatus = ChessPodium_PlayerStatus::STATUS_WITHDRAWN;
                    }
                    ?>
                    <tr>
                        <td><?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag($p->country ?? '', $p->country ?? '')); ?> <?php echo esc_html($p->name); ?></td>
                        <td>
                            <select name="player_status[<?php echo (int) $p->id; ?>]">
                                <option value="<?php echo esc_attr(ChessPodium_PlayerStatus::STATUS_PRESENT); ?>" <?php selected($currentStatus, ChessPodium_PlayerStatus::STATUS_PRESENT); ?>><?php echo esc_html__('Present', 'chess-podium'); ?></option>
                                <option value="<?php echo esc_attr(ChessPodium_PlayerStatus::STATUS_ABSENT); ?>" <?php selected($currentStatus, ChessPodium_PlayerStatus::STATUS_ABSENT); ?>><?php echo esc_html__('Absent (0.5 bye, opponent gets 1F)', 'chess-podium'); ?></option>
                                <option value="<?php echo esc_attr(ChessPodium_PlayerStatus::STATUS_BYE_REQUESTED); ?>" <?php selected($currentStatus, ChessPodium_PlayerStatus::STATUS_BYE_REQUESTED); ?>><?php echo esc_html__('Bye 0.5 pts', 'chess-podium'); ?></option>
                                <option value="<?php echo esc_attr(ChessPodium_PlayerStatus::STATUS_WITHDRAWN); ?>" <?php selected($currentStatus, ChessPodium_PlayerStatus::STATUS_WITHDRAWN); ?>><?php echo esc_html__('Withdrawn', 'chess-podium'); ?></option>
                            </select>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <p style="margin-top:8px;"><button class="button" type="submit"><?php echo esc_html__('Save statuses', 'chess-podium'); ?></button></p>
        </form>
        <?php endif; ?>

        <div class="cp-card">
            <h4><?php echo esc_html__('Generate & rollback', 'chess-podium'); ?></h4>
            <p class="description"><?php echo esc_html__('Generate pairings and print from Overview tab.', 'chess-podium'); ?></p>
            <form method="post" style="display:inline;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="rollback_round">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                <button class="button button-secondary" type="submit"
                        onclick="return confirm('<?php echo esc_js(__('Cancel current round and remove all pairings/results for this round?', 'chess-podium')); ?>');"
                    <?php disabled((int) $tournament->current_round <= 0); ?>>
                    <?php echo esc_html__('Rollback current round', 'chess-podium'); ?>
                </button>
            </form>
            <form method="post" style="display:inline;margin-left:8px;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="swap_round_colors">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                <button class="button button-secondary" type="submit"
                    <?php disabled((int) $tournament->current_round <= 0); ?>
                    title="<?php echo esc_attr__('Swap White and Black for all pairings in the current round. Use if colors were inverted by the pairing engine.', 'chess-podium'); ?>">
                    <?php echo esc_html__('Swap round colors', 'chess-podium'); ?>
                </button>
            </form>
        </div>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'content' ? 'cp-tab-active' : ''; ?>" data-panel="content">
        <h3><?php echo esc_html__('Content', 'chess-podium'); ?></h3>
        <h4><?php echo esc_html__('Tournament brochure (Bando)', 'chess-podium'); ?></h4>
        <?php
        $brochureMeta = ChessPodium_PdfBrochure::get_all_meta($tid);
        $customBrochureUrl = $brochureMeta['custom_brochure_pdf_url'] ?? '';
        $brochurePdfUrl = self::build_export_url($tid, 'brochure_pdf');
        $brochureHtmlUrl = self::build_export_url($tid, 'brochure_html');
        ?>
        <p class="description"><?php echo esc_html__('Upload a custom brochure PDF (e.g. from Vega, Swiss Manager) to use it instead of the generated one. Leave empty to build from the form below.', 'chess-podium'); ?></p>
        <form method="post" enctype="multipart/form-data" style="margin-bottom:1.5rem;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_custom_brochure">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label><?php echo esc_html__('Custom brochure PDF', 'chess-podium'); ?>:</label><br>
                <input type="url" name="custom_brochure_url" class="large-text" placeholder="https://..." value="<?php echo esc_attr($customBrochureUrl); ?>" style="max-width:400px;">
                <span class="description"><?php echo esc_html__('URL or upload below.', 'chess-podium'); ?></span><br>
                <input type="file" name="custom_brochure_file" accept=".pdf,application/pdf">
                <button type="submit" class="button"><?php echo esc_html__('Save', 'chess-podium'); ?></button>
            </p>
            <?php if (!empty($customBrochureUrl)): ?>
            <p><a href="<?php echo esc_url($customBrochureUrl); ?>" target="_blank" rel="noopener" class="button"><?php echo esc_html__('View brochure', 'chess-podium'); ?></a></p>
            <?php endif; ?>
        </form>
        <form method="post" class="cp-brochure-form" enctype="multipart/form-data">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="save_brochure">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="content">
            <table class="form-table">
                <tr>
                    <th><label for="brochure_tournament_description"><?php esc_html_e('Tournament description and highlights', 'chess-podium'); ?></label></th>
                    <td><textarea id="brochure_tournament_description" name="brochure_tournament_description" rows="5" class="large-text"><?php echo esc_textarea($brochureMeta['tournament_description'] ?? ''); ?></textarea>
                    <p class="description"><?php esc_html_e('Describe the tournament and its main attractions.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><label for="brochure_tournament_rules"><?php esc_html_e('Tournament rules', 'chess-podium'); ?></label></th>
                    <td><textarea id="brochure_tournament_rules" name="brochure_tournament_rules" rows="6" class="large-text"><?php echo esc_textarea($brochureMeta['tournament_rules'] ?? ''); ?></textarea>
                    <p class="description"><?php esc_html_e('Rules and regulations for the tournament.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><label for="brochure_logo_url"><?php esc_html_e('Tournament logo', 'chess-podium'); ?></label></th>
                    <td>
                        <input id="brochure_logo_url" name="brochure_logo_url" type="url" class="large-text" placeholder="https://..." value="<?php echo esc_attr($brochureMeta['brochure_logo'] ?? ''); ?>">
                        <p class="description"><?php esc_html_e('URL or upload below. Shown next to the tournament title.', 'chess-podium'); ?></p>
                        <p><input type="file" name="brochure_logo_file" accept="image/*"> <?php esc_html_e('or upload new image', 'chess-podium'); ?></p>
                        <?php if (!empty($brochureMeta['brochure_logo'])): ?>
                        <p><img src="<?php echo esc_url($brochureMeta['brochure_logo']); ?>" alt="" style="max-width:120px;max-height:80px;object-fit:contain;border:1px solid #ccc;"></p>
                        <?php endif; ?>
                    </td>
                </tr>
                <tr>
                    <th><label for="brochure_venue_name"><?php esc_html_e('Venue name', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_venue_name" name="brochure_venue_name" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['venue_name'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_venue_address"><?php esc_html_e('Venue address', 'chess-podium'); ?></label></th>
                    <td><textarea id="brochure_venue_address" name="brochure_venue_address" rows="3" class="large-text"><?php echo esc_textarea($brochureMeta['venue_address'] ?? ''); ?></textarea></td>
                </tr>
                <tr>
                    <th><label for="brochure_venue_map_url"><?php esc_html_e('Map URL (embed)', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_venue_map_url" name="brochure_venue_map_url" type="url" class="large-text" placeholder="https://www.openstreetmap.org/..." value="<?php echo esc_attr($brochureMeta['venue_map_url'] ?? ''); ?>">
                    <p class="description"><?php esc_html_e('OpenStreetMap or Google Maps embed URL.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><?php esc_html_e('Venue photos', 'chess-podium'); ?></th>
                    <td>
                        <div id="cp-brochure-venue-photos">
                            <?php
                            $venuePhotos = isset($brochureMeta['brochure_venue_photos']) ? json_decode($brochureMeta['brochure_venue_photos'], true) : [];
                            if (!is_array($venuePhotos)) {
                                $venuePhotos = [];
                            }
                            foreach ($venuePhotos as $url):
                                if (empty($url) || !is_string($url)) {
                                    continue;
                                }
                            ?>
                            <p class="cp-venue-photo-row" style="display:flex;align-items:center;gap:8px;margin:4px 0;">
                                <img src="<?php echo esc_url($url); ?>" alt="" style="max-width:80px;max-height:60px;object-fit:cover;">
                                <input type="hidden" name="brochure_venue_photos_urls[]" value="<?php echo esc_attr($url); ?>">
                                <button type="button" class="button button-small cp-remove-venue-photo"><?php esc_html_e('Remove', 'chess-podium'); ?></button>
                            </p>
                            <?php endforeach; ?>
                        </div>
                        <p><input type="file" name="brochure_venue_photos[]" accept="image/*" multiple> <?php esc_html_e('Add venue photos', 'chess-podium'); ?></p>
                        <p class="description"><?php esc_html_e('Photos of the playing venue for the brochure.', 'chess-podium'); ?></p>
                    </td>
                </tr>
                <tr>
                    <th><label for="brochure_start_date"><?php esc_html_e('Start date', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_start_date" name="brochure_start_date" type="date" value="<?php echo esc_attr($brochureMeta['start_date'] ?? ''); ?>">
                    <p class="description"><?php esc_html_e('Optional. Used as fallback if round dates are not set manually. Also used for the public events calendar ([chess_podium_events]) when you save this brochure.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><label for="brochure_end_date"><?php esc_html_e('End date', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_end_date" name="brochure_end_date" type="date" value="<?php echo esc_attr($brochureMeta['end_date'] ?? ''); ?>">
                    <p class="description"><?php esc_html_e('Optional. For multi-day tournaments, USCF/ECF export, and the public events calendar.', 'chess-podium'); ?></p></td>
                </tr>
            </table>
            <?php
            $roundsTotal = (int) $tournament->rounds_total;
            $roundDatesMeta = isset($brochureMeta['round_dates']) ? json_decode($brochureMeta['round_dates'], true) : [];
            $roundTimesMeta = isset($brochureMeta['round_times']) ? json_decode($brochureMeta['round_times'], true) : [];
            if (!is_array($roundDatesMeta)) {
                $roundDatesMeta = [];
            }
            if (!is_array($roundTimesMeta)) {
                $roundTimesMeta = [];
            }
            if ($roundsTotal > 0):
            ?>
            <h4><?php esc_html_e('Round dates and start times (manual)', 'chess-podium'); ?></h4>
            <p class="description"><?php esc_html_e('Enter the date and start time for each round. Each tournament may have different schedules.', 'chess-podium'); ?></p>
            <table class="form-table" style="max-width:500px;">
                <thead><tr><th><?php esc_html_e('Round', 'chess-podium'); ?></th><th><?php esc_html_e('Date', 'chess-podium'); ?></th><th><?php esc_html_e('Start time', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php for ($r = 1; $r <= $roundsTotal; $r++): ?>
                <tr>
                    <td><?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), $r)); ?></td>
                    <td><input id="brochure_round_date_<?php echo esc_attr((string) $r); ?>" name="brochure_round_date[<?php echo esc_attr((string) $r); ?>]" type="date" value="<?php echo esc_attr($roundDatesMeta[$r] ?? ''); ?>"></td>
                    <td><input id="brochure_round_time_<?php echo esc_attr((string) $r); ?>" name="brochure_round_time[<?php echo esc_attr((string) $r); ?>]" type="time" value="<?php echo esc_attr($roundTimesMeta[$r] ?? ''); ?>" placeholder="20:00"></td>
                </tr>
                <?php endfor; ?>
                </tbody>
            </table>
            <?php endif; ?>
            <h4><?php esc_html_e('Time control', 'chess-podium'); ?></h4>
            <table class="form-table">
                <tr>
                    <th><label for="brochure_time_control"><?php esc_html_e('Reflection time / Time control', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_time_control" name="brochure_time_control" type="text" class="regular-text" placeholder="<?php esc_attr_e('e.g. 90+30, 60 min + 30 sec/move', 'chess-podium'); ?>" value="<?php echo esc_attr($brochureMeta['time_control'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_time_control_type"><?php esc_html_e('Time control type', 'chess-podium'); ?></label></th>
                    <td>
                        <select id="brochure_time_control_type" name="brochure_time_control_type">
                            <option value=""><?php echo esc_html__('— Not specified —', 'chess-podium'); ?></option>
                            <option value="delay" <?php selected($brochureMeta['time_control_type'] ?? '', 'delay'); ?>><?php echo esc_html__('Delay (Bronstein/Fischer)', 'chess-podium'); ?></option>
                            <option value="increment" <?php selected($brochureMeta['time_control_type'] ?? '', 'increment'); ?>><?php echo esc_html__('Increment', 'chess-podium'); ?></option>
                        </select>
                        <p class="description"><?php echo esc_html__('Delay or increment for brochure display.', 'chess-podium'); ?></p>
                    </td>
                </tr>
            </table>
            <h4><?php echo esc_html__('USCF export', 'chess-podium'); ?></h4>
            <table class="form-table">
                <tr>
                    <th><label for="brochure_uscf_city"><?php esc_html_e('City', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_uscf_city" name="brochure_uscf_city" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['uscf_city'] ?? ''); ?>" placeholder="<?php echo esc_attr($brochureMeta['venue_name'] ?? ''); ?>">
                    <p class="description"><?php esc_html_e('City for USCF tournament report. Falls back to venue if empty.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><label for="brochure_uscf_td_code"><?php esc_html_e('TD code', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_uscf_td_code" name="brochure_uscf_td_code" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['uscf_td_code'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_uscf_affiliate"><?php esc_html_e('Affiliate', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_uscf_affiliate" name="brochure_uscf_affiliate" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['uscf_affiliate'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_uscf_state"><?php esc_html_e('State', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_uscf_state" name="brochure_uscf_state" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['uscf_state'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_uscf_zip"><?php esc_html_e('ZIP code', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_uscf_zip" name="brochure_uscf_zip" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['uscf_zip'] ?? ''); ?>">
                    <p class="description"><?php esc_html_e('Required for USCF tournament reports. Shown in brochure.', 'chess-podium'); ?></p></td>
                </tr>
            </table>
            <h4><?php esc_html_e('Registration fees', 'chess-podium'); ?></h4>
            <table class="form-table">
                <tr>
                    <th><label for="brochure_fee_titled"><?php esc_html_e('Titled players', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_fee_titled" name="brochure_fee_titled" type="text" class="regular-text" placeholder="<?php esc_attr_e('e.g. €15', 'chess-podium'); ?>" value="<?php echo esc_attr($brochureMeta['fee_titled'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_fee_normal"><?php esc_html_e('Standard fee', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_fee_normal" name="brochure_fee_normal" type="text" class="regular-text" placeholder="<?php esc_attr_e('e.g. €20', 'chess-podium'); ?>" value="<?php echo esc_attr($brochureMeta['fee_normal'] ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label for="brochure_fee_under18"><?php esc_html_e('Under 18', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_fee_under18" name="brochure_fee_under18" type="text" class="regular-text" placeholder="<?php esc_attr_e('e.g. €10', 'chess-podium'); ?>" value="<?php echo esc_attr($brochureMeta['fee_under18'] ?? ''); ?>"></td>
                </tr>
            </table>
            <h4><?php esc_html_e('Officials', 'chess-podium'); ?></h4>
            <table class="form-table">
                <tr>
                    <th><label for="brochure_chief_arbiter"><?php esc_html_e('Chief arbiter', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_chief_arbiter" name="brochure_chief_arbiter" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['chief_arbiter'] ?? $tournament->chief_arbiter ?? ''); ?>"></td>
                </tr>
                <tr>
                    <th><label><?php esc_html_e('Deputy chief arbiter(s)', 'chess-podium'); ?></label></th>
                    <td>
                        <div id="cp-brochure-deputy-arbiters">
                            <?php
                            $deputyArbiters = isset($brochureMeta['deputy_arbiters']) ? json_decode($brochureMeta['deputy_arbiters'], true) : [];
                            if (empty($deputyArbiters) && !empty($tournament->deputy_arbiter)) {
                                $deputyArbiters = [(string) $tournament->deputy_arbiter];
                            }
                            if (empty($deputyArbiters)) {
                                $deputyArbiters = [''];
                            }
                            foreach ($deputyArbiters as $i => $name):
                            ?>
                            <p><input type="text" name="brochure_deputy_arbiter[]" placeholder="<?php esc_attr_e('Deputy chief arbiter', 'chess-podium'); ?>" value="<?php echo esc_attr($name); ?>" style="width:280px;"></p>
                            <?php endforeach; ?>
                        </div>
                        <p><button type="button" class="button button-small cp-add-deputy-arbiter"><?php esc_html_e('Add deputy arbiter', 'chess-podium'); ?></button></p>
                    </td>
                </tr>
                <tr>
                    <th><label for="brochure_tournament_director"><?php esc_html_e('Tournament director', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_tournament_director" name="brochure_tournament_director" type="text" class="regular-text" value="<?php echo esc_attr($brochureMeta['tournament_director'] ?? ''); ?>"></td>
                </tr>
            </table>
            <h4><?php esc_html_e('Tournament info', 'chess-podium'); ?></h4>
            <table class="form-table">
                <tr>
                    <th><label for="brochure_fide_approved"><?php esc_html_e('FIDE approved tournament', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_fide_approved" name="brochure_fide_approved" type="checkbox" value="1" <?php checked(!empty($brochureMeta['fide_approved'])); ?>> <label for="brochure_fide_approved"><?php esc_html_e('Yes', 'chess-podium'); ?></label></td>
                </tr>
                <tr>
                    <th><label for="brochure_fide_game_type"><?php esc_html_e('Game type', 'chess-podium'); ?></label></th>
                    <td>
                        <select id="brochure_fide_game_type" name="brochure_fide_game_type">
                            <option value=""><?php esc_html_e('— Select —', 'chess-podium'); ?></option>
                            <option value="standard" <?php selected($brochureMeta['fide_game_type'] ?? '', 'standard'); ?>><?php esc_html_e('Standard', 'chess-podium'); ?></option>
                            <option value="rapid" <?php selected($brochureMeta['fide_game_type'] ?? '', 'rapid'); ?>><?php esc_html_e('Rapid', 'chess-podium'); ?></option>
                            <option value="blitz" <?php selected($brochureMeta['fide_game_type'] ?? '', 'blitz'); ?>><?php esc_html_e('Blitz', 'chess-podium'); ?></option>
                        </select>
                        <span class="description"><?php esc_html_e('Standard, Rapid, or Blitz', 'chess-podium'); ?></span>
                    </td>
                </tr>
                <tr>
                    <th><label for="brochure_wildcards_count"><?php esc_html_e('Number of wildcards', 'chess-podium'); ?></label></th>
                    <td><input id="brochure_wildcards_count" name="brochure_wildcards_count" type="number" min="0" value="<?php echo esc_attr($brochureMeta['wildcards_count'] ?? '0'); ?>" style="width:80px;"></td>
                </tr>
            </table>
            <h4><?php esc_html_e('Prize table', 'chess-podium'); ?></h4>
            <div id="cp-brochure-prizes">
                <p class="description"><?php esc_html_e('Category: General (overall), Age (e.g. U18, U12), or Rating (e.g. U2000, U1800).', 'chess-podium'); ?></p>
                <?php
                $prizes = isset($brochureMeta['prize_table']) ? json_decode($brochureMeta['prize_table'], true) : [];
                if (empty($prizes)) {
                    $prizes = [['position' => '1°', 'prize' => '', 'category' => 'general', 'age_range' => '', 'rating_range' => ''], ['position' => '2°', 'prize' => '', 'category' => 'general', 'age_range' => '', 'rating_range' => ''], ['position' => '3°', 'prize' => '', 'category' => 'general', 'age_range' => '', 'rating_range' => '']];
                }
                foreach ($prizes as $i => $p):
                    $cat = $p['category'] ?? 'general';
                ?>
                <p>
                    <select name="brochure_prize[<?php echo esc_attr((string) $i); ?>][category]" style="width:100px;">
                        <option value="general" <?php selected($cat, 'general'); ?>><?php esc_html_e('General', 'chess-podium'); ?></option>
                        <option value="age" <?php selected($cat, 'age'); ?>><?php esc_html_e('Age group', 'chess-podium'); ?></option>
                        <option value="rating" <?php selected($cat, 'rating'); ?>><?php esc_html_e('Rating band', 'chess-podium'); ?></option>
                    </select>
                    <input type="text" name="brochure_prize[<?php echo esc_attr((string) $i); ?>][position]" placeholder="<?php esc_attr_e('Position', 'chess-podium'); ?>" value="<?php echo esc_attr($p['position'] ?? ''); ?>" style="width:70px;">
                    <input type="text" name="brochure_prize[<?php echo esc_attr((string) $i); ?>][age_range]" placeholder="<?php esc_attr_e('e.g. U18', 'chess-podium'); ?>" value="<?php echo esc_attr($p['age_range'] ?? ''); ?>" style="width:70px;" title="<?php esc_attr_e('Age group', 'chess-podium'); ?>">
                    <input type="text" name="brochure_prize[<?php echo esc_attr((string) $i); ?>][rating_range]" placeholder="<?php esc_attr_e('e.g. U2000', 'chess-podium'); ?>" value="<?php echo esc_attr($p['rating_range'] ?? ''); ?>" style="width:80px;" title="<?php esc_attr_e('Rating band', 'chess-podium'); ?>">
                    <input type="text" name="brochure_prize[<?php echo esc_attr((string) $i); ?>][prize]" placeholder="<?php esc_attr_e('Prize', 'chess-podium'); ?>" value="<?php echo esc_attr($p['prize'] ?? ''); ?>" style="width:180px;">
                </p>
                <?php endforeach; ?>
                <p><button type="button" class="button button-small cp-add-prize"><?php esc_html_e('Add row', 'chess-podium'); ?></button></p>
            </div>
            <h4><?php esc_html_e('Contacts', 'chess-podium'); ?></h4>
            <div id="cp-brochure-contacts">
                <?php
                $contacts = isset($brochureMeta['contacts']) ? json_decode($brochureMeta['contacts'], true) : [];
                if (empty($contacts)) {
                    $contacts = [['name' => '', 'email' => '', 'phone' => '']];
                }
                foreach ($contacts as $i => $c):
                ?>
                <p><input type="text" name="brochure_contacts[<?php echo esc_attr((string) $i); ?>][name]" placeholder="<?php esc_attr_e('Name', 'chess-podium'); ?>" value="<?php echo esc_attr($c['name'] ?? ''); ?>" style="width:150px;">
                <input type="email" name="brochure_contacts[<?php echo esc_attr((string) $i); ?>][email]" placeholder="<?php esc_attr_e('Email', 'chess-podium'); ?>" value="<?php echo esc_attr($c['email'] ?? ''); ?>" style="width:180px;">
                <input type="text" name="brochure_contacts[<?php echo esc_attr((string) $i); ?>][phone]" placeholder="<?php esc_attr_e('Phone', 'chess-podium'); ?>" value="<?php echo esc_attr($c['phone'] ?? ''); ?>" style="width:120px;"></p>
                <?php endforeach; ?>
                <p><button type="button" class="button button-small cp-add-contact"><?php esc_html_e('Add contact', 'chess-podium'); ?></button></p>
            </div>
            <?php
            $calVisBro = isset($tournament->calendar_visible) ? (int) $tournament->calendar_visible : 1;
            ?>
            <input type="hidden" name="calendar_visible_present" value="1">
            <p><label><input type="checkbox" name="calendar_visible" value="1" <?php checked($calVisBro, 1); ?>> <?php esc_html_e('Show this tournament on the public events calendar', 'chess-podium'); ?></label></p>
            <p class="description"><?php esc_html_e('Saving the brochure updates the tournament row dates used by [chess_podium_events]. Uncheck to hide the event from the calendar without deleting the tournament.', 'chess-podium'); ?></p>
            <p><button class="button button-primary" type="submit"><?php esc_html_e('Save brochure', 'chess-podium'); ?></button></p>
        </form>
        <p>
            <a class="button" href="<?php echo esc_url($brochureHtmlUrl); ?>"><?php esc_html_e('Download HTML', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($brochurePdfUrl); ?>"><?php esc_html_e('Download PDF', 'chess-podium'); ?></a>
        </p>

        <h3><?php echo esc_html__('Live mode (projection)', 'chess-podium'); ?></h3>
        <?php
        $liveUrl = self::get_live_url($tid);
        ?>
        <p><a class="button" href="<?php echo esc_url($liveUrl); ?>" target="_blank"><?php esc_html_e('Open Live Dashboard', 'chess-podium'); ?></a></p>
        <p class="description"><?php esc_html_e('Full-screen page for projection in the playing hall. Auto-refreshes every 10 seconds when results are updated.', 'chess-podium'); ?></p>

        <h3><?php echo esc_html__('Photo gallery', 'chess-podium'); ?></h3>
        <form method="post" enctype="multipart/form-data">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_photo">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <strong><?php echo esc_html__('Single upload', 'chess-podium'); ?>:</strong>
                <input type="file" name="photo_file" accept="image/*">
                <strong><?php echo esc_html__('or bulk (multiple files)', 'chess-podium'); ?>:</strong>
                <input type="file" name="photo_files[]" accept="image/*" multiple>
                <strong><?php echo esc_html__('or URL', 'chess-podium'); ?>:</strong>
                <input type="url" name="photo_url" placeholder="https://..." class="regular-text" style="max-width:300px;">
                <input type="text" name="photo_caption" placeholder="<?php echo esc_attr__('Caption (optional)', 'chess-podium'); ?>" class="regular-text">
                <button class="button" type="submit"><?php echo esc_html__('Add photo', 'chess-podium'); ?></button>
            </p>
            <p class="description"><?php echo esc_html__('Hold Ctrl (Windows) or Cmd (Mac) while selecting multiple images. Caption applies to all photos in bulk upload.', 'chess-podium'); ?></p>
        </form>
        <?php if (!empty($photos)): ?>
        <p class="description"><?php echo esc_html__('Select a photo to enlarge and browse the gallery.', 'chess-podium'); ?></p>
        <div class="cp-admin-gallery" style="display:flex;flex-wrap:wrap;gap:12px;margin-top:12px;">
            <?php foreach ($photos as $i => $ph): ?>
            <div style="border:1px solid #ddd;padding:8px;max-width:200px;">
                <img src="<?php echo esc_url($ph->photo_url); ?>" alt="<?php echo esc_attr($ph->caption ?? ''); ?>" class="cp-gallery-thumb" data-index="<?php echo esc_attr((string) $i); ?>" data-src="<?php echo esc_url($ph->photo_url); ?>" data-caption="<?php echo esc_attr($ph->caption ?? ''); ?>" style="max-width:100%;height:120px;object-fit:cover;cursor:pointer;">
                <p style="font-size:12px;margin:4px 0;"><?php echo esc_html($ph->caption ?? ''); ?></p>
                <form method="post" style="margin:0;">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="delete_photo">
                    <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                    <input type="hidden" name="photo_id" value="<?php echo (int) $ph->id; ?>">
                    <button class="button button-small button-link-delete" type="submit"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                </form>
            </div>
            <?php endforeach; ?>
        </div>
        <div id="cp-admin-lightbox" class="cp-lightbox" style="display:none;position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.9);align-items:center;justify-content:center;">
            <button type="button" class="cp-lightbox-close" style="position:absolute;top:1rem;right:1rem;background:rgba(255,255,255,0.2);color:#fff;border:none;font-size:2rem;cursor:pointer;padding:0.5rem 1rem;">&times;</button>
            <button type="button" class="cp-lightbox-prev" style="position:absolute;left:1rem;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.2);color:#fff;border:none;font-size:2rem;cursor:pointer;padding:0.5rem 1rem;">&larr;</button>
            <button type="button" class="cp-lightbox-next" style="position:absolute;right:1rem;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.2);color:#fff;border:none;font-size:2rem;cursor:pointer;padding:0.5rem 1rem;">&rarr;</button>
            <div style="max-width:95vw;max-height:95vh;text-align:center;">
                <img id="cp-admin-lightbox-img" src="" alt="" style="max-width:95vw;max-height:85vh;object-fit:contain;">
                <p id="cp-admin-lightbox-caption" style="color:#fff;padding:1rem;"></p>
            </div>
        </div>
        <?php endif; ?>

        <h3><?php echo esc_html__('PGN games', 'chess-podium'); ?></h3>
        <p><?php echo esc_html__('Upload PGN file or paste content. Games will be viewable on the published page.', 'chess-podium'); ?></p>
        <form method="post" enctype="multipart/form-data">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_pgn">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label><?php echo esc_html__('Round', 'chess-podium'); ?>: <input type="number" name="pgn_round" min="1" value="1" style="width:60px;"></label>
                <strong><?php echo esc_html__('Single upload', 'chess-podium'); ?>:</strong>
                <input type="file" name="pgn_file" accept=".pgn">
                <strong><?php echo esc_html__('or bulk (multiple files)', 'chess-podium'); ?>:</strong>
                <input type="file" name="pgn_files[]" accept=".pgn" multiple>
                <button class="button" type="submit"><?php echo esc_html__('Upload PGN', 'chess-podium'); ?></button>
            </p>
            <p class="description"><?php echo esc_html__('Hold Ctrl (Windows) or Cmd (Mac) while selecting multiple PGN files. Rounds are auto-detected from player names and tournament pairings; fallback round for unmatched games.', 'chess-podium'); ?></p>
        </form>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_pgn">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p>
                <label><?php echo esc_html__('Round', 'chess-podium'); ?>: <input type="number" name="pgn_round" min="1" value="1" style="width:60px;"></label>
                <label><?php echo esc_html__('White', 'chess-podium'); ?>: <input type="text" name="pgn_white" placeholder="<?php echo esc_attr__('Name', 'chess-podium'); ?>"></label>
                <label><?php echo esc_html__('Black', 'chess-podium'); ?>: <input type="text" name="pgn_black" placeholder="<?php echo esc_attr__('Name', 'chess-podium'); ?>"></label>
                <label><?php echo esc_html__('Result', 'chess-podium'); ?>: <select name="pgn_result"><option value="">-</option><option value="1-0">1-0</option><option value="0-1">0-1</option><option value="1/2-1/2">1/2-1/2</option><option value="1F"><?php echo esc_html__('1-0F (White wins by forfeit)', 'chess-podium'); ?></option><option value="0F"><?php echo esc_html__('0-1F (Black wins by forfeit)', 'chess-podium'); ?></option></select></label>
            </p>
            <p><textarea name="pgn_content" rows="6" class="large-text" placeholder="<?php echo esc_attr__('Paste PGN here...', 'chess-podium'); ?>"></textarea></p>
            <p><button class="button" type="submit"><?php echo esc_html__('Add game', 'chess-podium'); ?></button></p>
        </form>
        <?php if (!empty($pgns)): ?>
        <p><strong><?php echo esc_html__('Loaded games', 'chess-podium'); ?>:</strong>
            <form method="post" style="display:inline;margin-left:0.5rem;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="delete_all_pgns">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <button class="button button-small button-link-delete" type="submit" onclick="return confirm('<?php echo esc_js(__('Remove all PGN games?', 'chess-podium')); ?>');"><?php echo esc_html__('Remove all PGN games', 'chess-podium'); ?></button>
            </form>
        </p>
        <form method="post" id="cp-bulk-pgn-rounds">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="bulk_update_pgn_rounds">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <p><button class="button button-primary" type="submit"><?php echo esc_html__('Update all rounds', 'chess-podium'); ?></button></p>
        </form>
        <ul>
            <?php foreach ($pgns as $pgn): ?>
            <li>
                <label><?php echo esc_html__('Round', 'chess-podium'); ?>: <input type="number" name="pgn_rounds[<?php echo (int) $pgn->id; ?>]" form="cp-bulk-pgn-rounds" min="1" value="<?php echo (int) $pgn->round_no; ?>" style="width:50px;"></label>
                <?php echo esc_html($pgn->white_name ?? '?'); ?> - <?php echo esc_html($pgn->black_name ?? '?'); ?> (<?php echo esc_html($pgn->result ?? '-'); ?>)
                <form method="post" style="display:inline;">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="delete_pgn">
                    <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                    <input type="hidden" name="pgn_id" value="<?php echo (int) $pgn->id; ?>">
                    <button class="button button-small button-link-delete" type="submit"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                </form>
            </li>
            <?php endforeach; ?>
        </ul>
        <p><button class="button button-primary" type="submit" form="cp-bulk-pgn-rounds"><?php echo esc_html__('Update all rounds', 'chess-podium'); ?></button></p>
        <?php endif; ?>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'budget' ? 'cp-tab-active' : ''; ?>" data-panel="budget">
        <h3><?php echo esc_html__('Budget (income & expenses)', 'chess-podium'); ?></h3>
        <p class="description"><?php echo esc_html__('Track income and expenses for this tournament. For organizers only.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_budget_entry">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <table class="form-table">
                <tr>
                    <th><label for="budget_date"><?php echo esc_html__('Date', 'chess-podium'); ?></label></th>
                    <td><input id="budget_date" name="budget_date" type="date" value="<?php echo esc_attr(date('Y-m-d')); ?>" required></td>
                </tr>
                <tr>
                    <th><label for="budget_description"><?php echo esc_html__('Description', 'chess-podium'); ?></label></th>
                    <td><input id="budget_description" name="budget_description" type="text" class="large-text" placeholder="<?php echo esc_attr__('e.g. Registration fees, Venue rental', 'chess-podium'); ?>" required></td>
                </tr>
                <tr>
                    <th><label for="budget_amount"><?php echo esc_html__('Amount', 'chess-podium'); ?></label></th>
                    <td><input id="budget_amount" name="budget_amount" type="number" step="0.01" required style="width:120px;"> <select name="budget_type"><option value="income"><?php echo esc_html__('Income', 'chess-podium'); ?></option><option value="expense"><?php echo esc_html__('Expense', 'chess-podium'); ?></option></select></td>
                </tr>
            </table>
            <p><button class="button" type="submit"><?php echo esc_html__('Add entry', 'chess-podium'); ?></button></p>
        </form>
        <?php
        $budgetEntries = self::get_budget_entries($tid);
        $totalIncome = 0.0;
        $totalExpense = 0.0;
        foreach ($budgetEntries as $e) {
            $amt = (float) $e->amount;
            if ($e->type === 'income') {
                $totalIncome += $amt;
            } else {
                $totalExpense += $amt;
            }
        }
        $balance = $totalIncome - $totalExpense;
        ?>
        <h4><?php echo esc_html__('Entries', 'chess-podium'); ?></h4>
        <?php if (empty($budgetEntries)): ?>
            <p><?php echo esc_html__('No entries yet.', 'chess-podium'); ?></p>
        <?php else: ?>
            <table class="widefat striped">
                <thead><tr><th><?php echo esc_html__('Date', 'chess-podium'); ?></th><th><?php echo esc_html__('Description', 'chess-podium'); ?></th><th><?php echo esc_html__('Type', 'chess-podium'); ?></th><th><?php echo esc_html__('Amount', 'chess-podium'); ?></th><th></th></tr></thead>
                <tbody>
                <?php foreach ($budgetEntries as $e): ?>
                    <tr>
                        <td><?php echo esc_html($e->entry_date); ?></td>
                        <td><?php echo esc_html($e->description); ?></td>
                        <td><?php echo $e->type === 'income' ? esc_html__('Income', 'chess-podium') : esc_html__('Expense', 'chess-podium'); ?></td>
                        <td><?php echo esc_html(number_format((float) $e->amount, 2)); ?></td>
                        <td>
                            <form method="post" style="display:inline;" onsubmit="return confirm('<?php echo esc_js(__('Delete this entry?', 'chess-podium')); ?>');">
                                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                                <input type="hidden" name="regina_action" value="delete_budget_entry">
                                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                                <input type="hidden" name="budget_entry_id" value="<?php echo (int) $e->id; ?>">
                                <button type="submit" class="button button-small button-link-delete"><?php echo esc_html__('Delete', 'chess-podium'); ?></button>
                            </form>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <p><strong><?php echo esc_html__('Total income', 'chess-podium'); ?>:</strong> <?php echo esc_html(number_format($totalIncome, 2)); ?> &ndash; <strong><?php echo esc_html__('Total expenses', 'chess-podium'); ?>:</strong> <?php echo esc_html(number_format($totalExpense, 2)); ?> &ndash; <strong><?php echo esc_html__('Balance', 'chess-podium'); ?>:</strong> <?php echo esc_html(number_format($balance, 2)); ?></p>
        <?php endif; ?>

        <?php
        $partnerHotels = self::get_partner_hotels($tid);
        $playersForHotel = self::get_players($tid);
        $hotelStaysByPlayer = self::get_player_hotel_stays_indexed($tid);
        $hotelRateById = [];
        foreach ($partnerHotels as $h) {
            $hid = (int) ($h->id ?? 0);
            if ($hid > 0) {
                $hotelRateById[$hid] = (float) ($h->rate_per_presence_per_day ?? 0);
            }
        }
        ?>

        <hr style="margin:1.5rem 0;">
        <h3><?php echo esc_html__('Partner hotels', 'chess-podium'); ?></h3>
        <?php
        $hotelReportCsvUrl = self::build_export_url($tid, 'hotel_report_csv');
        $hotelReportPdfUrl = self::build_export_url($tid, 'hotel_report_pdf');
        ?>
        <p class="description"><?php echo esc_html__('Add hotels affiliated with the tournament and track the tournament share per player stay.', 'chess-podium'); ?></p>
        <p>
            <a class="button" href="<?php echo esc_url($hotelReportCsvUrl); ?>"><?php echo esc_html__('Export hotels (CSV)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($hotelReportPdfUrl); ?>"><?php echo esc_html__('Export hotels (PDF)', 'chess-podium'); ?></a>
        </p>

        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_partner_hotel">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <table class="form-table">
                <tr>
                    <th><label for="hotel_name"><?php echo esc_html__('Hotel name', 'chess-podium'); ?></label></th>
                    <td><input id="hotel_name" name="hotel_name" type="text" class="regular-text" required></td>
                </tr>
                <tr>
                    <th><label for="hotel_city"><?php echo esc_html__('City', 'chess-podium'); ?></label></th>
                    <td><input id="hotel_city" name="hotel_city" type="text" class="regular-text" placeholder="<?php echo esc_attr__('optional', 'chess-podium'); ?>"></td>
                </tr>
                <tr>
                    <th><label for="hotel_stars"><?php echo esc_html__('Stars', 'chess-podium'); ?></label></th>
                    <td><input id="hotel_stars" name="hotel_stars" type="number" min="0" max="5" value="0" style="width:70px;"> <span class="description"><?php echo esc_html__('0 = not specified', 'chess-podium'); ?></span></td>
                </tr>
                <tr>
                    <th><label for="hotel_rate"><?php echo esc_html__('Tournament share per presence/day', 'chess-podium'); ?></label></th>
                    <td>
                        <input id="hotel_rate" name="hotel_rate_per_presence_per_day" type="number" step="0.01" min="0" max="9999" value="0" style="width:120px;">
                        <span class="description"><?php echo esc_html__('Example: 5.00 = €5 per person per day (player + family).', 'chess-podium'); ?></span>
                    </td>
                </tr>
                <tr>
                    <th><label for="hotel_notes"><?php echo esc_html__('Notes', 'chess-podium'); ?></label></th>
                    <td><input id="hotel_notes" name="hotel_notes" type="text" class="large-text" placeholder="<?php echo esc_attr__('optional', 'chess-podium'); ?>"></td>
                </tr>
            </table>
            <p><button class="button" type="submit"><?php echo esc_html__('Add partner hotel', 'chess-podium'); ?></button></p>
        </form>

        <h4><?php echo esc_html__('Hotels', 'chess-podium'); ?></h4>
        <?php if (empty($partnerHotels)): ?>
            <p><?php echo esc_html__('No partner hotels yet.', 'chess-podium'); ?></p>
        <?php else: ?>
            <table class="widefat striped">
                <thead>
                    <tr>
                        <th><?php echo esc_html__('Hotel', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('Rate (€/presence/day)', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('Notes', 'chess-podium'); ?></th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>
                <?php foreach ($partnerHotels as $h): ?>
                    <tr>
                        <td>
                            <strong><?php echo esc_html($h->name ?? ''); ?></strong>
                            <?php if (!empty($h->stars)): ?> (<?php echo (int) $h->stars; ?>★)<?php endif; ?>
                            <?php if (!empty($h->city)): ?> — <?php echo esc_html($h->city); ?><?php endif; ?>
                        </td>
                        <td><?php echo esc_html(number_format((float) ($h->rate_per_presence_per_day ?? 0), 2)); ?></td>
                        <td><?php echo esc_html($h->notes ?? ''); ?></td>
                        <td>
                            <form method="post" style="display:inline;" onsubmit="return confirm('<?php echo esc_js(__('Delete this hotel? This will also clear player assignments.', 'chess-podium')); ?>');">
                                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                                <input type="hidden" name="regina_action" value="delete_partner_hotel">
                                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                                <input type="hidden" name="hotel_id" value="<?php echo (int) ($h->id ?? 0); ?>">
                                <button type="submit" class="button button-small button-link-delete"><?php echo esc_html__('Delete', 'chess-podium'); ?></button>
                            </form>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>

        <h4 style="margin-top:1.25rem;"><?php echo esc_html__('Player stays (partner hotels)', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Select the partner hotel for each player. Tournament share is auto-calculated as rate × presences × days (presences = player + family). You can override the share manually if needed.', 'chess-podium'); ?></p>

        <?php if (empty($playersForHotel)): ?>
            <p><?php echo esc_html__('No players yet.', 'chess-podium'); ?></p>
        <?php else: ?>
            <form method="post" id="cp-hotel-stays-form">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="save_player_hotel_stays">
                <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
                <table class="widefat striped">
                    <thead>
                        <tr>
                            <th><?php echo esc_html__('Player', 'chess-podium'); ?></th>
                            <th><?php echo esc_html__('Hotel', 'chess-podium'); ?></th>
                            <th><?php echo esc_html__('Family members', 'chess-podium'); ?></th>
                            <th><?php echo esc_html__('Days', 'chess-podium'); ?></th>
                            <th><?php echo esc_html__('Hotel total (€)', 'chess-podium'); ?></th>
                            <th><?php echo esc_html__('Tournament share (€)', 'chess-podium'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                    <?php foreach ($playersForHotel as $p): ?>
                        <?php
                        $pid = (int) ($p->id ?? 0);
                        $stay = $hotelStaysByPlayer[$pid] ?? null;
                        $hid = $stay ? (int) ($stay->hotel_id ?? 0) : 0;
                        $family = $stay ? (int) ($stay->family_members ?? 0) : 0;
                        $days = $stay ? (int) ($stay->days ?? 0) : 0;
                        $hotelTotal = $stay ? (float) ($stay->hotel_total ?? 0) : 0.0;
                        $share = $stay ? (float) ($stay->share_amount ?? 0) : 0.0;
                        ?>
                        <tr class="cp-hotel-stay-row">
                            <td><strong><?php echo esc_html($p->name ?? ''); ?></strong></td>
                            <td>
                                <input type="hidden" name="hotel_stays[<?php echo $pid; ?>][player_id]" value="<?php echo $pid; ?>">
                                <select class="cp-hotel-id" name="hotel_stays[<?php echo $pid; ?>][hotel_id]">
                                    <option value="0"><?php echo esc_html__('— none —', 'chess-podium'); ?></option>
                                    <?php foreach ($partnerHotels as $h): ?>
                                        <option value="<?php echo (int) ($h->id ?? 0); ?>" <?php selected($hid, (int) ($h->id ?? 0)); ?>>
                                            <?php echo esc_html($h->name ?? ''); ?>
                                        </option>
                                    <?php endforeach; ?>
                                </select>
                            </td>
                            <td><input class="cp-hotel-family" type="number" min="0" max="20" name="hotel_stays[<?php echo $pid; ?>][family_members]" value="<?php echo esc_attr((string) $family); ?>" style="width:90px;"></td>
                            <td><input class="cp-hotel-days" type="number" min="0" max="365" name="hotel_stays[<?php echo $pid; ?>][days]" value="<?php echo esc_attr((string) $days); ?>" style="width:70px;"></td>
                            <td><input type="number" step="0.01" min="0" max="999999" name="hotel_stays[<?php echo $pid; ?>][hotel_total]" value="<?php echo esc_attr((string) number_format($hotelTotal, 2, '.', '')); ?>" style="width:120px;"></td>
                            <td>
                                <input class="cp-hotel-share" type="number" step="0.01" min="0" max="999999" name="hotel_stays[<?php echo $pid; ?>][share_amount]" value="<?php echo esc_attr((string) number_format($share, 2, '.', '')); ?>" style="width:120px;">
                                <button type="button" class="button button-small cp-hotel-recalc" style="margin-left:6px;"><?php echo esc_html__('Recalc', 'chess-podium'); ?></button>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
                <p style="margin-top:1rem;"><button class="button button-primary" type="submit"><?php echo esc_html__('Save hotel stays', 'chess-podium'); ?></button></p>
            </form>

            <?php
            $incomeByHotel = [];
            foreach ($playersForHotel as $p) {
                $pid = (int) ($p->id ?? 0);
                $stay = $hotelStaysByPlayer[$pid] ?? null;
                $hid = $stay ? (int) ($stay->hotel_id ?? 0) : 0;
                if ($hid <= 0) {
                    continue;
                }
                $incomeByHotel[$hid] = ($incomeByHotel[$hid] ?? 0.0) + (float) ($stay->share_amount ?? 0);
            }
            $totalHotelIncome = array_sum($incomeByHotel);
            ?>

            <h4><?php echo esc_html__('Tournament income by hotel', 'chess-podium'); ?></h4>
            <?php if (empty($incomeByHotel)): ?>
                <p><?php echo esc_html__('No hotel stays recorded yet.', 'chess-podium'); ?></p>
            <?php else: ?>
                <table class="widefat striped" style="max-width:720px;">
                    <thead>
                        <tr><th><?php echo esc_html__('Hotel', 'chess-podium'); ?></th><th><?php echo esc_html__('Tournament share (€)', 'chess-podium'); ?></th></tr>
                    </thead>
                    <tbody>
                        <?php foreach ($partnerHotels as $h): ?>
                            <?php $hid = (int) ($h->id ?? 0); ?>
                            <?php if (!isset($incomeByHotel[$hid])) { continue; } ?>
                            <tr>
                                <td><?php echo esc_html($h->name ?? ''); ?></td>
                                <td><?php echo esc_html(number_format((float) $incomeByHotel[$hid], 2)); ?></td>
                            </tr>
                        <?php endforeach; ?>
                    </tbody>
                    <tfoot>
                        <tr>
                            <th><?php echo esc_html__('Total', 'chess-podium'); ?></th>
                            <th><?php echo esc_html(number_format((float) $totalHotelIncome, 2)); ?></th>
                        </tr>
                    </tfoot>
                </table>
            <?php endif; ?>

            <script>
            (function() {
              const rateById = <?php echo wp_json_encode($hotelRateById); ?>;
              function recalcRow(tr) {
                const sel = tr.querySelector('.cp-hotel-id');
                const family = tr.querySelector('.cp-hotel-family');
                const days = tr.querySelector('.cp-hotel-days');
                const share = tr.querySelector('.cp-hotel-share');
                if (!sel || !family || !days || !share) return;
                const hotelId = parseInt(sel.value || '0', 10) || 0;
                const rate = (hotelId > 0 && rateById[hotelId]) ? parseFloat(rateById[hotelId]) : 0;
                const fam = Math.max(0, parseInt(family.value || '0', 10) || 0);
                const d = Math.max(0, parseInt(days.value || '0', 10) || 0);
                const presences = 1 + fam;
                const calc = rate * presences * d;
                share.value = (Math.round(calc * 100) / 100).toFixed(2);
              }
              document.addEventListener('click', function(e) {
                const btn = e.target && e.target.closest ? e.target.closest('.cp-hotel-recalc') : null;
                if (!btn) return;
                const tr = btn.closest('tr');
                if (tr) recalcRow(tr);
              });
              document.addEventListener('change', function(e) {
                const el = e.target;
                if (!el) return;
                if (el.classList.contains('cp-hotel-id') || el.classList.contains('cp-hotel-family') || el.classList.contains('cp-hotel-days')) {
                  const tr = el.closest('tr');
                  if (tr) recalcRow(tr);
                }
              });
            })();
            </script>
        <?php endif; ?>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'publish' ? 'cp-tab-active' : ''; ?>" data-panel="publish">
        <h3><?php echo esc_html__('Publish & export', 'chess-podium'); ?></h3>
        <h4><?php echo esc_html__('Publish tournament (static export)', 'chess-podium'); ?></h4>
        <p><?php echo esc_html__('Generate a linkable HTML folder in wp-content/uploads/chess-podium/ with standings, pairings, PGN games and photo gallery.', 'chess-podium'); ?></p>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="publish_tournament">
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <button class="button button-primary" type="submit"><?php echo esc_html__('Publish tournament', 'chess-podium'); ?></button>
        </form>
        <?php if (file_exists(ChessPodium_ExportEngine::get_export_dir($tournament) . '/index.html')): ?>
        <p><a href="<?php echo esc_url($publishUrl); ?>" target="_blank" class="button"><?php echo esc_html__('Open published page', 'chess-podium'); ?></a></p>
        <?php endif; ?>

        <h4><?php echo esc_html__('Export tournament data', 'chess-podium'); ?></h4>
        <p>
            <a class="button" href="<?php echo esc_url($exportTurnsUrl); ?>"><?php echo esc_html__('Export rounds (CSV)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($exportStandingsUrl); ?>"><?php echo esc_html__('Export standings (CSV)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($exportTrfUrl); ?>"><?php echo esc_html__('Export FIDE TRF', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($exportUscfUrl); ?>"><?php echo esc_html__('Export USCF (.TH)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url($exportEcfUrl); ?>"><?php echo esc_html__('Export ECF (JSON)', 'chess-podium'); ?></a>
        </p>
        <p class="description"><?php echo esc_html__('Use these files to share tournament data and debug pairing. TRF for FIDE, .TH for USCF, JSON for ECF LMS.', 'chess-podium'); ?></p>

        <h4><?php echo esc_html__('Certificates', 'chess-podium'); ?></h4>
        <p>
            <a class="button" href="<?php echo esc_url(self::build_export_url($tid, 'certificates_winners')); ?>"><?php echo esc_html__('Winner certificates (top 3)', 'chess-podium'); ?></a>
            <a class="button" href="<?php echo esc_url(self::build_export_url($tid, 'certificates_participation')); ?>"><?php echo esc_html__('Participation certificates (all)', 'chess-podium'); ?></a>
        </p>
        <p class="description"><?php echo esc_html__('Generate PDF diplomas for the podium or participation certificates for all players.', 'chess-podium'); ?></p>
        </div>

        <div class="cp-tab-panel <?php echo $activeTab === 'players' ? 'cp-tab-active' : ''; ?>" data-panel="players">
        <h3><?php echo esc_html__('Players', 'chess-podium'); ?></h3>
        <h4><?php echo esc_html__('Add player', 'chess-podium'); ?></h4><?php
            if (!ChessPodium_License::is_pro()) {
                $pc = count(self::get_players($tournament->id));
                $lim = ChessPodium_License::get_free_player_limit();
                echo ' <span class="description">(' . (int) $pc . '/' . $lim . ' ' . esc_html__('Players', 'chess-podium') . ')</span>';
            }
        ?></h3>
        <?php
        $canAdd = ChessPodium_License::can_add_players(count(self::get_players($tournament->id)), 1);
        if (!$canAdd): ?>
            <p class="notice notice-warning inline" style="margin:0.5rem 0;padding:0.5rem 1rem;"><?php echo wp_kses_post(sprintf(__('Player limit reached. <a href="%s" target="_blank" rel="noopener">Upgrade to Chess Podium Pro</a> for unlimited players.', 'chess-podium'), esc_url('https://www.chesspodium.com/pricing/'))); ?></p>
        <?php else: ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="add_player">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <table class="form-table">
                <tr>
                    <th><label for="player_name"><?php echo esc_html__('Name', 'chess-podium'); ?></label></th>
                    <td><input id="player_name" name="player_name" type="text" required class="regular-text"></td>
                </tr>
                <tr>
                    <th><label for="player_email"><?php echo esc_html__('Email', 'chess-podium'); ?></label></th>
                    <td>
                        <input id="player_email" name="player_email" type="email" class="regular-text" autocomplete="email">
                        <p class="description"><?php echo esc_html__('Optional. Required if the player should receive technical tournament emails.', 'chess-podium'); ?></p>
                    </td>
                </tr>
                <tr>
                    <th><?php echo esc_html__('Technical emails', 'chess-podium'); ?></th>
                    <td>
                        <label><input type="checkbox" name="player_allow_notifications" value="1"> <?php echo esc_html__('Player accepts technical emails (pairings, notices) for this tournament', 'chess-podium'); ?></label>
                    </td>
                </tr>
                <tr>
                    <th><label for="player_country"><?php echo esc_html__('Nationality', 'chess-podium'); ?></label></th>
                    <td>
                        <select id="player_country" name="player_country" required>
                            <?php foreach (ChessPodium_CountryHelper::get_countries() as $code => $label): ?>
                                <option value="<?php echo esc_attr($code); ?>"><?php echo esc_html($label); ?></option>
                            <?php endforeach; ?>
                        </select>
                        <p class="description"><?php echo esc_html__('Required for manual entry. Shown as flag next to player name.', 'chess-podium'); ?></p>
                    </td>
                </tr>
                <tr>
                    <th><label for="player_rating"><?php echo esc_html__('Rating', 'chess-podium'); ?></label></th>
                    <td><input id="player_rating" name="player_rating" type="number" min="0" max="3500" value="0"></td>
                </tr>
                <tr>
                    <th><label for="player_title"><?php echo esc_html__('FIDE title', 'chess-podium'); ?></label></th>
                    <td>
                        <select id="player_title" name="player_title">
                            <option value=""><?php echo esc_html__('— None —', 'chess-podium'); ?></option>
                            <option value="GM">GM</option>
                            <option value="IM">IM</option>
                            <option value="FM">FM</option>
                            <option value="CM">CM</option>
                            <option value="WGM">WGM</option>
                            <option value="WIM">WIM</option>
                            <option value="WFM">WFM</option>
                            <option value="WCM">WCM</option>
                        </select>
                        <p class="description"><?php echo esc_html__('Optional. Shown before player name in tables.', 'chess-podium'); ?></p>
                    </td>
                </tr>
                <tr>
                    <th><label for="player_fide_id"><?php echo esc_html__('FIDE ID', 'chess-podium'); ?></label></th>
                    <td>
                        <input id="player_fide_id" name="player_fide_id" type="text" inputmode="numeric" pattern="[0-9]*" class="regular-text" placeholder="<?php echo esc_attr__('e.g. 1503014', 'chess-podium'); ?>">
                        <p class="description"><?php echo esc_html__('Optional. Official FIDE identification number; must be unique in this tournament.', 'chess-podium'); ?></p>
                    </td>
                </tr>
                <tr>
                    <th><label for="uscf_id"><?php echo esc_html__('USCF ID', 'chess-podium'); ?></label></th>
                    <td><input id="uscf_id" name="uscf_id" type="text" inputmode="numeric" class="regular-text" placeholder="<?php echo esc_attr__('e.g. 12345678', 'chess-podium'); ?>">
                    <p class="description"><?php echo esc_html__('Optional. For USCF-rated tournaments.', 'chess-podium'); ?></p></td>
                </tr>
                <tr>
                    <th><label for="ecf_id"><?php echo esc_html__('ECF code', 'chess-podium'); ?></label></th>
                    <td><input id="ecf_id" name="ecf_id" type="text" class="regular-text" placeholder="<?php echo esc_attr__('e.g. 123456A', 'chess-podium'); ?>">
                    <p class="description"><?php echo esc_html__('Optional. For ECF-graded tournaments.', 'chess-podium'); ?></p></td>
                </tr>
            </table>
            <p><button class="button" type="submit"><?php echo esc_html__('Add player', 'chess-podium'); ?></button></p>
        </form>
        <?php endif; ?>

        <h3><?php echo esc_html__('Import players from CSV or Excel', 'chess-podium'); ?></h3>
        <?php if (!$canAdd): ?>
            <p class="description"><?php echo esc_html__('Import disabled: player limit reached.', 'chess-podium'); ?></p>
        <?php else: ?>
        <form method="post" enctype="multipart/form-data">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="bulk_import_csv">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <p>
                <input type="file" name="csv_file" accept=".csv,.xlsx">
                <button class="button" type="submit"><?php echo esc_html__('Import CSV or Excel', 'chess-podium'); ?></button>
            </p>
            <p class="description"><?php echo esc_html__('CSV format: name;rating;fide_id;country;uscf_id;ecf_id;title. Excel: chess-results export supported (columns Name, Rtg, Fed, FIDE ID, Title). First row: optional header.', 'chess-podium'); ?></p>
        </form>
        <?php endif; ?>

        <h3><?php echo esc_html__('Import player from FIDE ID', 'chess-podium'); ?></h3>
        <?php if (!$canAdd): ?>
            <p class="description"><?php echo esc_html__('Import disabled: player limit reached.', 'chess-podium'); ?></p>
        <?php else: ?>
        <form method="post">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="regina_action" value="import_fide_player">
            <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <table class="form-table">
                <tr>
                    <th><label for="fide_id"><?php echo esc_html__('FIDE ID', 'chess-podium'); ?></label></th>
                    <td>
                        <input id="fide_id" name="fide_id" type="text" inputmode="numeric" pattern="[0-9]+" class="regular-text" placeholder="<?php echo esc_attr__('e.g. 1503014', 'chess-podium'); ?>" required>
                        <p class="description"><?php echo esc_html__('Automatic import from official FIDE source: name, FIDE ID, standard Elo, nationality (when available).', 'chess-podium'); ?></p>
                    </td>
                </tr>
            </table>
            <p><button class="button" type="submit"><?php echo esc_html__('Import from FIDE', 'chess-podium'); ?></button></p>
        </form>
        <?php endif; ?>

        <h3><?php echo esc_html(sprintf(__('Registered players (%d)', 'chess-podium'), count($players))); ?></h3>
        <?php if (empty($players)): ?>
            <p><?php echo esc_html__('No players added yet.', 'chess-podium'); ?></p>
        <?php else: ?>
            <p style="margin-bottom:1rem;">
                <button class="button button-primary" type="submit" form="cp-bulk-players-form"><?php echo esc_html__('Save all players', 'chess-podium'); ?></button>
                <button class="button" type="submit" form="cp-sync-fide-all-players-form" style="margin-left:8px;"><?php echo esc_html__('Sync with FIDE', 'chess-podium'); ?></button>
                <?php if (self::is_round_robin_family($tournament)): ?>
                    <button class="button" type="submit" form="cp-randomize-pairing-numbers-form" style="margin-left:8px;" title="<?php echo esc_attr__('Randomly assign pairing numbers 1..N (simulates opening ceremony draw).', 'chess-podium'); ?>"><?php echo esc_html__('Automatic draw', 'chess-podium'); ?></button>
                <?php endif; ?>
            </p>

            <form method="post" id="cp-sync-fide-all-players-form" style="display:none;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="sync_fide_all_players">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                <input type="hidden" name="cp_tab" value="players">
            </form>

            <?php if (self::is_round_robin_family($tournament)): ?>
                <form method="post" id="cp-randomize-pairing-numbers-form" style="display:none;">
                    <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                    <input type="hidden" name="regina_action" value="randomize_pairing_numbers">
                    <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                    <input type="hidden" name="cp_tab" value="players">
                </form>
            <?php endif; ?>

            <form method="post" id="cp-bulk-players-form" enctype="multipart/form-data">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="bulk_update_players">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
            <table class="widefat striped">
                <thead><tr><th>#</th><?php if (self::is_round_robin_family($tournament)): ?><th><?php echo esc_html__('Nr.', 'chess-podium'); ?></th><?php endif; ?><th><?php echo esc_html__('Photo', 'chess-podium'); ?></th><th><?php echo esc_html__('Name', 'chess-podium'); ?></th><th><?php echo esc_html__('Email', 'chess-podium'); ?></th><th><?php echo esc_html__('Tech. mail', 'chess-podium'); ?></th><th><?php echo esc_html__('FIDE ID', 'chess-podium'); ?></th><th><?php echo esc_html__('USCF ID', 'chess-podium'); ?></th><th><?php echo esc_html__('ECF code', 'chess-podium'); ?></th><th><?php echo esc_html__('Rating', 'chess-podium'); ?></th><th><?php echo esc_html__('Actions', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($players as $idx => $p): ?>
                    <tr>
                        <td><?php echo (int) ($idx + 1); ?></td>
                        <?php if (self::is_round_robin_family($tournament)): ?>
                        <td><input name="players[<?php echo (int) $idx; ?>][start_number]" type="number" min="1" max="<?php echo max(1, count($players)); ?>" value="<?php echo esc_attr((int) ($p->start_number ?? '')); ?>" placeholder="1-<?php echo (int) count($players); ?>" style="width:55px;" title="<?php echo esc_attr__('Pairing number (Numero di Sorteggio) for Berger tables. Assign 1..N before generating round 1.', 'chess-podium'); ?>"></td>
                        <?php endif; ?>
                        <?php
                        $photoId = isset($p->photo_attachment_id) ? (int) $p->photo_attachment_id : 0;
                        $photoThumb = $photoId > 0 ? wp_get_attachment_image_url($photoId, 'thumbnail') : '';
                        ?>
                        <td style="white-space:nowrap;min-width:150px;">
                            <input class="cp-player-photo-id" type="hidden" name="players[<?php echo (int) $idx; ?>][photo_attachment_id]" value="<?php echo esc_attr($photoId > 0 ? (string) $photoId : ''); ?>">
                            <img class="cp-player-photo-preview" src="<?php echo $photoThumb ? esc_url($photoThumb) : ''; ?>" alt="" style="width:34px;height:34px;object-fit:cover;border-radius:6px;border:1px solid #dcdcde;vertical-align:middle;<?php echo $photoThumb ? '' : 'display:none;'; ?>">
                            <button type="button" class="button button-small cp-player-photo-select" style="margin-left:6px;"><?php echo esc_html__('Select', 'chess-podium'); ?></button>
                            <button type="button" class="button button-small cp-player-photo-remove"><?php echo esc_html__('Remove', 'chess-podium'); ?></button>
                            <div style="margin-top:6px;">
                                <input type="file" name="player_photo_files[<?php echo (int) $idx; ?>]" accept="image/*">
                            </div>
                        </td>
                        <td>
                            <?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag($p->country ?? '', (string) ($p->country ?? ''))); ?>
                            <span style="display:inline-flex;gap:8px;align-items:center;flex-wrap:wrap;">
                                <input type="hidden" name="players[<?php echo (int) $idx; ?>][player_id]" value="<?php echo (int) $p->id; ?>">
                                <select name="players[<?php echo (int) $idx; ?>][player_title]" style="width:70px;">
                                    <option value="">—</option>
                                    <option value="GM" <?php selected(($p->title ?? ''), 'GM'); ?>>GM</option>
                                    <option value="IM" <?php selected(($p->title ?? ''), 'IM'); ?>>IM</option>
                                    <option value="FM" <?php selected(($p->title ?? ''), 'FM'); ?>>FM</option>
                                    <option value="CM" <?php selected(($p->title ?? ''), 'CM'); ?>>CM</option>
                                    <option value="WGM" <?php selected(($p->title ?? ''), 'WGM'); ?>>WGM</option>
                                    <option value="WIM" <?php selected(($p->title ?? ''), 'WIM'); ?>>WIM</option>
                                    <option value="WFM" <?php selected(($p->title ?? ''), 'WFM'); ?>>WFM</option>
                                    <option value="WCM" <?php selected(($p->title ?? ''), 'WCM'); ?>>WCM</option>
                                </select>
                                <input name="players[<?php echo (int) $idx; ?>][player_name]" type="text" value="<?php echo esc_attr($p->name); ?>" required>
                                <select name="players[<?php echo (int) $idx; ?>][player_country]" style="width:140px;">
                                    <?php foreach (ChessPodium_CountryHelper::get_countries() as $code => $label): ?>
                                        <option value="<?php echo esc_attr($code); ?>" <?php selected(($p->country ?? ''), $code); ?>><?php echo esc_html($label); ?></option>
                                    <?php endforeach; ?>
                                </select>
                            </span>
                        </td>
                        <td><input name="players[<?php echo (int) $idx; ?>][player_email]" type="email" class="regular-text" value="<?php echo esc_attr($p->email ?? ''); ?>" placeholder="<?php echo esc_attr__('optional', 'chess-podium'); ?>" style="min-width:160px;"></td>
                        <td style="white-space:nowrap;">
                            <label><input type="checkbox" name="players[<?php echo (int) $idx; ?>][allow_notifications]" value="1" <?php checked(!empty($p->allow_notifications)); ?>> <?php echo esc_html__('Opt-in', 'chess-podium'); ?></label>
                        </td>
                        <td><?php echo !empty($p->fide_id) ? esc_html((string) $p->fide_id) : '-'; ?></td>
                        <td><input name="players[<?php echo (int) $idx; ?>][uscf_id]" type="text" value="<?php echo esc_attr($p->uscf_id ?? ''); ?>" placeholder="<?php echo esc_attr__('USCF', 'chess-podium'); ?>" style="width:90px;"></td>
                        <td><input name="players[<?php echo (int) $idx; ?>][ecf_id]" type="text" value="<?php echo esc_attr($p->ecf_id ?? ''); ?>" placeholder="<?php echo esc_attr__('ECF', 'chess-podium'); ?>" style="width:90px;"></td>
                        <td><input name="players[<?php echo (int) $idx; ?>][player_rating]" type="number" min="0" max="3500" value="<?php echo (int) $p->rating; ?>" style="width:90px;"></td>
                        <td>
                            <?php $canDelete = !self::player_used_in_pairings((int) $tournament->id, (int) $p->id); ?>
                            <?php if (!empty($p->fide_id)): ?>
                                <button type="submit" form="cp-sync-fide-player-<?php echo (int) $p->id; ?>" class="button button-small"><?php echo esc_html__('Sync FIDE', 'chess-podium'); ?></button>
                            <?php endif; ?>
                            <button type="submit" form="cp-delete-player-<?php echo (int) $p->id; ?>" class="button button-link-delete"
                                    onclick="return confirm('<?php echo esc_js(__('Permanently delete this player?', 'chess-podium')); ?>');"
                                    <?php disabled(!$canDelete); ?>>
                                <?php echo esc_html__('Delete', 'chess-podium'); ?>
                            </button>
                            <?php if (!$canDelete): ?>
                                <div class="description"><?php echo esc_html__('Cannot delete: already paired in one or more rounds.', 'chess-podium'); ?></div>
                            <?php endif; ?>
                        </td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <p style="margin-top:1rem;">
                <button class="button button-primary" type="submit"><?php echo esc_html__('Save all players', 'chess-podium'); ?></button>
            </p>
            </form>
            <?php foreach ($players as $p): ?>
            <form id="cp-sync-fide-player-<?php echo (int) $p->id; ?>" method="post" style="display:none;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="sync_fide_player">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                <input type="hidden" name="player_id" value="<?php echo (int) $p->id; ?>">
                <input type="hidden" name="cp_tab" value="players">
            </form>
            <form id="cp-delete-player-<?php echo (int) $p->id; ?>" method="post" style="display:none;">
                <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
                <input type="hidden" name="regina_action" value="delete_player">
                <input type="hidden" name="tournament_id" value="<?php echo (int) $tournament->id; ?>">
                <input type="hidden" name="player_id" value="<?php echo (int) $p->id; ?>">
            </form>
            <?php endforeach; ?>
        <?php endif; ?>
        </div>

        <?php
        if (class_exists('ChessPodium_Tournament_Communications')) {
            ChessPodium_Tournament_Communications::render_admin_tab($tournament, $activeTab === 'communications');
        }
        ?>

        <?php
        if (!empty($tournament->is_team_tournament) && class_exists('ChessPodium_TeamTournaments')) {
            ChessPodium_TeamTournaments::render_admin_tab($tournament, $activeTab === 'teams');
        }
        ?>

            </div><!-- .cp-tab-panels -->
        </div><!-- .cp-admin-tabs -->
        <?php
    }

    public static function render_tornei_page_shortcode(array $atts = []): string
    {
        $published = self::get_published_tournaments();
        if (empty($published)) {
            return '<div class="checkmate-tornei-list"><p>' . esc_html__('No published tournaments. Publish a tournament from Chess Podium to see it here.', 'chess-podium') . '</p></div>';
        }
        $logoUrl = self::get_default_logo_url();
        ob_start();
        ?>
        <div class="checkmate-tornei-list">
            <div class="checkmate-tornei-grid">
                <?php foreach ($published as $item): ?>
                <a href="<?php echo esc_url($item['url']); ?>" class="checkmate-torneo-card" target="_blank" rel="noopener">
                    <div class="checkmate-torneo-preview">
                        <img src="<?php echo esc_url($item['preview_url']); ?>" alt="<?php echo esc_attr($item['name']); ?>">
                    </div>
                    <div class="checkmate-torneo-info">
                        <h3><?php echo esc_html($item['name']); ?></h3>
                        <span class="checkmate-torneo-meta"><?php echo esc_html(sprintf(_n('%d round', '%d rounds', (int) $item['rounds'], 'chess-podium'), (int) $item['rounds'])); ?></span>
                    </div>
                </a>
                <?php endforeach; ?>
            </div>
        </div>
        <?php
        return (string) ob_get_clean();
    }

    public static function render_tournament_shortcode(array $atts = []): string
    {
        $atts = shortcode_atts(['id' => 0, 'round' => 0], $atts, 'regina_torneo');
        $tid = (int) $atts['id'];
        $roundFromUrl = isset($_GET['round']) ? max(1, (int) $_GET['round']) : 0;
        $roundOverride = (int) $atts['round'] ?: $roundFromUrl;
        $tournament = $tid > 0 ? self::get_tournament($tid) : self::get_latest_tournament();
        if (!$tournament) {
            return '<p>Nessun torneo disponibile.</p>';
        }
        return self::render_public_block($tournament, $roundOverride > 0 ? $roundOverride : 0);
    }

    private static function build_player_profile_url(int $tournamentId, int $playerId): string
    {
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return add_query_arg('cp_player', (string) $playerId, self::get_public_tournament_view_url($tournamentId));
        }
        $player = null;
        foreach (self::get_players($tournamentId) as $p) {
            if ((int) ($p->id ?? 0) === $playerId) {
                $player = $p;
                break;
            }
        }
        if ($player && class_exists('ChessPodium_PlayerDirectory')) {
            $unified = ChessPodium_PlayerDirectory::find_for_tournament_player($player);
            if ($unified && !empty($unified->slug)) {
                return self::get_unified_player_profile_url((string) $unified->slug, $tournamentId);
            }
        }
        $mainSlug = apply_filters('chess_podium_tournament_slug', 'torneo');
        $mainSlug = sanitize_title($mainSlug) ?: 'torneo';
        $tSeo = self::get_tournament_seo_slug($t);
        $pSeo = $player ? self::get_player_seo_slug($player) : '';
        if ($tSeo !== '' && $pSeo !== '') {
            return home_url('/' . $mainSlug . '/' . $tSeo . '/player/' . $pSeo . '/');
        }
        return add_query_arg('cp_player', (string) $playerId, $tSeo !== '' ? self::get_tournament_pretty_slug_url($tSeo) : self::get_tournament_pretty_url($tournamentId));
    }

    /**
     * True for FIDE site-wide images (og preview, logos, placeholder avatar) — not the player's photo.
     */
    private static function is_fide_generic_profile_image_url(string $url): bool
    {
        $u = strtolower($url);
        $needles = [
            'fide_og_',
            'og_1200',
            'directory.fide.com/img/fide',
            '/img/fide_',
            'logo1.png',
            'logo1.',
            'user.svg',
            'ratings.fide.com/img/logo',
            'fb-top',
            'instagram',
            'twitter-card',
        ];
        foreach ($needles as $n) {
            if (strpos($u, $n) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * Resolve relative FIDE paths to absolute https URLs.
     */
    private static function normalize_fide_profile_image_candidate(string $src): string
    {
        $src = trim(html_entity_decode($src, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        if ($src === '') {
            return '';
        }
        if (strpos($src, '//') === 0) {
            return 'https:' . $src;
        }
        if (preg_match('#^https?://#i', $src)) {
            return $src;
        }
        if ($src[0] === '/') {
            return 'https://ratings.fide.com' . $src;
        }
        return $src;
    }

    /**
     * Parse ratings.fide.com profile HTML: real photo lives in .profile-photo (not og:image, which is a generic FIDE banner).
     */
    private static function extract_fide_profile_photo_url(string $body, string $fideId): ?string
    {
        // Broader class match (e.g. "photo profile-photo", extra spaces).
        if (preg_match('/<div[^>]*\bclass="[^"]*\bprofile-photo\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i', $body, $block)) {
            $inner = $block[1];
            if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\']/i', $inner, $imgs, PREG_SET_ORDER)) {
                foreach ($imgs as $row) {
                    $cand = self::normalize_fide_profile_image_candidate($row[1]);
                    if ($cand === '' || self::is_fide_generic_profile_image_url($cand)) {
                        continue;
                    }
                    if (strpos($cand, 'data:image/') === 0) {
                        // FIDE sometimes embeds placeholder avatars as data:image/svg+xml or tiny base64 blobs.
                        // Treat those as "no photo" so we fall back to the generic Chess Podium avatars.
                        if (stripos($cand, 'data:image/svg') === 0) {
                            continue;
                        }
                        if (strlen($cand) < 2000) {
                            continue;
                        }
                        if (strlen($cand) > 180000) {
                            continue;
                        }
                        return $cand;
                    }
                    return esc_url_raw($cand);
                }
            }
            if (preg_match('/background-image:\s*url\(\s*["\']?([^"\')\s]+)/i', $inner, $bg)) {
                $cand = self::normalize_fide_profile_image_candidate($bg[1]);
                if ($cand !== '' && !self::is_fide_generic_profile_image_url($cand) && strpos($cand, 'data:image/') !== 0) {
                    return esc_url_raw($cand);
                }
            }
            if (preg_match('/(data:image\/(?:jpeg|png|gif|webp);base64,[A-Za-z0-9+\/=\r\n]+)/i', $inner, $dm)) {
                $cand = preg_replace('/\s+/', '', $dm[1]);
                if (strlen($cand) > 200 && strlen($cand) <= 180000) {
                    return $cand;
                }
            }
        }
        if (preg_match('/<meta\s+property="og:image"\s+content="([^"]+)"/i', $body, $m)) {
            $img = esc_url_raw(trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')));
            if ($img !== '' && !self::is_fide_generic_profile_image_url($img)) {
                return $img;
            }
        }
        // Quoted key/value pairs in inline scripts (hydration / config).
        if (preg_match_all('/"(?:profilePhoto|photoUrl|profile_image|profileImage|avatar|Photo)"\s*:\s*"([^"]+)"/i', $body, $jm)) {
            foreach ($jm[1] as $raw) {
                $raw = stripslashes($raw);
                if (!preg_match('/\.(jpe?g|png|webp)(\?|$)/i', $raw) && strpos($raw, 'data:image/') !== 0) {
                    continue;
                }
                $cand = self::normalize_fide_profile_image_candidate($raw);
                if ($cand === '' || self::is_fide_generic_profile_image_url($cand)) {
                    continue;
                }
                if (strpos($cand, 'data:image/') === 0) {
                    return strlen($cand) <= 180000 ? $cand : null;
                }
                return esc_url_raw($cand);
            }
        }
        // JSON-LD blocks: scan for image URLs.
        if (preg_match_all('#<script[^>]*type="application/ld\+json"[^>]*>([\s\S]*?)</script>#i', $body, $ld)) {
            foreach ($ld[1] as $json) {
                $blob = trim($json);
                if ($blob === '') {
                    continue;
                }
                if (!preg_match_all('#https?://[^"\\\\]+\.(?:jpe?g|png|webp)(?:\?[^"\\\\]*)?#i', $blob, $urls)) {
                    continue;
                }
                foreach ($urls[0] as $u) {
                    $cand = esc_url_raw($u);
                    if ($cand !== '' && !self::is_fide_generic_profile_image_url($cand)) {
                        return $cand;
                    }
                }
            }
        }
        // Last resort: any portrait-like URL on FIDE hosts in the raw document (bundles).
        if (preg_match_all('#https?://(?:ratings\.fide\.com|directory\.fide\.com)[^"\'\s<>]+\.(?:jpe?g|png|webp)(?:\?[^"\'\s<>]*)?#i', $body, $um)) {
            foreach ($um[0] as $u) {
                $cand = esc_url_raw($u);
                if ($cand !== '' && !self::is_fide_generic_profile_image_url($cand)) {
                    return $cand;
                }
            }
        }
        return null;
    }

    /**
     * Name variants for Wikipedia matching (e.g. "Caruana, Fabiano" → "Fabiano Caruana").
     *
     * @return list<string>
     */
    private static function wikipedia_player_name_variants(string $name): array
    {
        $name = trim(preg_replace('/\s+/u', ' ', $name));
        if ($name === '') {
            return [];
        }
        $variants = [$name];
        if (preg_match('/^([^,]+),\s*(.+)$/', $name, $m)) {
            $flipped = trim($m[2] . ' ' . $m[1]);
            if ($flipped !== '' && !in_array($flipped, $variants, true)) {
                $variants[] = $flipped;
            }
        }
        return $variants;
    }

    /**
     * Wikimedia HTTP defaults (User-Agent policy).
     */
    private static function wikimedia_http_args(int $timeout = 12): array
    {
        return [
            'timeout' => $timeout,
            'user-agent' => 'ChessPodium/1.0 WordPress plugin (https://wordpress.org/)',
        ];
    }

    /**
     * REST page summary thumbnail (no transient cache; callers cache).
     *
     * @see https://www.mediawiki.org/wiki/API:REST_API/Reference
     */
    private static function fetch_wikipedia_rest_summary_thumbnail_uncached(string $pageTitle, string $lang = 'en'): ?string
    {
        $pageTitle = trim($pageTitle);
        if ($pageTitle === '') {
            return null;
        }
        $lang = preg_match('/^[a-z]{2}$/i', $lang) ? strtolower($lang) : 'en';
        $title = str_replace(' ', '_', $pageTitle);
        $api = sprintf('https://%s.wikipedia.org/api/rest_v1/page/summary/%s', $lang, rawurlencode($title));
        $response = wp_remote_get($api, array_merge(self::wikimedia_http_args(10), [
            'headers' => ['Accept' => 'application/json'],
        ]));
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            return null;
        }
        $data = json_decode((string) wp_remote_retrieve_body($response), true);
        if (!is_array($data)) {
            return null;
        }
        $thumb = $data['thumbnail']['source'] ?? '';
        if (!is_string($thumb) || $thumb === '') {
            return null;
        }
        $thumb = esc_url_raw($thumb);
        if ($thumb === '' || !preg_match('#^https?://#i', $thumb)) {
            return null;
        }
        return $thumb;
    }

    /**
     * First search hit that has a lead image (handles titles like "R Praggnanandhaa" vs stored name).
     *
     * @see https://www.mediawiki.org/wiki/API:Search
     */
    private static function fetch_wikipedia_thumbnail_by_search(string $searchTerm, string $lang = 'en'): ?string
    {
        $searchTerm = trim($searchTerm);
        if ($searchTerm === '') {
            return null;
        }
        $lang = preg_match('/^[a-z]{2}$/i', $lang) ? strtolower($lang) : 'en';
        $cacheKey = 'cp_wiki_search_v2_' . md5($lang . '|' . $searchTerm);
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }
        $api = sprintf(
            'https://%s.wikipedia.org/w/api.php?action=query&generator=search&gsrsearch=%s&gsrlimit=5&gsrnamespace=0&prop=pageimages&piprop=thumbnail&pithumbsize=320&format=json',
            $lang,
            rawurlencode($searchTerm)
        );
        $response = wp_remote_get($api, self::wikimedia_http_args(12));
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS);
            return null;
        }
        $data = json_decode((string) wp_remote_retrieve_body($response), true);
        $pages = is_array($data) ? ($data['query']['pages'] ?? []) : [];
        if (!is_array($pages)) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
            return null;
        }
        foreach ($pages as $page) {
            if (!is_array($page)) {
                continue;
            }
            $title = (string) ($page['title'] ?? '');
            if ($title !== '' && stripos($title, 'disambiguation') !== false) {
                continue;
            }
            $thumb = $page['thumbnail']['source'] ?? '';
            if (!is_string($thumb) || $thumb === '') {
                continue;
            }
            $thumb = esc_url_raw($thumb);
            if ($thumb !== '' && preg_match('#^https?://#i', $thumb)) {
                set_transient($cacheKey, $thumb, DAY_IN_SECONDS * 14);
                return $thumb;
            }
        }
        set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
        return null;
    }

    /**
     * Resolve Wikidata Q-id from FIDE ID (P1440).
     */
    private static function wikidata_qid_for_fide_id(string $fideId): ?string
    {
        $fideId = self::sanitize_fide_id($fideId);
        if ($fideId === '' || strlen($fideId) < 5) {
            return null;
        }
        $cacheKey = 'cp_wd_qid_v1_' . $fideId;
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_string($cached) && preg_match('/^Q\d+$/', $cached)) {
            return $cached;
        }
        $query = 'SELECT ?item WHERE { ?item wdt:P1440 "' . $fideId . '" . } LIMIT 1';
        $url = 'https://query.wikidata.org/sparql?' . http_build_query([
            'format' => 'json',
            'query' => $query,
        ]);
        $response = wp_remote_get($url, self::wikimedia_http_args(15));
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
            return null;
        }
        $data = json_decode((string) wp_remote_retrieve_body($response), true);
        $bindings = is_array($data) ? ($data['results']['bindings'] ?? []) : [];
        $uri = isset($bindings[0]['item']['value']) ? (string) $bindings[0]['item']['value'] : '';
        if ($uri !== '' && preg_match('#/entity/(Q\d+)$#', $uri, $m)) {
            set_transient($cacheKey, $m[1], DAY_IN_SECONDS * 60);
            return $m[1];
        }
        set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 30);
        return null;
    }

    /**
     * Sitelinks for a Wikidata item (cached per Q-id).
     *
     * @return array<string, string>|null site key → page title
     */
    private static function wikidata_sitelinks_for_qid(string $qid): ?array
    {
        if (!preg_match('/^Q\d+$/', $qid)) {
            return null;
        }
        $cacheKey = 'cp_wd_sitelinks_v1_' . $qid;
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_array($cached)) {
            return $cached;
        }
        $response = wp_remote_get('https://www.wikidata.org/w/api.php?' . http_build_query([
            'action' => 'wbgetentities',
            'ids' => $qid,
            'format' => 'json',
            'props' => 'sitelinks',
        ]), self::wikimedia_http_args(12));
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
            return null;
        }
        $data = json_decode((string) wp_remote_retrieve_body($response), true);
        $entity = is_array($data) ? ($data['entities'][$qid] ?? null) : null;
        $sitelinks = is_array($entity) ? ($entity['sitelinks'] ?? []) : [];
        if (!is_array($sitelinks) || $sitelinks === []) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
            return null;
        }
        $map = [];
        foreach ($sitelinks as $site => $info) {
            if (is_array($info) && isset($info['title']) && is_string($info['title']) && $info['title'] !== '') {
                $map[(string) $site] = (string) $info['title'];
            }
        }
        if ($map === []) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
            return null;
        }
        set_transient($cacheKey, $map, DAY_IN_SECONDS * 60);
        return $map;
    }

    /**
     * Thumbnail via Wikidata (FIDE P1440) → Wikipedia sitelink title → REST summary.
     */
    private static function fetch_wikipedia_photo_via_wikidata_fide(string $fideId): ?string
    {
        $fideId = self::sanitize_fide_id($fideId);
        if ($fideId === '') {
            return null;
        }
        $cacheKey = 'cp_wiki_wd_photo_v1_' . $fideId;
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }
        $qid = self::wikidata_qid_for_fide_id($fideId);
        if ($qid === null) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 14);
            return null;
        }
        $map = self::wikidata_sitelinks_for_qid($qid);
        if ($map === null || $map === []) {
            set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 14);
            return null;
        }
        $try = [
            ['site' => 'enwiki', 'lang' => 'en'],
            ['site' => 'itwiki', 'lang' => 'it'],
            ['site' => 'dewiki', 'lang' => 'de'],
            ['site' => 'eswiki', 'lang' => 'es'],
            ['site' => 'frwiki', 'lang' => 'fr'],
        ];
        foreach ($try as $row) {
            $site = $row['site'];
            if (!isset($map[$site])) {
                continue;
            }
            $thumb = self::fetch_wikipedia_rest_summary_thumbnail_uncached($map[$site], $row['lang']);
            if ($thumb !== null && $thumb !== '') {
                set_transient($cacheKey, $thumb, DAY_IN_SECONDS * 14);
                return $thumb;
            }
        }
        set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 14);
        return null;
    }

    /**
     * Wikipedia thumbnails: Wikidata by FIDE ID, then REST by name variants, then full-text search.
     */
    private static function fetch_wikipedia_photo_url_combined(string $playerName, ?string $fideId): ?string
    {
        $name = trim($playerName);
        $fid = $fideId !== null ? self::sanitize_fide_id($fideId) : '';
        $cacheKey = 'cp_wiki_photo_v4_' . md5($name . '|' . $fid);
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_string($cached) && $cached !== '') {
            return $cached;
        }
        $img = null;
        if ($fid !== '') {
            $img = self::fetch_wikipedia_photo_via_wikidata_fide($fid);
        }
        if ($img === null || $img === '') {
            foreach (self::wikipedia_player_name_variants($name) as $variant) {
                $img = self::fetch_wikipedia_rest_summary_thumbnail_uncached($variant, 'en');
                if ($img !== null && $img !== '') {
                    break;
                }
            }
        }
        if ($img === null || $img === '') {
            foreach (self::wikipedia_player_name_variants($name) as $variant) {
                $img = self::fetch_wikipedia_thumbnail_by_search($variant, 'en');
                if ($img !== null && $img !== '') {
                    break;
                }
            }
        }
        if (($img === null || $img === '') && function_exists('get_locale')) {
            $loc = (string) get_locale();
            $lang = strlen($loc) >= 2 ? strtolower(substr($loc, 0, 2)) : '';
            if ($lang !== '' && $lang !== 'en' && preg_match('/^[a-z]{2}$/', $lang)) {
                foreach (self::wikipedia_player_name_variants($name) as $variant) {
                    $img = self::fetch_wikipedia_rest_summary_thumbnail_uncached($variant, $lang);
                    if ($img !== null && $img !== '') {
                        break;
                    }
                }
                if ($img === null || $img === '') {
                    foreach (self::wikipedia_player_name_variants($name) as $variant) {
                        $img = self::fetch_wikipedia_thumbnail_by_search($variant, $lang);
                        if ($img !== null && $img !== '') {
                            break;
                        }
                    }
                }
            }
        }
        if ($img !== null && $img !== '') {
            set_transient($cacheKey, $img, DAY_IN_SECONDS * 14);
            return $img;
        }
        set_transient($cacheKey, '__none__', DAY_IN_SECONDS * 7);
        return null;
    }

    /**
     * Photo URL from transients only (no HTTP). Used for fast public HTML; network fetch runs via AJAX.
     */
    private static function resolve_player_profile_photo_url_from_cache_only(object $player, ?object $tournament = null): ?string
    {
        $fideId = self::sanitize_fide_id((string) ($player->fide_id ?? ''));
        $name = (string) ($player->name ?? '');
        $img = null;
        if ($fideId !== '') {
            $c = get_transient('cp_fide_photo_v2_' . $fideId);
            if (is_string($c) && $c !== '' && $c !== '__none__') {
                $img = $c;
            }
        }
        if (($img === null || $img === '') && $name !== '') {
            $c = get_transient('cp_wiki_photo_v4_' . md5($name . '|' . $fideId));
            if (is_string($c) && $c !== '' && $c !== '__none__') {
                $img = $c;
            }
        }
        if (($img === null || $img === '') && $fideId !== '') {
            $c = get_transient('cp_wiki_wd_photo_v1_' . $fideId);
            if (is_string($c) && $c !== '' && $c !== '__none__') {
                $img = $c;
            }
        }

        return apply_filters('chess_podium_player_photo_url', $img, $player, $tournament);
    }

    /**
     * Resolve photo with FIDE + Wikipedia (slow). Used by AJAX after the profile shell is sent.
     */
    public static function fetch_tournament_player_profile_photo_with_network(int $tournamentId, int $playerId): ?string
    {
        if ($tournamentId <= 0 || $playerId <= 0) {
            return null;
        }
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return null;
        }
        $player = null;
        foreach (self::get_players($tournamentId) as $p) {
            if ((int) ($p->id ?? 0) === $playerId) {
                $player = $p;
                break;
            }
        }
        if (!$player) {
            return null;
        }
        self::extend_long_operation_budget(120);

        return self::resolve_player_profile_photo_url($player, $t);
    }

    /**
     * FIDE profile photo first, then Wikipedia (Wikidata FIDE ID, REST, search).
     *
     * @param object|null $tournament Optional context for filters.
     */
    private static function resolve_player_profile_photo_url(object $player, ?object $tournament = null): ?string
    {
        $fideId = (string) ($player->fide_id ?? '');
        $name = (string) ($player->name ?? '');
        $img = ($fideId !== '') ? self::fetch_fide_photo_url($fideId) : null;
        if ($img === null || $img === '') {
            $img = self::fetch_wikipedia_photo_url_combined($name, $fideId !== '' ? $fideId : null);
        }
        /**
         * Filters the resolved player profile photo URL (FIDE + Wikipedia fallbacks).
         *
         * @param string|null $url        Image URL or null.
         * @param object      $player     Player row.
         * @param object|null $tournament Tournament context when applicable.
         */
        return apply_filters('chess_podium_player_photo_url', $img, $player, $tournament);
    }

    private static function esc_profile_image_src(string $url): string
    {
        if (strpos($url, 'data:image/') === 0) {
            return esc_attr($url);
        }
        return esc_url($url);
    }

    private static function fetch_fide_photo_url(string $fideId): ?string
    {
        $fideId = self::sanitize_fide_id($fideId);
        if ($fideId === '') {
            return null;
        }
        $cacheKey = 'cp_fide_photo_v2_' . $fideId;
        $cached = get_transient($cacheKey);
        if ($cached === '__none__') {
            return null;
        }
        if (is_string($cached) && $cached !== '') {
            // Defensive: older caches might contain placeholders (blank SVG / tiny data URI / generic images).
            // Treat those as "no photo" so the caller can fall back to Wikipedia-verified or generic avatars.
            $c = $cached;
            if (stripos($c, 'data:image/svg') === 0) {
                set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
                return null;
            }
            if (strpos($c, 'data:image/') === 0 && strlen($c) < 2000) {
                set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
                return null;
            }
            if (self::is_fide_generic_profile_image_url($c)) {
                set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
                return null;
            }
            return $c;
        }
        $url = 'https://ratings.fide.com/profile/' . rawurlencode($fideId);
        $response = wp_remote_get($url, [
            'timeout' => 15,
            'redirection' => 3,
            'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
            'headers' => [
                'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                'Accept-Language' => 'en-US,en;q=0.9',
            ],
        ]);
        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
            return null;
        }
        $body = (string) wp_remote_retrieve_body($response);
        if ($body === '') {
            set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
            return null;
        }
        $img = self::extract_fide_profile_photo_url($body, $fideId);
        /**
         * Filters the FIDE profile photo URL after parsing (e.g. third-party CDN or override).
         *
         * @param string|null $url    Resolved URL or data URI, or null.
         * @param string      $fideId Sanitized FIDE ID.
         * @param string      $body   Full HTML body (for advanced parsers).
         */
        $img = apply_filters('chess_podium_fide_player_photo_url', $img, $fideId, $body);
        if ($img === null || $img === '') {
            set_transient($cacheKey, '__none__', HOUR_IN_SECONDS);
            return null;
        }
        if (strpos($img, 'data:image/') === 0) {
            set_transient($cacheKey, $img, DAY_IN_SECONDS * 2);
            return $img;
        }
        set_transient($cacheKey, $img, DAY_IN_SECONDS * 14);
        return $img;
    }

    private static function render_tournament_player_profile(object $tournament, int $playerId, array $playersById, array $standings, array $playerStats, array $allPairings, array $startingRanks): string
    {
        $tid = (int) $tournament->id;
        $player = $playersById[$playerId] ?? null;
        if (!$player) {
            return '';
        }
        $unified = null;
        if (class_exists('ChessPodium_PlayerDirectory') && class_exists('ChessPodium_GrandPrix')) {
            $gpIds = ChessPodium_GrandPrix::get_grand_prix_ids_for_tournament($tid);
            if (!empty($gpIds)) {
                $unified = ChessPodium_PlayerDirectory::resolve_or_create((string) ($player->name ?? ''), (string) ($player->fide_id ?? ''), (string) ($player->country ?? ''));
            }
        }
        $standing = null;
        foreach ($standings as $idx => $s) {
            if ((int) ($s['player_id'] ?? 0) === $playerId) {
                $standing = array_merge($s, ['position' => $idx + 1]);
                break;
            }
        }
        $stats = $playerStats[$playerId] ?? null;
        $photoUrl = self::resolve_player_profile_photo_url_from_cache_only($player, $tournament);
        if ($unified && isset($unified->avatar_attachment_id) && (int) $unified->avatar_attachment_id > 0) {
            $u = wp_get_attachment_image_url((int) $unified->avatar_attachment_id, 'medium');
            if (is_string($u) && $u !== '') {
                $photoUrl = $u;
            }
        }
        $photoNonce = wp_create_nonce('chess_podium_player_photo_' . $tid);
        $gamesPlayed = $stats ? (int) (($stats['wins'] ?? 0) + ($stats['draws'] ?? 0) + ($stats['losses'] ?? 0)) : 0;
        $perf = $stats ? (int) ($stats['performance'] ?? 0) : 0;
        $eloVar = $stats ? (int) ($stats['elo_variation'] ?? 0) : 0;
        $eloVarText = $eloVar > 0 ? '+' . $eloVar : (string) $eloVar;
        $tiebreakerCols = self::get_tiebreaker_columns_for_display($tournament);
        $tournamentHomeUrl = self::get_tournament_seo_slug($tournament) !== ''
            ? self::get_tournament_pretty_slug_url(self::get_tournament_seo_slug($tournament))
            : self::get_tournament_pretty_url($tid);
        $standingsWithRank = [];
        foreach ($standings as $pos => $s) {
            $pid = (int) $s['player_id'];
            $standingsWithRank[] = array_merge($s, ['pos' => $pos + 1, 'start_rank' => $startingRanks[$pid] ?? '-']);
        }
        $gamesRows = [];
        foreach ($allPairings as $p) {
            if ((int) ($p->is_bye ?? 0) === 1) {
                continue;
            }
            $wid = (int) ($p->white_player_id ?? 0);
            $bid = (int) ($p->black_player_id ?? 0);
            $roundNo = (int) ($p->round_no ?? 0);
            if ($wid === $playerId) {
                $oppId = $bid;
                $colour = __('White', 'chess-podium');
                $oppName = (string) ($p->black_name ?? '');
            } elseif ($bid === $playerId) {
                $oppId = $wid;
                $colour = __('Black', 'chess-podium');
                $oppName = (string) ($p->white_name ?? '');
            } else {
                continue;
            }
            $gamesRows[] = [
                'round' => $roundNo,
                'pairing_id' => (int) ($p->id ?? 0),
                'opponent_id' => $oppId,
                'opponent_name' => $oppName,
                'colour' => $colour,
                'result' => trim((string) ($p->result ?? '')),
            ];
        }
        usort($gamesRows, static function ($a, $b) {
            return ($a['round'] ?? 0) <=> ($b['round'] ?? 0);
        });
        ob_start();
        ?>
        <section id="cp-player-profile" style="border:1px solid #e2e8f0;border-radius:8px;padding:1rem;margin:1rem 0 1.5rem;background:#f8fafc;">
            <p style="margin:0 0 0.75rem 0;font-size:0.95rem;"><a href="<?php echo esc_url($tournamentHomeUrl); ?>">&larr; <?php echo esc_html($tournament->name); ?></a></p>
            <h2 style="margin-top:0;"><?php echo esc_html((string) $player->name); ?></h2>
            <div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-start;">
                <?php if (!empty($photoUrl)): ?>
                    <img src="<?php echo self::esc_profile_image_src($photoUrl); ?>" alt="<?php echo esc_attr((string) $player->name); ?>" style="width:110px;height:110px;object-fit:cover;border-radius:8px;border:1px solid #d1d5db;">
                <?php else: ?>
                    <div id="cp-player-profile-photo-mount" class="cp-player-profile-photo-mount" style="width:110px;height:110px;flex-shrink:0;background:#e5e7eb;border-radius:8px;border:1px solid #d1d5db;" role="img" aria-label="<?php echo esc_attr((string) $player->name); ?>"
                        data-tournament-id="<?php echo esc_attr((string) $tid); ?>"
                        data-player-id="<?php echo esc_attr((string) $playerId); ?>"
                        data-nonce="<?php echo esc_attr($photoNonce); ?>"
                        data-ajax-url="<?php echo esc_url(admin_url('admin-ajax.php')); ?>"
                        data-player-name="<?php echo esc_attr((string) $player->name); ?>"></div>
                    <script>
                    (function(){
                        var w=document.getElementById('cp-player-profile-photo-mount');
                        if(!w||w.getAttribute('data-cp-photo-loaded'))return;
                        w.setAttribute('data-cp-photo-loaded','1');
                        var u=w.getAttribute('data-ajax-url')||'',tid=w.getAttribute('data-tournament-id'),pid=w.getAttribute('data-player-id'),n=w.getAttribute('data-nonce');
                        if(!u||!tid||!pid||!n)return;
                        var body='action=chess_podium_player_photo&tournament_id='+encodeURIComponent(tid)+'&player_id='+encodeURIComponent(pid)+'&_ajax_nonce='+encodeURIComponent(n);
                        fetch(u,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body,credentials:'same-origin'})
                            .then(function(r){return r.json();})
                            .then(function(j){
                                if(!j||!j.success||!j.data||!j.data.url)return;
                                var img=document.createElement('img');
                                img.src=j.data.url;
                                img.alt=w.getAttribute('data-player-name')||'';
                                img.style.cssText='width:110px;height:110px;object-fit:cover;border-radius:8px;border:1px solid #d1d5db;';
                                w.parentNode.insertBefore(img,w);
                                w.remove();
                            }).catch(function(){});
                    })();
                    </script>
                <?php endif; ?>
                <div style="flex:1;min-width:240px;">
                    <p style="margin:0 0 .4rem 0;font-size:1.05rem;"><?php echo esc_html__('Rating', 'chess-podium'); ?>: <strong><?php echo (int) ($player->rating ?? 0); ?></strong><?php if (!empty($player->fide_id)): ?> | FIDE ID: <strong><?php echo esc_html((string) $player->fide_id); ?></strong><?php endif; ?></p>
                    <p style="margin:.15rem 0;"><?php echo esc_html__('Games played', 'chess-podium'); ?>: <strong><?php echo (int) $gamesPlayed; ?></strong><?php if ($stats): ?> | W-D-L: <strong><?php echo (int) ($stats['wins'] ?? 0); ?>-<?php echo (int) ($stats['draws'] ?? 0); ?>-<?php echo (int) ($stats['losses'] ?? 0); ?></strong><?php endif; ?></p>
                    <p style="margin:.15rem 0;"><?php echo esc_html__('Performance', 'chess-podium'); ?>: <strong><?php echo (int) $perf; ?></strong> | <?php echo esc_html__('Variation', 'chess-podium'); ?>: <strong><?php echo esc_html($eloVarText); ?></strong><?php if ($standing): ?> | <?php echo esc_html__('Pos', 'chess-podium'); ?>: <strong><?php echo (int) ($standing['position'] ?? 0); ?></strong><?php endif; ?></p>
                    <?php if ($unified && !empty($unified->bio)): ?>
                        <div style="margin-top:0.75rem;color:#111827;line-height:1.55;">
                            <?php echo wp_kses_post(wpautop((string) $unified->bio)); ?>
                        </div>
                    <?php endif; ?>
                </div>
            </div>

            <h3 style="margin:1.25rem 0 0.5rem 0;font-size:1.05rem;"><?php echo esc_html__('Standings', 'chess-podium'); ?></h3>
            <?php if (empty($standingsWithRank)): ?>
                <p class="muted"><?php echo esc_html__('Standings not available.', 'chess-podium'); ?></p>
            <?php else: ?>
            <div style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
            <table style="min-width:640px;width:100%;border-collapse:collapse;">
                <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Nr', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><?php foreach ($tiebreakerCols as $label): ?><th><?php echo esc_html($label); ?></th><?php endforeach; ?></tr></thead>
                <tbody>
                <?php foreach ($standingsWithRank as $s):
                    $pid = (int) ($s['player_id'] ?? 0);
                    $isSel = $pid === $playerId;
                    $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, isset($s['title']) ? (string) $s['title'] : null);
                    }
                    $display = wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) ? (int) $s['rating'] : null, !empty($norms) ? $norms : null));
                    $profileUrl = self::build_player_profile_url($tid, $pid);
                    ?>
                    <tr style="background:<?php echo $isSel ? '#dbeafe' : 'transparent'; ?>;font-weight:<?php echo $isSel ? '700' : '400'; ?>;">
                        <td><?php echo (int) ($s['pos'] ?? 0); ?></td>
                        <td><?php echo esc_html((string) ($s['start_rank'] ?? '-')); ?></td>
                        <td><?php echo $pid > 0 ? '<a href="' . esc_url($profileUrl) . '" style="text-decoration:none;color:inherit;">' . $display . '</a>' : $display; ?></td>
                        <td><?php echo esc_html(number_format((float) ($s['points'] ?? 0), 1)); ?></td>
                        <?php foreach (array_keys($tiebreakerCols) as $key):
                            $val = $s[$key] ?? 0;
                            echo '<td>' . esc_html(in_array($key, ['rating'], true) ? (string) (int) $val : number_format((float) $val, 2)) . '</td>';
                        endforeach; ?>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            </div>
            <?php endif; ?>

            <h3 style="margin:1.25rem 0 0.5rem 0;font-size:1.05rem;"><?php echo esc_html__('Games', 'chess-podium'); ?></h3>
            <?php if (empty($gamesRows)): ?>
                <p class="muted"><?php echo esc_html__('No games yet.', 'chess-podium'); ?></p>
            <?php else: ?>
            <div style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
            <table style="min-width:520px;width:100%;border-collapse:collapse;">
                <thead><tr><th><?php echo esc_html__('Round', 'chess-podium'); ?></th><th><?php echo esc_html__('Colour', 'chess-podium'); ?></th><th><?php echo esc_html__('Opponent', 'chess-podium'); ?></th><th><?php echo esc_html__('Result', 'chess-podium'); ?></th><th></th></tr></thead>
                <tbody>
                <?php foreach ($gamesRows as $gr):
                    $oppUrl = $gr['opponent_id'] > 0 ? self::build_player_profile_url($tid, (int) $gr['opponent_id']) : '';
                    $gameUrl = self::get_game_url((int) $gr['pairing_id'], $tid);
                    ?>
                    <tr>
                        <td><?php echo (int) $gr['round']; ?></td>
                        <td><?php echo esc_html((string) $gr['colour']); ?></td>
                        <td><?php echo $oppUrl !== '' ? '<a href="' . esc_url($oppUrl) . '" style="text-decoration:none;color:inherit;">' . esc_html($gr['opponent_name']) . '</a>' : esc_html($gr['opponent_name']); ?></td>
                        <td><?php echo esc_html($gr['result'] !== '' ? $gr['result'] : __('in progress', 'chess-podium')); ?></td>
                        <td><a href="<?php echo esc_url($gameUrl); ?>"><?php echo esc_html__('Game', 'chess-podium'); ?> →</a></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            </div>
            <?php endif; ?>
        </section>
        <?php
        return (string) ob_get_clean();
    }

    public static function render_brochure_shortcode(array $atts = []): string
    {
        $atts = shortcode_atts(['id' => 0], $atts, 'chess_podium_brochure');
        $id = $atts['id'];
        if (is_string($id) && strpos($id, 'ext_') === 0) {
            $ext = self::get_external_tournament_by_id($id);
            if ($ext && !empty($ext['brochure_url'])) {
                $url = esc_url($ext['brochure_url']);
                return '<div class="cp-brochure-external"><p><a href="' . $url . '" target="_blank" rel="noopener" class="button">' . esc_html__('Download brochure (PDF)', 'chess-podium') . '</a></p><iframe src="' . $url . '" width="100%" height="800" style="border:1px solid #ddd;"></iframe></div>';
            }
            return '<p>' . esc_html__('No brochure available for this tournament.', 'chess-podium') . '</p>';
        }
        $tid = (int) $id;
        $tournament = $tid > 0 ? self::get_tournament($tid) : self::get_latest_tournament();
        if (!$tournament) {
            return '<p>' . esc_html__('No tournament available.', 'chess-podium') . '</p>';
        }
        $customUrl = ChessPodium_PdfBrochure::get_meta((int) $tournament->id, 'custom_brochure_pdf_url');
        if (!empty($customUrl)) {
            $url = esc_url($customUrl);
            return '<div class="cp-brochure-custom"><p><a href="' . $url . '" target="_blank" rel="noopener" class="button">' . esc_html__('Download brochure (PDF)', 'chess-podium') . '</a></p><iframe src="' . $url . '" width="100%" height="800" style="border:1px solid #ddd;"></iframe></div>';
        }
        return ChessPodium_PdfBrochure::generate_html($tournament);
    }

    public static function render_club_ranking_shortcode(array $atts = []): string
    {
        $standings = ChessPodium_ClubRanking::get_club_standings();
        if (empty($standings)) {
            return '<div class="cp-club-ranking"><p>' . esc_html__('No club ranking data. Publish tournaments to build the ranking.', 'chess-podium') . '</p></div>';
        }
        ob_start();
        ?>
        <div class="cp-club-ranking">
            <h2><?php esc_html_e('Club ranking', 'chess-podium'); ?></h2>
            <table class="cp-club-table">
                <thead><tr><th><?php esc_html_e('Pos', 'chess-podium'); ?></th><th><?php esc_html_e('Player', 'chess-podium'); ?></th><th><?php esc_html_e('Tournaments', 'chess-podium'); ?></th><th><?php esc_html_e('Total points', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($standings as $pos => $s): ?>
                    <tr>
                        <td><?php echo (int) ($pos + 1); ?></td>
                        <td><?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag($s['country'] ?? '', $s['country'] ?? '')); ?> <?php echo esc_html($s['name']); ?></td>
                        <td><?php echo (int) $s['tournaments']; ?></td>
                        <td><?php echo esc_html(number_format((float) $s['total_points'], 1)); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php
        return ob_get_clean();
    }

    /**
     * Find a tournament roster row matching club aggregate profile (for photo cache).
     *
     * @param array<string, mixed> $profile From ChessPodium_ClubRanking::get_player_profile().
     * @return array{tournament: ?object, player: ?object}
     */
    private static function find_roster_context_for_club_profile(array $profile): array
    {
        $fProfile = self::sanitize_fide_id((string) ($profile['fide_id'] ?? ''));
        $nameProfile = trim((string) ($profile['name'] ?? ''));
        foreach ($profile['history'] ?? [] as $h) {
            $tid = (int) ($h['tournament_id'] ?? 0);
            if ($tid <= 0) {
                continue;
            }
            $tournament = self::get_tournament($tid);
            if (!$tournament) {
                continue;
            }
            foreach (self::get_players($tid) as $p) {
                if ($fProfile !== '') {
                    $fP = self::sanitize_fide_id((string) ($p->fide_id ?? ''));
                    if ($fP !== '' && $fP === $fProfile) {
                        return ['tournament' => $tournament, 'player' => $p];
                    }
                } elseif ($nameProfile !== '' && strcasecmp(trim((string) ($p->name ?? '')), $nameProfile) === 0) {
                    return ['tournament' => $tournament, 'player' => $p];
                }
            }
        }

        return ['tournament' => null, 'player' => null];
    }

    /**
     * Unified public profile URL + avatar for shortcode header.
     *
     * @param array<string, mixed> $profile From ChessPodium_ClubRanking::get_player_profile().
     * @return array{url: string, avatar: string}
     */
    private static function resolve_player_profile_shortcode_header(array $profile): array
    {
        $url = '';
        $avatar = '';
        $unified = null;
        if (class_exists('ChessPodium_PlayerDirectory')) {
            $fid = self::sanitize_fide_id((string) ($profile['fide_id'] ?? ''));
            if ($fid !== '') {
                $unified = ChessPodium_PlayerDirectory::find_by_fide_id($fid);
            }
            if (!$unified && trim((string) ($profile['name'] ?? '')) !== '') {
                $unified = ChessPodium_PlayerDirectory::find_by_name((string) $profile['name']);
            }
            if ($unified && !empty($unified->slug)) {
                $url = self::get_unified_player_profile_url((string) $unified->slug);
                $aid = (int) ($unified->avatar_attachment_id ?? 0);
                if ($aid > 0) {
                    $u = wp_get_attachment_image_url($aid, 'thumbnail');
                    if (is_string($u) && $u !== '') {
                        $avatar = $u;
                    }
                }
            }
        }
        if ($avatar === '') {
            $ctx = self::find_roster_context_for_club_profile($profile);
            if ($ctx['player'] && $ctx['tournament']) {
                $ph = self::resolve_player_profile_photo_url_from_cache_only($ctx['player'], $ctx['tournament']);
                if (is_string($ph) && $ph !== '') {
                    $avatar = $ph;
                }
            }
        }

        return ['url' => $url, 'avatar' => $avatar];
    }

    public static function render_player_profile_shortcode(array $atts = []): string
    {
        $atts = shortcode_atts(['fide_id' => '', 'name' => ''], $atts, 'chess_podium_player_profile');
        $identifier = trim((string) $atts['fide_id']) ?: trim((string) $atts['name']);
        if ($identifier === '') {
            return '<p>' . esc_html__('Specify fide_id or name attribute.', 'chess-podium') . '</p>';
        }
        $profile = ChessPodium_ClubRanking::get_player_profile($identifier);
        if (!$profile) {
            return '<p>' . esc_html__('Player not found.', 'chess-podium') . '</p>';
        }
        $header = self::resolve_player_profile_shortcode_header($profile);
        $fullStatsUrl = $header['url'];
        $avatarUrl = $header['avatar'];
        $displayName = (string) ($profile['name'] ?? '');
        $country = trim((string) ($profile['country'] ?? ''));
        $flagHtml = ($country !== '' && class_exists('ChessPodium_CountryHelper'))
            ? ChessPodium_CountryHelper::render_flag_img($country, $country, 22)
            : '';
        ob_start();
        ?>
        <div class="cp-player-profile">
            <?php if ($fullStatsUrl !== ''): ?>
            <h2 class="cp-player-profile-title">
                <a class="cp-player-profile-cardlink" href="<?php echo esc_url($fullStatsUrl); ?>" title="<?php echo esc_attr__('Full player profile and statistics', 'chess-podium'); ?>">
                    <?php if ($avatarUrl !== ''): ?>
                    <img class="cp-player-profile-avatar" src="<?php echo esc_url($avatarUrl); ?>" alt="<?php echo esc_attr($displayName); ?>" width="72" height="72" loading="lazy" decoding="async">
                    <?php endif; ?>
                    <span class="cp-player-profile-name-inner">
                        <?php if ($flagHtml !== ''): ?><?php echo wp_kses_post($flagHtml); ?><?php endif; ?>
                        <span class="cp-player-profile-name-text"><?php echo esc_html($displayName); ?></span>
                    </span>
                </a>
            </h2>
            <?php else: ?>
            <h2 class="cp-player-profile-title">
                <span class="cp-player-profile-head-plain">
                    <?php if ($avatarUrl !== ''): ?>
                    <img class="cp-player-profile-avatar" src="<?php echo esc_url($avatarUrl); ?>" alt="<?php echo esc_attr($displayName); ?>" width="72" height="72" loading="lazy" decoding="async">
                    <?php endif; ?>
                    <span class="cp-player-profile-name-inner">
                        <?php if ($flagHtml !== ''): ?><?php echo wp_kses_post($flagHtml); ?><?php endif; ?>
                        <span class="cp-player-profile-name-text"><?php echo esc_html($displayName); ?></span>
                    </span>
                </span>
            </h2>
            <?php endif; ?>
            <?php if (!empty($profile['fide_id'])): ?><p><?php esc_html_e('FIDE ID', 'chess-podium'); ?>: <?php echo esc_html($profile['fide_id']); ?></p><?php endif; ?>
            <p><?php echo esc_html(sprintf(__('%d tournaments', 'chess-podium'), $profile['total_tournaments'])); ?> &ndash; <?php echo esc_html(number_format($profile['total_points'], 1)); ?> <?php esc_html_e('points', 'chess-podium'); ?></p>
            <h3><?php esc_html_e('Tournament history', 'chess-podium'); ?></h3>
            <table class="cp-profile-table">
                <thead><tr><th><?php esc_html_e('Tournament', 'chess-podium'); ?></th><th><?php esc_html_e('Position', 'chess-podium'); ?></th><th><?php esc_html_e('Points', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($profile['history'] as $h): ?>
                    <tr>
                        <td><?php echo esc_html($h['tournament_name']); ?></td>
                        <td><?php echo (int) $h['position']; ?>°</td>
                        <td><?php echo esc_html(number_format((float) $h['points'], 1)); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        </div>
        <?php
        return ob_get_clean();
    }

    private static function compute_standings_before_round(int $tournamentId, int $roundNo): array
    {
        $tournament = self::get_tournament($tournamentId);
        $byePoints = ($tournament && isset($tournament->bye_points)) ? (float) $tournament->bye_points : 1.0;
        $pointsSystem = ($tournament && isset($tournament->points_system)) ? strtolower((string) $tournament->points_system) : 'standard';
        if ($pointsSystem === '310') {
            $byePoints = $byePoints * 3.0;
        }
        $players = self::get_players($tournamentId);
        $pairings = self::get_pairings($tournamentId);
        $pairings = array_filter($pairings, static function ($m) use ($roundNo) {
            return (int) $m->round_no < $roundNo;
        });

        $scores = [];
        $opponents = [];
        foreach ($players as $p) {
            $scores[(int) $p->id] = 0.0;
            $opponents[(int) $p->id] = [];
        }
        foreach ($pairings as $match) {
            $w = (int) $match->white_player_id;
            $b = (int) $match->black_player_id;
            $res = self::normalize_result((string) ($match->result ?? ''));
            $isBye = (int) $match->is_bye === 1;
            if ($isBye) {
                $pts = isset($match->bye_points) && $match->bye_points !== null ? (float) $match->bye_points : $byePoints;
                if ($pointsSystem === '310' && isset($match->bye_points) && $match->bye_points !== null) {
                    $pts *= 3.0;
                }
                $scores[$w] = ($scores[$w] ?? 0) + $pts;
                continue;
            }
            $opponents[$w][] = $b;
            $opponents[$b][] = $w;
            $wPts = self::get_points_for_result($res, true, $pointsSystem);
            $bPts = self::get_points_for_result($res, false, $pointsSystem);
            $scores[$w] = ($scores[$w] ?? 0) + $wPts;
            $scores[$b] = ($scores[$b] ?? 0) + $bPts;
        }
        return $scores;
    }

    private static function get_starting_ranks(int $tournamentId): array
    {
        $players = self::get_players($tournamentId);
        usort($players, static function ($a, $b) {
            $sa = isset($a->start_number) && (int) $a->start_number > 0 ? (int) $a->start_number : null;
            $sb = isset($b->start_number) && (int) $b->start_number > 0 ? (int) $b->start_number : null;
            if ($sa !== null && $sb !== null) {
                return $sa <=> $sb;
            }
            if ($sa !== null) {
                return -1;
            }
            if ($sb !== null) {
                return 1;
            }
            $ra = (int) ($a->rating ?? 0);
            $rb = (int) ($b->rating ?? 0);
            if ($ra !== $rb) {
                return $rb <=> $ra;
            }
            // Same rating: FIDE ID ascending (Swiss Manager tiebreak), then name, then title
            $fideA = isset($a->fide_id) && (string) $a->fide_id !== '' ? (int) $a->fide_id : 999999999;
            $fideB = isset($b->fide_id) && (string) $b->fide_id !== '' ? (int) $b->fide_id : 999999999;
            if ($fideA !== $fideB) {
                return $fideA <=> $fideB;
            }
            $nameA = strtolower(trim((string) ($a->name ?? '')));
            $nameB = strtolower(trim((string) ($b->name ?? '')));
            if ($nameA !== $nameB) {
                return strcmp($nameA, $nameB);
            }
            $titleA = isset($a->title) ? (string) $a->title : '';
            $titleB = isset($b->title) ? (string) $b->title : '';
            return strcmp($titleA, $titleB);
        });
        $ranks = [];
        foreach ($players as $i => $p) {
            $ranks[(int) $p->id] = $i + 1;
        }
        return $ranks;
    }

    private static function format_player_display(string $name, ?string $title, ?string $country, bool $useImg = true, ?int $rating = null, ?array $norms = null): string
    {
        $flag = $country ? ($useImg ? ChessPodium_CountryHelper::render_flag_img($country, $country) : ChessPodium_CountryHelper::render_flag($country, $country)) . ' ' : '';
        $titleStr = $title ? esc_html(trim($title)) . ' ' : '';
        $ratingStr = ($rating !== null && $rating > 0) ? ' (' . (int) $rating . ')' : '';
        $normStr = '';
        if (!empty($norms) && class_exists('ChessPodium_FideNorm')) {
            $labels = [];
            foreach ($norms as $n) {
                $l = ChessPodium_FideNorm::get_norm_label($n);
                if ($l !== '') {
                    $labels[] = $l;
                }
            }
            if (!empty($labels)) {
                $normStr = ' <span class="cp-norm-badge" title="' . esc_attr(implode(', ', $labels)) . '">' . esc_html(implode(', ', $labels)) . '</span>';
            }
        }
        return $flag . $titleStr . esc_html($name) . $ratingStr . $normStr;
    }

    private static function maybe_fetch_pgn_sources_for_tournament(int $tournamentId): void
    {
        if ($tournamentId <= 0) {
            return;
        }
        $sources = ChessPodium_PgnLive::get_sources_for_tournament($tournamentId);
        if (empty($sources)) {
            return;
        }
        $transientKey = 'chess_podium_pgn_fetch_' . $tournamentId;
        if (get_transient($transientKey)) {
            return;
        }
        if (!empty(self::$pgnFetchShutdownScheduled[$tournamentId])) {
            return;
        }
        self::$pgnFetchShutdownScheduled[$tournamentId] = true;
        // Debounce across requests; fetch runs after response is flushed when the SAPI supports it.
        set_transient($transientKey, 1, 15);
        $tid = $tournamentId;
        add_action(
            'shutdown',
            static function () use ($tid): void {
                @ignore_user_abort(true);
                if (function_exists('fastcgi_finish_request')) {
                    @fastcgi_finish_request();
                } elseif (function_exists('litespeed_finish_request')) {
                    @litespeed_finish_request();
                }
                ChessPodium_PgnLive::fetch_sources_for_tournament($tid);
            },
            999
        );
    }

    private static function render_public_block(object $tournament, int $roundOverride = 0): string
    {
        $tid = (int) $tournament->id;
        $displayRound = $roundOverride > 0 ? $roundOverride : (int) $tournament->current_round;
        $players = self::get_players($tid);
        $playerCount = count($players);
        $selectedPlayerId = max(0, (int) get_query_var('cp_player'));
        if ($selectedPlayerId <= 0 && isset($_GET['cp_player'])) {
            $selectedPlayerId = max(0, (int) $_GET['cp_player']);
        }
        $playerSlugFromUrl = sanitize_title((string) get_query_var('cp_player_slug'));
        if ($playerSlugFromUrl === '' && isset($_GET['cp_player_slug'])) {
            $playerSlugFromUrl = sanitize_title((string) wp_unslash($_GET['cp_player_slug']));
        }
        if ($selectedPlayerId <= 0 && $playerSlugFromUrl !== '') {
            foreach ($players as $p) {
                $slug = self::get_player_seo_slug($p);
                if ($slug !== '' && $slug === $playerSlugFromUrl) {
                    $selectedPlayerId = (int) ($p->id ?? 0);
                    break;
                }
            }
        }

        // Avoid blocking player profile loads on external PGN fetches (same as main tournament view).
        if ($selectedPlayerId <= 0) {
            self::maybe_fetch_pgn_sources_for_tournament($tid);
        }

        $useAsyncStandings = $playerCount > self::ASYNC_STANDINGS_PLAYER_THRESHOLD && $selectedPlayerId <= 0;

        $standings = $useAsyncStandings ? [] : self::compute_standings($tid);
        $pairings = self::get_pairings_for_round($tid, $displayRound);
        $pointsBeforeRound = $displayRound > 0 ? self::compute_standings_before_round($tid, $displayRound) : [];
        $startingRanks = self::get_starting_ranks($tid);

        $venuePhotoUrl = self::get_venue_photo_url($tid);
        $allPairings = $useAsyncStandings ? [] : self::get_pairings_detailed($tid);
        $playerStats = (!$useAsyncStandings && class_exists('ChessPodium_ExportEngine')) ? ChessPodium_ExportEngine::get_player_stats($players, $allPairings) : [];
        $playersById = [];
        foreach ($players as $pp) {
            $playersById[(int) ($pp->id ?? 0)] = $pp;
        }

        $logoUrl = class_exists('ChessPodium_PdfBrochure') ? (ChessPodium_PdfBrochure::get_meta($tid, 'brochure_logo') ?: '') : '';
        if ($logoUrl === '') {
            $logoUrl = self::get_default_logo_url();
        }
        $isTeamTournament = !empty($tournament->is_team_tournament) && class_exists('ChessPodium_TeamTournaments');
        $livePairings = self::get_live_pairings_for_tournament($tid, (int) $displayRound);
        $hasLiveBoards = !empty($livePairings);
        ob_start();
        ?>
        <div class="cp-logo-banner" style="text-align:center;padding:1.5rem 0 1rem;margin-bottom:1rem;border-bottom:1px solid #e2e8f0;">
            <img src="<?php echo esc_url($logoUrl); ?>" alt="" class="cp-tournament-logo" loading="eager" style="max-height:200px;max-width:100%;width:auto;object-fit:contain;display:inline-block;">
        </div>
        <?php if ($venuePhotoUrl): ?>
        <figure class="cp-venue-hero" style="margin:0 0 1.5rem;padding:0;"><img src="<?php echo esc_url($venuePhotoUrl); ?>" alt="<?php echo esc_attr__('Venue', 'chess-podium'); ?>" loading="eager" style="width:100%;max-height:320px;object-fit:cover;border-radius:8px;display:block;"></figure>
        <?php endif; ?>
        <h1><?php echo esc_html($tournament->name); ?></h1>
        <p><strong><?php echo esc_html__('Current round', 'chess-podium'); ?>:</strong> <?php echo (int) $displayRound; ?> / <?php echo (int) $tournament->rounds_total; ?> | <a href="#cp-elo-performance"><?php echo esc_html__('Elo performance', 'chess-podium'); ?></a><?php if ($hasLiveBoards): ?> | <a href="#cp-live-boards" class="cp-watch-live-btn"><?php echo esc_html(sprintf(__('Watch %d live board(s)', 'chess-podium'), count($livePairings))); ?></a><?php endif; ?><?php if (class_exists('ChessPodium_FairPlayDisplay') && ChessPodium_FairPlayDisplay::should_display($tournament)): ?> | <a href="#cp-fair-play"><?php echo esc_html__('Fair play', 'chess-podium'); ?></a><?php endif; ?></p>
        <?php
        // Allow access to previous rounds while the tournament is still in progress (not published yet).
        $roundMax = (int) $tournament->current_round;
        $seoSlug = self::get_tournament_seo_slug($tournament);
        if ($roundMax > 1):
            $roundMax = min($roundMax, (int) $tournament->rounds_total);
        ?>
            <div class="cp-round-nav" style="margin:1rem 0 0.75rem;display:flex;gap:0.4rem;align-items:center;flex-wrap:wrap;">
                <span style="font-size:0.95em;color:#6b7280;font-weight:600;"><?php echo esc_html__('Rounds', 'chess-podium'); ?>:</span>
                <?php for ($r = 1; $r <= $roundMax; $r++):
                    $isCurrent = $r === (int) $displayRound;
                    $url = $seoSlug !== ''
                        ? self::get_tournament_round_pretty_slug_url($seoSlug, $r)
                        : self::get_tournament_round_pretty_url((int) $tournament->id, $r);
                    ?>
                    <a href="<?php echo esc_url($url); ?>" style="text-decoration:none;color:inherit;padding:0.35rem 0.6rem;border-radius:8px;border:1px solid #e2e8f0;background:<?php echo $isCurrent ? '#22c55e' : '#f8fafc'; ?>;color:<?php echo $isCurrent ? '#ffffff' : '#111827'; ?>;font-weight:<?php echo $isCurrent ? '800' : '600'; ?>;">
                        <?php echo (int) $r; ?>
                    </a>
                <?php endfor; ?>
            </div>
        <?php endif; ?>
        <?php
        if (class_exists('ChessPodium_FairPlayDisplay') && ChessPodium_FairPlayDisplay::should_display($tournament)) {
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML built with esc_* inside helper
            echo ChessPodium_FairPlayDisplay::render_section_html($tournament);
        }
        ?>
        <?php if ($selectedPlayerId > 0): ?>
            <?php echo self::render_tournament_player_profile($tournament, $selectedPlayerId, $playersById, $standings, $playerStats, $allPairings, $startingRanks); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
        <?php endif; ?>

        <?php if ($isTeamTournament): ?>
            <?php
            $teamPrimary = isset($tournament->team_scoring) ? (string) $tournament->team_scoring : 'mp';
            $teamStandings = ChessPodium_TeamTournaments::compute_standings($tid, $teamPrimary);
            $teams = ChessPodium_TeamTournaments::get_teams($tid);
            $teamsById = [];
            foreach ($teams as $tt) {
                $teamsById[(int) $tt->id] = $tt;
            }
            $teamMatches = ChessPodium_TeamTournaments::get_matches($tid, (int) $displayRound);
            ?>
            <h2><?php echo esc_html__('Team standings', 'chess-podium'); ?></h2>
            <?php if (empty($teamStandings)): ?>
                <p><?php echo esc_html__('No team results yet.', 'chess-podium'); ?></p>
            <?php else: ?>
                <div class="cp-team-standings-wrap" style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
                <table style="min-width:520px;width:100%;">
                    <thead><tr>
                        <th><?php echo esc_html__('Pos', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('Team', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('MP', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('BP', 'chess-podium'); ?></th>
                        <th><?php echo esc_html__('Played', 'chess-podium'); ?></th>
                    </tr></thead>
                    <tbody>
                    <?php foreach ($teamStandings as $i => $r): ?>
                        <tr>
                            <td><?php echo (int) ($i + 1); ?></td>
                            <td><?php echo esc_html((string) ($r['name'] ?? '')); ?></td>
                            <td><?php echo esc_html((string) ((int) ($r['mp'] ?? 0))); ?></td>
                            <td><?php echo esc_html(number_format((float) ($r['bp'] ?? 0.0), 1)); ?></td>
                            <td><?php echo esc_html((string) ((int) ($r['played'] ?? 0))); ?></td>
                        </tr>
                    <?php endforeach; ?>
                    </tbody>
                </table>
                </div>
            <?php endif; ?>

            <h2><?php echo esc_html(sprintf(__('Round %d matches', 'chess-podium'), (int) $displayRound)); ?></h2>
            <?php if (empty($teamMatches)): ?>
                <p><?php echo esc_html__('No matches generated yet.', 'chess-podium'); ?></p>
            <?php else: ?>
                <?php foreach ($teamMatches as $m): ?>
                    <?php
                    $mid = (int) ($m->id ?? 0);
                    $homeId = (int) ($m->home_team_id ?? 0);
                    $awayId = (int) ($m->away_team_id ?? 0);
                    $homeName = isset($teamsById[$homeId]) ? (string) ($teamsById[$homeId]->name ?? '') : ('#' . $homeId);
                    $awayName = isset($teamsById[$awayId]) ? (string) ($teamsById[$awayId]->name ?? '') : ('#' . $awayId);
                    $boards = ChessPodium_TeamTournaments::get_match_boards($tid, $mid);
                    ?>
                    <div class="cp-team-match" style="margin:1em 0;padding:12px;border:1px solid #e2e8f0;border-radius:10px;background:#fafafa;">
                        <h3 style="margin:0 0 10px;font-size:1.1em;"><?php echo esc_html($homeName . ' vs ' . $awayName); ?></h3>
                        <div style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
                        <table style="min-width:640px;width:100%;">
                            <thead><tr>
                                <th><?php echo esc_html__('Board', 'chess-podium'); ?></th>
                                <th><?php echo esc_html($homeName); ?></th>
                                <th><?php echo esc_html__('Result', 'chess-podium'); ?></th>
                                <th><?php echo esc_html($awayName); ?></th>
                            </tr></thead>
                            <tbody>
                            <?php foreach ($boards as $b): ?>
                                <?php
                                $bno = (int) ($b->board_no ?? 0);
                                $homePid = (int) ($b->home_player_id ?? 0);
                                $awayPid = (int) ($b->away_player_id ?? 0);
                                $homePlayer = $homePid > 0 ? ($playersById[$homePid]->name ?? '') : '';
                                $awayPlayer = $awayPid > 0 ? ($playersById[$awayPid]->name ?? '') : '';
                                $res = trim((string) ($b->result ?? ''));
                                ?>
                                <tr>
                                    <td><?php echo esc_html((string) $bno); ?></td>
                                    <td><?php echo esc_html((string) $homePlayer); ?></td>
                                    <td><?php echo esc_html($res !== '' ? $res : '—'); ?></td>
                                    <td><?php echo esc_html((string) $awayPlayer); ?></td>
                                </tr>
                            <?php endforeach; ?>
                            </tbody>
                        </table>
                        </div>
                    </div>
                <?php endforeach; ?>
            <?php endif; ?>
        <?php endif; ?>

        <?php if (!$isTeamTournament): ?>
        <h2><?php echo $roundOverride > 0 ? esc_html(sprintf(__('Round %d pairings', 'chess-podium'), (int) $displayRound)) : esc_html__('Current round pairings', 'chess-podium'); ?></h2>
        <?php if (empty($pairings)): ?>
            <p><?php echo esc_html__('No pairings generated yet.', 'chess-podium'); ?></p>
        <?php else:
            $fixedBoards = self::get_fixed_board_assignments($tid, $displayRound);
            $hasFixedBoard = !empty($fixedBoards);
        ?>
            <div class="cp-pairings-table-wrap" style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
            <table style="min-width:820px;width:100%;">
                <thead><tr><th><?php echo esc_html__('Board', 'chess-podium'); ?></th><th><?php echo esc_html__('White', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><th><?php echo esc_html__('Nr', 'chess-podium'); ?></th><th><?php echo esc_html__('Result', 'chess-podium'); ?></th><th><?php echo esc_html__('Nr', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><th><?php echo esc_html__('Black', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($pairings as $i => $p):
                    $whiteId = (int) $p->white_player_id;
                    $blackId = (int) $p->black_player_id;
                    $whitePts = $pointsBeforeRound[$whiteId] ?? 0;
                    $blackPts = (int) $p->is_bye === 1 ? null : ($pointsBeforeRound[$blackId] ?? 0);
                    $whiteRank = $startingRanks[$whiteId] ?? '-';
                    $blackRank = (int) $p->is_bye === 1 ? null : ($startingRanks[$blackId] ?? '-');
                    $whiteFixed = isset($fixedBoards[$whiteId]);
                    $blackFixed = (int) $p->is_bye !== 1 && isset($fixedBoards[$blackId]);
                    $whiteProfileUrl = $whiteId > 0 ? self::build_player_profile_url($tid, $whiteId) : '';
                    $blackProfileUrl = ((int) $p->is_bye !== 1 && $blackId > 0) ? self::build_player_profile_url($tid, $blackId) : '';
                    $whiteCell = wp_kses_post(self::format_player_display($p->white_name ?? '', $p->white_title ?? null, $p->white_country ?? null, true));
                    if ($whiteProfileUrl !== '') {
                        $whiteCell = '<a href="' . esc_url($whiteProfileUrl) . '" class="cp-player-link">' . $whiteCell . '</a>';
                    }
                    $blackCell = (int) $p->is_bye === 1
                        ? ($p->result === 'NOT_PAIRED' ? '(' . esc_html__('not paired', 'chess-podium') . ')' : '-')
                        : wp_kses_post(self::format_player_display($p->black_name ?? '', $p->black_title ?? null, $p->black_country ?? null, true));
                    if ((int) $p->is_bye !== 1 && $blackProfileUrl !== '') {
                        $blackCell = '<a href="' . esc_url($blackProfileUrl) . '" class="cp-player-link">' . $blackCell . '</a>';
                    }
                ?>
                    <tr>
                        <td><?php echo (int) ($i + 1); ?></td>
                        <td><?php echo wp_kses_post($whiteCell); ?><?php echo $whiteFixed ? ' <sup>*</sup>' : ''; ?></td>
                        <td><?php echo $whitePts !== null ? '(' . number_format((float) $whitePts, 1) . ')' : '-'; ?></td>
                        <td><?php echo esc_html((string) $whiteRank); ?></td>
                        <td><?php echo esc_html($p->result ?: ((int) $p->is_bye === 1 ? ($p->result === 'NOT_PAIRED' ? __('not paired', 'chess-podium') : 'BYE') : __('in progress', 'chess-podium'))); ?><?php echo !empty($p->is_live) ? ' <span class="cp-live-badge" title="' . esc_attr__('Live transmission', 'chess-podium') . '">' . esc_html__('Live', 'chess-podium') . '</span>' : ''; ?></td>
                        <td><?php echo $blackRank !== null ? esc_html((string) $blackRank) : '-'; ?></td>
                        <td><?php echo $blackPts !== null ? '(' . number_format((float) $blackPts, 1) . ')' : '-'; ?></td>
                        <td><?php echo wp_kses_post($blackCell); ?><?php echo ((int) $p->is_bye !== 1 && $blackFixed) ? ' <sup>*</sup>' : ''; ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            </div>
            <?php if ($hasFixedBoard): ?>
            <p class="cp-pairing-footnote" style="font-size:0.9em;color:#666;margin-top:0.5rem;"><sup>*</sup> <?php echo esc_html__('Player assigned to a fixed board.', 'chess-podium'); ?></p>
            <?php endif; ?>
        <?php endif; ?>
        <?php endif; ?>

        <?php if ($hasLiveBoards):
            $restUrl = esc_url(rest_url('chess-podium/v1/live-moves'));
            $liveMovesUrl = add_query_arg('tournament_id', $tid, $restUrl);
            $defaultFen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
            $archivedPgnByPairing = self::get_latest_archived_pgns_by_pairing_ids(
                $tid,
                array_map(static fn ($lp) => (int) ($lp->id ?? 0), $livePairings)
            );
        ?>
        <h2 id="cp-live-boards"><?php echo esc_html__('Live boards', 'chess-podium'); ?></h2>
        <p class="cp-live-hint" style="color:#666;font-size:0.9em;margin-bottom:1rem;"><?php echo esc_html__('Games transmitted live from DGT boards or PGN feed. Updates every 1–2 seconds. Select a board to open the full game replay page.', 'chess-podium'); ?></p>
        <div class="cp-live-boards-container" data-live-moves-url="<?php echo esc_attr($liveMovesUrl); ?>" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1.5rem;margin-bottom:2rem;">
            <?php foreach ($livePairings as $idx => $lp):
                $boardId = 'cp-live-board-' . (int) $lp->id;
                $fen = !empty($lp->live_fen) ? $lp->live_fen : $defaultFen;
                $pid = (int) ($lp->id ?? 0);
                $pgn = !empty($lp->live_pgn) ? $lp->live_pgn : (($pid > 0 && isset($archivedPgnByPairing[$pid])) ? $archivedPgnByPairing[$pid] : '');
                $res = trim((string) ($lp->result ?? ''));
                // SEO-friendly: /tournament/{tournament-slug}/game/{pairingId}/
                $gameUrl = self::get_game_url((int) $lp->id, $tid);
            ?>
            <a href="<?php echo esc_url($gameUrl); ?>" class="cp-live-board-block cp-live-board-link" data-pairing-id="<?php echo (int) $lp->id; ?>" data-round="<?php echo (int) $lp->round_no; ?>" data-white="<?php echo esc_attr($lp->white_name ?? ''); ?>" data-black="<?php echo esc_attr($lp->black_name ?? ''); ?>" data-fen="<?php echo esc_attr($fen); ?>" data-pgn="<?php echo esc_attr($pgn); ?>" data-result="<?php echo esc_attr($res); ?>" style="border:1px solid #e2e8f0;border-radius:8px;padding:1rem;background:#fafafa;text-decoration:none;color:inherit;display:block;">
                <div class="cp-live-board-header" style="margin-bottom:0.5rem;font-weight:600;font-size:0.9rem;line-height:1.3;">
                    <?php echo esc_html(sprintf(__('Round %d', 'chess-podium'), (int) $lp->round_no)); ?>: <?php echo esc_html($lp->white_name ?? ''); ?> vs <?php echo esc_html($lp->black_name ?? ''); ?><?php if ($res !== ''): ?> <span class="cp-live-result" style="color:#059669;font-weight:700;"><?php echo esc_html($res); ?></span><?php endif; ?>
                </div>
                <div id="<?php echo esc_attr($boardId); ?>" class="cp-live-board" style="width:280px;height:280px;margin:0 auto;pointer-events:none;" data-initial-fen="<?php echo esc_attr($fen); ?>"></div>
                <div class="cp-live-board-pgn-nav" data-pairing-id="<?php echo (int) $lp->id; ?>" style="margin-top:0.5rem;display:block!important;visibility:visible!important;">
                    <div class="cp-live-pgn-controls" style="display:flex!important;align-items:center;gap:0.4rem;flex-wrap:wrap;margin-bottom:0.25rem;">
                        <button type="button" class="cp-live-pgn-first" style="padding:0.3rem 0.6rem;font-size:1rem;cursor:pointer;background:#d1d5db;border:1px solid #9ca3af;border-radius:4px;color:#374151;" aria-label="<?php echo esc_attr__('Start', 'chess-podium'); ?>">|&larr;</button>
                        <button type="button" class="cp-live-pgn-prev" style="padding:0.3rem 0.6rem;font-size:1rem;cursor:pointer;background:#d1d5db;border:1px solid #9ca3af;border-radius:4px;color:#374151;" aria-label="<?php echo esc_attr__('Previous', 'chess-podium'); ?>">&larr;</button>
                        <span class="cp-live-pgn-info" style="min-width:4rem;font-size:0.9em;color:#6b7280;">—</span>
                        <button type="button" class="cp-live-pgn-next" style="padding:0.3rem 0.6rem;font-size:1rem;cursor:pointer;background:#d1d5db;border:1px solid #9ca3af;border-radius:4px;color:#374151;" aria-label="<?php echo esc_attr__('Next', 'chess-podium'); ?>">&rarr;</button>
                        <button type="button" class="cp-live-pgn-last" style="padding:0.3rem 0.6rem;font-size:1rem;cursor:pointer;background:#d1d5db;border:1px solid #9ca3af;border-radius:4px;color:#374151;" aria-label="<?php echo esc_attr__('End', 'chess-podium'); ?>">&rarr;|</button>
                    </div>
                    <div class="cp-live-pgn-moves" style="max-height:4.5em;overflow-y:auto;line-height:1.5;margin-top:0.25rem;"></div>
                </div>
                <p class="cp-live-open-hint" style="margin-top:0.5rem;font-size:0.85em;color:#2563eb;"><?php echo esc_html__('Open full game replay →', 'chess-podium'); ?></p>
            </a>
            <?php endforeach; ?>
        </div>
        <?php endif; ?>

        <h2><?php echo esc_html__('Standings', 'chess-podium'); ?></h2>
        <?php if ($useAsyncStandings): ?>
            <div id="cp-async-standings"
                 data-tournament-id="<?php echo (int) $tid; ?>"
                 data-per-page="50"
                 data-nonce="<?php echo esc_attr(wp_create_nonce('chess_podium_standings_' . $tid)); ?>">
                <p class="cp-async-hint" style="color:#666;font-size:0.9em;"><?php echo esc_html__('Loading standings…', 'chess-podium'); ?></p>
            </div>
            <?php self::output_async_standings_script(); ?>
        <?php elseif (empty($standings)): ?>
            <p><?php echo esc_html__('Standings not available.', 'chess-podium'); ?></p>
        <?php else: ?>
            <?php echo self::get_standings_table_html($tournament, $standings, $startingRanks); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
        <?php endif; ?>

        <h2 id="cp-elo-performance"><?php echo esc_html__('Elo performance', 'chess-podium'); ?></h2>
        <p class="cp-elo-hint" style="color:#666;font-size:0.9em;margin-bottom:1rem;"><?php echo esc_html__('Performance: tournament performance rating. Variation: FIDE Elo change (K=20 for rating < 2400, K=10 for 2400+, rating diff capped at ±400).', 'chess-podium'); ?></p>
        <?php if ($useAsyncStandings): ?>
            <div id="cp-async-performance"></div>
        <?php elseif (empty($standings)): ?>
            <p><?php echo esc_html__('Elo performance not available.', 'chess-podium'); ?></p>
        <?php else: ?>
            <table>
                <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Rating', 'chess-podium'); ?></th><th><?php echo esc_html__('Performance', 'chess-podium'); ?></th><th><?php echo esc_html__('Variation', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($standings as $pos => $s):
                    $pid = (int) ($s['player_id'] ?? 0);
                    $stats = $playerStats[$pid] ?? null;
                    $perf = $stats ? (int) $stats['performance'] : (int) ($s['rating'] ?? 0);
                    $eloVar = $stats ? (int) $stats['elo_variation'] : 0;
                    $eloStr = $eloVar > 0 ? '+' . $eloVar : (string) $eloVar;
                ?>
                    <tr>
                        <td><?php echo (int) ($pos + 1); ?></td>
                        <td><?php
                            $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, isset($s['title']) ? (string) $s['title'] : null);
                            }
                            $display = wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) ? (int) $s['rating'] : null, !empty($norms) ? $norms : null));
                            $profileUrl = self::build_player_profile_url((int) $tournament->id, (int) ($s['player_id'] ?? 0));
                            echo '<a href="' . esc_url($profileUrl) . '#cp-player-profile" style="text-decoration:none;color:inherit;">' . $display . '</a>';
                        ?></td>
                        <td><?php echo (int) ($s['rating'] ?? 0); ?></td>
                        <td><?php echo (int) $perf; ?></td>
                        <td><?php echo esc_html($eloStr); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        <?php endif; ?>

        <?php if (class_exists('ChessPodium_Sponsors')): ?>
            <div style="margin:2rem 0 1rem;padding:1.5rem;background:#f8fafc;border-top:1px solid #e2e8f0;">
                <?php echo ChessPodium_Sponsors::render_html('cp-sponsors-public', (int) $tournament->id); ?>
            </div>
        <?php endif; ?>
        <?php echo self::render_branding_footer_html(false); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
        <?php
        return (string) ob_get_clean();
    }

    public static function get_async_standings_payload(int $tournamentId, int $page, int $perPage): array
    {
        $t = self::get_tournament($tournamentId);
        if (!$t) {
            return [
                'html' => '<p>' . esc_html__('Standings not available.', 'chess-podium') . '</p>',
                'performance_html' => '',
                'page' => $page,
                'pages' => 1,
                'total' => 0,
            ];
        }

        $locale = function_exists('determine_locale') ? determine_locale() : (function_exists('get_locale') ? get_locale() : 'en_US');
        $cacheKey = 'cp_async_st_' . $tournamentId . '_v' . self::cache_version($tournamentId) . '_' . md5($locale . '|' . $page . '|' . $perPage);
        $cached = get_transient($cacheKey);
        if (is_array($cached) && isset($cached['html'])) {
            return $cached;
        }

        $standings = self::compute_standings($tournamentId);
        $startingRanks = self::get_starting_ranks($tournamentId);

        $total = count($standings);
        $pages = max(1, (int) ceil($total / $perPage));
        $page = min($pages, max(1, $page));
        $slice = array_slice($standings, ($page - 1) * $perPage, $perPage);

        $html = self::get_standings_table_html($t, $slice, $startingRanks, ($page - 1) * $perPage);

        $players = self::get_players($tournamentId);
        $allPairings = self::get_pairings_detailed($tournamentId);
        $playerStats = class_exists('ChessPodium_ExportEngine') ? ChessPodium_ExportEngine::get_player_stats($players, $allPairings) : [];
        $performanceHtml = self::get_performance_table_html($t, $standings, $playerStats);

        $payload = [
            'html' => $html,
            'performance_html' => $performanceHtml,
            'page' => $page,
            'pages' => $pages,
            'total' => $total,
        ];

        set_transient($cacheKey, $payload, 60);
        return $payload;
    }

    private static function get_standings_table_html(object $tournament, array $standings, array $startingRanks, int $posOffset = 0): string
    {
        $tiebreakerCols = self::get_tiebreaker_columns_for_display($tournament);
        ob_start();
        ?>
        <div class="cp-standings-wrap" style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
        <table style="min-width:820px;width:100%;">
            <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Nr', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><?php foreach ($tiebreakerCols as $label): ?><th><?php echo esc_html($label); ?></th><?php endforeach; ?></tr></thead>
            <tbody>
            <?php
            foreach ($standings as $idx => $s):
                $pid = (int) ($s['player_id'] ?? 0);
                $pos = $posOffset + $idx + 1;
                $startRank = $startingRanks[$pid] ?? '-';
            ?>
                <tr>
                    <td><?php echo (int) $pos; ?></td>
                    <td><?php echo esc_html((string) $startRank); ?></td>
                    <td><?php
                        $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, isset($s['title']) ? (string) $s['title'] : null);
                        }
                        $display = wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) ? (int) $s['rating'] : null, !empty($norms) ? $norms : null));
                        $profileUrl = self::build_player_profile_url((int) $tournament->id, $pid);
                        echo '<a href="' . esc_url($profileUrl) . '#cp-player-profile" style="text-decoration:none;color:inherit;">' . $display . '</a>';
                    ?></td>
                    <td><?php echo esc_html(number_format((float) ($s['points'] ?? 0), 1)); ?></td>
                    <?php foreach (array_keys($tiebreakerCols) as $key):
                        $val = $s[$key] ?? 0;
                        echo '<td>' . esc_html(in_array($key, ['rating'], true) ? (string) (int) $val : number_format((float) $val, 2)) . '</td>';
                    endforeach; ?>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
        </div>
        <?php
        return (string) ob_get_clean();
    }

    private static function get_performance_table_html(object $tournament, array $standings, array $playerStats): string
    {
        ob_start();
        ?>
        <div class="cp-performance-wrap" style="width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;">
        <table style="min-width:820px;width:100%;">
            <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Rating', 'chess-podium'); ?></th><th><?php echo esc_html__('Performance', 'chess-podium'); ?></th><th><?php echo esc_html__('Variation', 'chess-podium'); ?></th></tr></thead>
            <tbody>
            <?php foreach ($standings as $pos => $s):
                $pid = (int) ($s['player_id'] ?? 0);
                $stats = $playerStats[$pid] ?? null;
                $perf = $stats ? (int) $stats['performance'] : (int) ($s['rating'] ?? 0);
                $eloVar = $stats ? (int) $stats['elo_variation'] : 0;
                $eloStr = $eloVar > 0 ? '+' . $eloVar : (string) $eloVar;
                $display = wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) ? (int) $s['rating'] : null));
                $profileUrl = self::build_player_profile_url((int) $tournament->id, $pid);
            ?>
                <tr>
                    <td><?php echo (int) ($pos + 1); ?></td>
                    <td><?php echo '<a href="' . esc_url($profileUrl) . '#cp-player-profile" style="text-decoration:none;color:inherit;">' . $display . '</a>'; ?></td>
                    <td><?php echo esc_html((string) (int) ($s['rating'] ?? 0)); ?></td>
                    <td><?php echo esc_html((string) (int) $perf); ?></td>
                    <td><?php echo esc_html($eloStr); ?></td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
        </div>
        <?php
        return (string) ob_get_clean();
    }

    private static function output_async_standings_script(): void
    {
        static $done = false;
        if ($done) {
            return;
        }
        $done = true;
        $ajaxUrl = esc_url(admin_url('admin-ajax.php'));
        ?>
        <script>
        (function(){
          function qs(id){return document.getElementById(id);}
          var root = qs('cp-async-standings');
          if(!root) return;
          var perf = qs('cp-async-performance');
          var tid = root.getAttribute('data-tournament-id');
          var nonce = root.getAttribute('data-nonce');
          var perPage = parseInt(root.getAttribute('data-per-page')||'50',10);
          var page = 1;

          function renderPager(pages,total){
            var el = document.createElement('div');
            el.className='cp-async-pager';
            el.style.cssText='display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin:.75rem 0;color:#374151;font-size:.95em;';
            el.innerHTML = '<strong>'+total+'</strong> players · page '+page+' / '+pages+'';
            if(pages>1){
              var prev = document.createElement('button');
              prev.type='button'; prev.textContent='←';
              prev.disabled = page<=1;
              prev.onclick=function(){ page=Math.max(1,page-1); load(); };
              var next = document.createElement('button');
              next.type='button'; next.textContent='→';
              next.disabled = page>=pages;
              next.onclick=function(){ page=Math.min(pages,page+1); load(); };
              [prev,next].forEach(function(b){
                b.style.cssText='padding:.35rem .6rem;border:1px solid #e5e7eb;border-radius:8px;background:#fff;cursor:pointer;';
                if(b.disabled){ b.style.opacity=.5; b.style.cursor='not-allowed'; }
              });
              el.appendChild(prev); el.appendChild(next);
            }
            return el;
          }

          function load(){
            root.innerHTML = '<p style="color:#666;font-size:.9em;">Loading standings…</p>';
            fetch('<?php echo $ajaxUrl; ?>?action=chess_podium_standings&tournament_id='+encodeURIComponent(tid)+'&page='+page+'&per_page='+perPage+'&_ajax_nonce='+encodeURIComponent(nonce), {credentials:'same-origin'})
              .then(function(r){return r.json();})
              .then(function(j){
                if(!j || !j.success){ throw new Error((j && j.data && j.data.message) || 'Request failed'); }
                root.innerHTML = j.data.html + '';
                root.prepend(renderPager(j.data.pages||1, j.data.total||0));
                if(perf && j.data.performance_html){
                  perf.innerHTML = j.data.performance_html + '';
                }
              })
              .catch(function(e){
                root.innerHTML = '<p style="color:#b91c1c;">Unable to load standings.</p>';
              });
          }

          load();
        })();
        </script>
        <?php
    }

    /**
     * Footer "Generated by Chess Podium" with logo (same structure as static HTML export).
     *
     * @param bool $dark Dark theme for live dashboard; light for /torneo/ and shortcode.
     */
    private static function render_branding_footer_html(bool $dark = false): string
    {
        $logoUrl = plugins_url('assets/logo-chess-podium.png', CHESS_PODIUM_PLUGIN_FILE);
        $classes = 'cp-public-footer';
        if ($dark) {
            $classes .= ' cp-public-footer--dark';
        }
        $html = '<footer class="' . esc_attr($classes) . '"><p>';
        $html .= esc_html__('Generated by', 'chess-podium') . '<br>';
        $html .= '<span class="cp-footer-brand">';
        $html .= '<img src="' . esc_url($logoUrl) . '" alt="" width="20" height="20" class="cp-footer-logo" loading="lazy" />';
        $html .= '<a href="https://www.chesspodium.com/" target="_blank" rel="noopener">' . esc_html__('Chess Podium', 'chess-podium') . '</a>';
        $html .= '</span></p></footer>';

        return $html;
    }

    private static function render_print_pairings_block(object $tournament, int $round): void
    {
        $pairings = self::get_pairings_for_round((int) $tournament->id, $round);
        if (empty($pairings)) {
            return;
        }
        $fixedBoards = self::get_fixed_board_assignments((int) $tournament->id, $round);
        $hasFixedBoard = !empty($fixedBoards);
        ?>
        <div id="checkmate-print-block" class="checkmate-print-block" style="background:#fffbe6;border:1px solid #e6d58b;padding:16px;margin:16px 0;">
            <h2><?php echo esc_html(sprintf(__('Printable pairings - %s - Round %d', 'chess-podium'), $tournament->name, (int) $round)); ?></h2>
            <table class="widefat striped">
                <thead><tr><th><?php echo esc_html__('Board', 'chess-podium'); ?></th><th><?php echo esc_html__('White', 'chess-podium'); ?></th><th><?php echo esc_html__('Black', 'chess-podium'); ?></th><th><?php echo esc_html__('Result', 'chess-podium'); ?></th></tr></thead>
                <tbody>
                <?php foreach ($pairings as $i => $p):
                    $whiteId = (int) $p->white_player_id;
                    $blackId = (int) $p->black_player_id;
                    $whiteFixed = isset($fixedBoards[$whiteId]);
                    $blackFixed = (int) $p->is_bye !== 1 && isset($fixedBoards[$blackId]);
                ?>
                    <tr>
                        <td><?php echo (int) ($i + 1); ?></td>
                        <td><?php echo wp_kses_post(ChessPodium_CountryHelper::render_flag($p->white_country ?? '', $p->white_country ?? '')); ?> <?php echo esc_html($p->white_name); ?><?php echo $whiteFixed ? ' <sup>*</sup>' : ''; ?></td>
                        <td><?php echo (int) $p->is_bye === 1 ? ($p->result === 'NOT_PAIRED' ? '(' . esc_html__('not paired', 'chess-podium') . ')' : '-') : (wp_kses_post(ChessPodium_CountryHelper::render_flag($p->black_country ?? '', $p->black_country ?? '')) . ' ' . esc_html($p->black_name) . ($blackFixed ? ' <sup>*</sup>' : '')); ?></td>
                        <td><?php echo (int) $p->is_bye === 1 ? ($p->result === 'NOT_PAIRED' ? esc_html__('not paired', 'chess-podium') : 'BYE') : '__________'; ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <?php if ($hasFixedBoard): ?>
            <p class="cp-pairing-footnote" style="font-size:0.9em;color:#666;margin-top:0.5rem;"><sup>*</sup> <?php echo esc_html__('Player assigned to a fixed board.', 'chess-podium'); ?></p>
            <?php endif; ?>
            <p>
                <button class="button button-secondary" type="button" onclick="window.print();"><?php echo esc_html__('Print now', 'chess-podium'); ?></button>
            </p>
        </div>
        <?php
    }

    private static function render_print_standings_block(object $tournament): void
    {
        $standings = self::compute_standings((int) $tournament->id);
        if (empty($standings)) {
            return;
        }
        $tiebreakerCols = self::get_tiebreaker_columns_for_display($tournament);
        ?>
        <div id="checkmate-print-standings" class="checkmate-print-block" style="background:#e7f5e9;border:1px solid #7cb87c;padding:16px;margin:16px 0;">
            <h2><?php echo esc_html(sprintf(__('Printable standings - %s', 'chess-podium'), $tournament->name)); ?></h2>
            <table class="widefat striped">
                <thead><tr><th><?php echo esc_html__('Pos', 'chess-podium'); ?></th><th><?php echo esc_html__('Player', 'chess-podium'); ?></th><th><?php echo esc_html__('Pts', 'chess-podium'); ?></th><?php foreach ($tiebreakerCols as $label): ?><th><?php echo esc_html($label); ?></th><?php endforeach; ?></tr></thead>
                <tbody>
                <?php foreach ($standings as $pos => $s): ?>
                    <tr>
                        <td><?php echo (int) ($pos + 1); ?></td>
                        <td><?php
                            $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, isset($s['title']) ? (string) $s['title'] : null);
                            }
                            echo wp_kses_post(self::format_player_display($s['name'] ?? '', $s['title'] ?? null, $s['country'] ?? null, true, isset($s['rating']) && (int) $s['rating'] > 0 ? (int) $s['rating'] : null, !empty($norms) ? $norms : null));
                        ?></td>
                        <td><?php echo esc_html(number_format((float) $s['points'], 1)); ?></td>
                        <?php foreach (array_keys($tiebreakerCols) as $key):
                            $val = $s[$key] ?? 0;
                            echo '<td>' . esc_html(in_array($key, ['rating'], true) ? (string) (int) $val : number_format((float) $val, 2)) . '</td>';
                        endforeach; ?>
                    </tr>
                <?php endforeach; ?>
            </tbody>
            </table>
            <p>
                <button class="button button-secondary" type="button" onclick="window.print();"><?php echo esc_html__('Print now', 'chess-podium'); ?></button>
            </p>
        </div>
        <?php
    }

    private static function create_tables(): void
    {
        global $wpdb;
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        $charsetCollate = $wpdb->get_charset_collate();
        $tournaments = $wpdb->prefix . 'regina_tournaments';
        $players = $wpdb->prefix . 'regina_players';
        $pairings = $wpdb->prefix . 'regina_pairings';

        $sql = "
        CREATE TABLE $tournaments (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            name VARCHAR(191) NOT NULL,
            rounds_total INT NOT NULL DEFAULT 5,
            tournament_type VARCHAR(20) NOT NULL DEFAULT 'swiss',
            is_team_tournament TINYINT(1) NOT NULL DEFAULT 0,
            team_boards INT NOT NULL DEFAULT 4,
            team_scoring VARCHAR(10) NOT NULL DEFAULT 'mp',
            bye_points DECIMAL(3,2) NOT NULL DEFAULT 1.0,
            tiebreakers VARCHAR(100) NOT NULL DEFAULT 'buchholz,sb,rating',
            current_round INT NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id)
        ) $charsetCollate;

        CREATE TABLE $players (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            tournament_id BIGINT UNSIGNED NOT NULL,
            name VARCHAR(191) NOT NULL,
            photo_attachment_id BIGINT UNSIGNED NULL,
            fide_id VARCHAR(20) NULL,
            country VARCHAR(2) NULL,
            rating INT NOT NULL DEFAULT 0,
            team_id BIGINT UNSIGNED NULL,
            board_no INT NULL,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id),
            KEY tournament_id (tournament_id),
            KEY fide_id (fide_id),
            KEY team_id (team_id)
        ) $charsetCollate;

        CREATE TABLE $pairings (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            tournament_id BIGINT UNSIGNED NOT NULL,
            round_no INT NOT NULL,
            white_player_id BIGINT UNSIGNED NOT NULL,
            black_player_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            is_bye TINYINT(1) NOT NULL DEFAULT 0,
            result VARCHAR(20) NULL,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id),
            KEY tournament_round (tournament_id, round_no)
        ) $charsetCollate;

        CREATE TABLE {$wpdb->prefix}regina_tournament_photos (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            tournament_id BIGINT UNSIGNED NOT NULL,
            photo_url VARCHAR(500) NOT NULL,
            caption VARCHAR(500) NULL,
            sort_order INT NOT NULL DEFAULT 0,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id),
            KEY tournament_id (tournament_id)
        ) $charsetCollate;

        CREATE TABLE {$wpdb->prefix}regina_tournament_pgns (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            tournament_id BIGINT UNSIGNED NOT NULL,
            round_no INT NOT NULL,
            pairing_id BIGINT UNSIGNED NULL,
            white_name VARCHAR(191) NULL,
            black_name VARCHAR(191) NULL,
            result VARCHAR(20) NULL,
            pgn_content LONGTEXT NOT NULL,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id),
            KEY tournament_id (tournament_id)
        ) $charsetCollate;
        ";

        dbDelta($sql);
    }

    private static function db_table(string $name): string
    {
        global $wpdb;
        return $wpdb->prefix . $name;
    }

    private static function maybe_run_schema_updates(): void
    {
        global $wpdb;
        $playersTable = self::db_table('regina_players');
        $tournamentsTable = self::db_table('regina_tournaments');
        $photoCol = $wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'photo_attachment_id'"); // phpcs:ignore
        if ($photoCol === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN photo_attachment_id BIGINT UNSIGNED NULL AFTER name"); // phpcs:ignore
        }
        $column = $wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'fide_id'"); // phpcs:ignore
        if ($column === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN fide_id VARCHAR(20) NULL AFTER name"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $playersTable ADD INDEX fide_id (fide_id)"); // phpcs:ignore
        }
        $countryCol = $wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'country'"); // phpcs:ignore
        if ($countryCol === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN country VARCHAR(2) NULL AFTER fide_id"); // phpcs:ignore
        }
        $teamIdCol = $wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'team_id'"); // phpcs:ignore
        if ($teamIdCol === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN team_id BIGINT UNSIGNED NULL AFTER rating"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $playersTable ADD INDEX team_id (team_id)"); // phpcs:ignore
        }
        $boardNoCol = $wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'board_no'"); // phpcs:ignore
        if ($boardNoCol === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN board_no INT NULL AFTER team_id"); // phpcs:ignore
        }
        $typeCol = $wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'tournament_type'"); // phpcs:ignore
        if ($typeCol === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN tournament_type VARCHAR(20) NOT NULL DEFAULT 'swiss' AFTER rounds_total"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN bye_points DECIMAL(3,2) NOT NULL DEFAULT 1.0 AFTER tournament_type"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN tiebreakers VARCHAR(100) NOT NULL DEFAULT 'buchholz,sb,rating' AFTER bye_points"); // phpcs:ignore
        }
        $isTeamCol = $wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'is_team_tournament'"); // phpcs:ignore
        if ($isTeamCol === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN is_team_tournament TINYINT(1) NOT NULL DEFAULT 0 AFTER tournament_type"); // phpcs:ignore
        }
        $teamBoardsCol = $wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'team_boards'"); // phpcs:ignore
        if ($teamBoardsCol === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN team_boards INT NOT NULL DEFAULT 4 AFTER is_team_tournament"); // phpcs:ignore
        }
        $teamScoringCol = $wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'team_scoring'"); // phpcs:ignore
        if ($teamScoringCol === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN team_scoring VARCHAR(10) NOT NULL DEFAULT 'mp' AFTER team_boards"); // phpcs:ignore
        }

        if (class_exists('ChessPodium_TeamTournaments')) {
            ChessPodium_TeamTournaments::create_tables();
        }
        $fixedBoardsTable = self::db_table('regina_fixed_boards');
        if ($wpdb->get_var("SHOW TABLES LIKE '$fixedBoardsTable'") !== $fixedBoardsTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $fixedBoardsTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                player_id BIGINT UNSIGNED NOT NULL,
                board_number INT NOT NULL,
                from_round INT NOT NULL DEFAULT 2,
                PRIMARY KEY (id),
                UNIQUE KEY tournament_player (tournament_id, player_id),
                KEY tournament_round (tournament_id, from_round)
            ) $charset"); // phpcs:ignore
        }
        $photosTable = self::db_table('regina_tournament_photos');
        if ($wpdb->get_var("SHOW TABLES LIKE '$photosTable'") !== $photosTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $photosTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                photo_url VARCHAR(500) NOT NULL,
                caption VARCHAR(500) NULL,
                sort_order INT NOT NULL DEFAULT 0,
                created_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                KEY tournament_id (tournament_id)
            ) $charset"); // phpcs:ignore
        }
        $pgnsTable = self::db_table('regina_tournament_pgns');
        if ($wpdb->get_var("SHOW TABLES LIKE '$pgnsTable'") !== $pgnsTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $pgnsTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                round_no INT NOT NULL,
                pairing_id BIGINT UNSIGNED NULL,
                white_name VARCHAR(191) NULL,
                black_name VARCHAR(191) NULL,
                result VARCHAR(20) NULL,
                pgn_content LONGTEXT NOT NULL,
                created_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                KEY tournament_id (tournament_id)
            ) $charset"); // phpcs:ignore
        }
        $budgetTable = self::db_table('regina_tournament_budget');
        if ($wpdb->get_var("SHOW TABLES LIKE '$budgetTable'") !== $budgetTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $budgetTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                entry_date DATE NOT NULL,
                description VARCHAR(500) NOT NULL,
                amount DECIMAL(12,2) NOT NULL,
                type VARCHAR(10) NOT NULL DEFAULT 'expense',
                created_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                KEY tournament_id (tournament_id)
            ) $charset"); // phpcs:ignore
        }

        $hotelsTable = self::db_table('regina_tournament_hotels');
        if ($wpdb->get_var("SHOW TABLES LIKE '$hotelsTable'") !== $hotelsTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $hotelsTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                name VARCHAR(191) NOT NULL,
                city VARCHAR(100) NULL,
                stars TINYINT UNSIGNED NULL,
                rate_per_presence_per_day DECIMAL(10,2) NOT NULL DEFAULT 0,
                notes VARCHAR(500) NULL,
                created_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                KEY tournament_id (tournament_id)
            ) $charset"); // phpcs:ignore
        }

        $staysTable = self::db_table('regina_tournament_hotel_stays');
        if ($wpdb->get_var("SHOW TABLES LIKE '$staysTable'") !== $staysTable) { // phpcs:ignore
            require_once ABSPATH . 'wp-admin/includes/upgrade.php';
            $charset = $wpdb->get_charset_collate();
            $wpdb->query("CREATE TABLE $staysTable (
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                tournament_id BIGINT UNSIGNED NOT NULL,
                player_id BIGINT UNSIGNED NOT NULL,
                hotel_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
                family_members INT NOT NULL DEFAULT 0,
                days INT NOT NULL DEFAULT 0,
                hotel_total DECIMAL(12,2) NOT NULL DEFAULT 0,
                share_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
                updated_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                UNIQUE KEY tournament_player (tournament_id, player_id),
                KEY tournament_id (tournament_id),
                KEY hotel_id (hotel_id)
            ) $charset"); // phpcs:ignore
        }
        $regCol = $wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'registration_enabled'"); // phpcs:ignore
        if ($regCol === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN registration_enabled TINYINT(1) NOT NULL DEFAULT 0"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN registration_fee DECIMAL(10,2) NOT NULL DEFAULT 0"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN registration_currency VARCHAR(3) NOT NULL DEFAULT 'EUR'"); // phpcs:ignore
        }
        foreach (['city' => 'VARCHAR(100) NULL', 'federation' => 'VARCHAR(3) NULL', 'start_date' => 'DATE NULL', 'end_date' => 'DATE NULL', 'chief_arbiter' => 'VARCHAR(100) NULL', 'deputy_arbiter' => 'VARCHAR(100) NULL', 'time_control' => 'VARCHAR(50) NULL'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'calendar_visible'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN calendar_visible TINYINT(1) NOT NULL DEFAULT 1"); // phpcs:ignore
        }
        foreach (['birth_date' => 'DATE NULL', 'sex' => 'CHAR(1) NULL', 'title' => 'VARCHAR(10) NULL'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $playersTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        foreach (['uscf_id' => 'VARCHAR(20) NULL', 'ecf_id' => 'VARCHAR(20) NULL'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $playersTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'start_number'") === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN start_number INT UNSIGNED NULL AFTER rating"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'email'") === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN email VARCHAR(191) NULL AFTER name"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $playersTable LIKE 'allow_notifications'") === null) {
            $wpdb->query("ALTER TABLE $playersTable ADD COLUMN allow_notifications TINYINT(1) NOT NULL DEFAULT 0"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'pairing_engine'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN pairing_engine VARCHAR(20) NULL DEFAULT '' AFTER tiebreakers"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'round1_pairing_mode'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN round1_pairing_mode VARCHAR(20) NOT NULL DEFAULT 'dutch' AFTER pairing_engine"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'round2_pairing_tiebreak'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN round2_pairing_tiebreak VARCHAR(20) NOT NULL DEFAULT 'nr' AFTER round1_pairing_mode"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'pairing_acceleration'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN pairing_acceleration VARCHAR(20) NOT NULL DEFAULT 'none' AFTER round2_pairing_tiebreak"); // phpcs:ignore
        }
        foreach (['rating_system' => 'VARCHAR(10) NULL', 'country_code' => 'VARCHAR(2) NULL'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        foreach (['aro_cut_strongest' => 'TINYINT NOT NULL DEFAULT 0', 'aro_cut_weakest' => 'TINYINT NOT NULL DEFAULT 0', 'aro_min_rating' => 'INT NOT NULL DEFAULT 1400'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        foreach (['buchholz_cut_strongest' => 'TINYINT NOT NULL DEFAULT 0', 'buchholz_cut_weakest' => 'TINYINT NOT NULL DEFAULT 0', 'sb_cut_strongest' => 'TINYINT NOT NULL DEFAULT 0', 'sb_cut_weakest' => 'TINYINT NOT NULL DEFAULT 0'] as $col => $def) {
            if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE '$col'") === null) {
                $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN $col $def"); // phpcs:ignore
            }
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'points_system'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN points_system VARCHAR(10) NOT NULL DEFAULT 'standard'"); // phpcs:ignore
        }
        if ($wpdb->get_var("SHOW COLUMNS FROM $tournamentsTable LIKE 'fair_play_public'") === null) {
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN fair_play_public TINYINT(1) NOT NULL DEFAULT 0"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN fair_play_measures TEXT NULL"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN fair_play_officer VARCHAR(191) NOT NULL DEFAULT ''"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN fair_play_complaint_url VARCHAR(500) NOT NULL DEFAULT ''"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $tournamentsTable ADD COLUMN fair_play_additional LONGTEXT NULL"); // phpcs:ignore
        }
        ChessPodium_Registration::create_tables();
        ChessPodium_Tournament_Communications::create_log_table();
        ChessPodium_PlayerStatus::create_table();
        ChessPodium_PdfBrochure::create_table();
        ChessPodium_Sponsors::create_table();
        ChessPodium_PgnLive::create_table();
        ChessPodium_GrandPrix::create_tables();
        if (class_exists('ChessPodium_PlayerDirectory')) {
            ChessPodium_PlayerDirectory::create_tables();
        }
        $pairingsTable = self::db_table('regina_pairings');
        $byePointsCol = $wpdb->get_var("SHOW COLUMNS FROM $pairingsTable LIKE 'bye_points'"); // phpcs:ignore
        if ($byePointsCol === null) {
            $wpdb->query("ALTER TABLE $pairingsTable ADD COLUMN bye_points DECIMAL(3,2) NULL AFTER result"); // phpcs:ignore
        }
        $isLiveCol = $wpdb->get_var("SHOW COLUMNS FROM $pairingsTable LIKE 'is_live'"); // phpcs:ignore
        if ($isLiveCol === null) {
            $wpdb->query("ALTER TABLE $pairingsTable ADD COLUMN is_live TINYINT(1) NOT NULL DEFAULT 0 AFTER bye_points"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $pairingsTable ADD COLUMN live_pgn LONGTEXT NULL AFTER is_live"); // phpcs:ignore
            $wpdb->query("ALTER TABLE $pairingsTable ADD COLUMN live_fen VARCHAR(100) NULL AFTER live_pgn"); // phpcs:ignore
        }
        if (!get_option('chess_podium_live_pgn_longtext', false)) {
            $col = $wpdb->get_row("SHOW COLUMNS FROM $pairingsTable LIKE 'live_pgn'"); // phpcs:ignore
            if ($col && isset($col->Type) && stripos($col->Type, 'text') !== false && stripos($col->Type, 'longtext') === false) {
                $wpdb->query("ALTER TABLE $pairingsTable MODIFY COLUMN live_pgn LONGTEXT NULL"); // phpcs:ignore
            }
            update_option('chess_podium_live_pgn_longtext', true);
        }
        $savedVersion = get_option('chess_podium_db_version', '0');
        if (version_compare($savedVersion, self::DB_VERSION, '<')) {
            update_option('chess_podium_db_version', self::DB_VERSION);
            flush_rewrite_rules();
        }
    }

    private static function insert_tournament(string $name, int $rounds, string $type = 'swiss', float $byePoints = 1.0, string $tiebreakers = 'buchholz,sb,rating'): int
    {
        global $wpdb;
        $type = in_array($type, ['swiss', 'round_robin', 'double_round_robin'], true) ? $type : 'swiss';
        $byePoints = max(0.0, min(1.0, (float) $byePoints));
        $tiebreakers = self::sanitize_tiebreakers($tiebreakers);
        $rounds = max(1, min(self::MAX_ROUNDS_TOTAL, $rounds));
        $wpdb->insert(
            self::db_table('regina_tournaments'),
            [
                'name' => $name,
                'rounds_total' => $rounds,
                'tournament_type' => $type,
                'bye_points' => $byePoints,
                'tiebreakers' => $tiebreakers,
                'current_round' => 0,
                'created_at' => current_time('mysql'),
            ],
            ['%s', '%d', '%s', '%f', '%s', '%d', '%s']
        );
        return (int) $wpdb->insert_id;
    }

    private static function sanitize_tiebreakers(string $raw): string
    {
        $allowed = ['buchholz', 'buchholz1', 'sb', 'rating', 'aro', 'direct', 'win', 'bwg', 'ks', 'rep'];
        $parts = array_map('trim', explode(',', strtolower($raw)));
        $valid = array_filter($parts, static fn ($p) => in_array($p, $allowed, true));
        return implode(',', array_slice(array_unique($valid), 0, 10)) ?: 'buchholz,sb,rating';
    }

    /** Returns tiebreaker columns for standings display: array of [key => label]. Skips 'direct' (no numeric column). */
    public static function get_tiebreaker_columns_for_display(?object $tournament): array
    {
        $withColumns = [
            'buchholz' => __('Buchholz', 'chess-podium'),
            'buchholz1' => __('FIDE Buchholz Cut 1', 'chess-podium'),
            'sb' => __('SB', 'chess-podium'),
            'aro' => __('ARO', 'chess-podium'),
            'rating' => __('Rating', 'chess-podium'),
            'win' => __('WIN', 'chess-podium'),
            'bwg' => __('BWG', 'chess-podium'),
            'ks' => __('KS', 'chess-podium'),
            'rep' => __('REP', 'chess-podium'),
        ];
        $raw = ($tournament && isset($tournament->tiebreakers)) ? (string) $tournament->tiebreakers : 'buchholz,sb,rating';
        $list = array_map('trim', array_map('strtolower', explode(',', $raw)));
        $result = [];
        foreach ($list as $tb) {
            if ($tb === 'direct') {
                continue;
            }
            if (isset($withColumns[$tb])) {
                $result[$tb] = $withColumns[$tb];
            }
        }
        return $result;
    }

    /** Renders collapsible tiebreaker legend (details/summary). */
    private static function render_tiebreaker_legend(): void
    {
        ?>
        <details class="cp-tiebreaker-legend" style="margin-top:0.75rem;max-width:500px;">
            <summary style="cursor:pointer;padding:0.5rem 0.75rem;background:#f8f9fa;border:1px solid #e2e8f0;border-radius:4px;font-size:12px;font-weight:600;list-style:none;">
                <?php echo esc_html__('Tiebreaker codes', 'chess-podium'); ?>
            </summary>
            <div style="margin-top:0;padding:0.75rem 1rem;background:#f8f9fa;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 4px 4px;font-size:12px;">
                <dl style="margin:0;display:grid;grid-template-columns:auto 1fr;gap:0.25rem 1rem;">
                    <dt><code>buchholz</code></dt><dd><?php echo esc_html__('Buchholz — sum of opponents\' scores', 'chess-podium'); ?></dd>
                    <dt><code>buchholz1</code></dt><dd><?php echo esc_html__('FIDE Buchholz Cut 1 — Buchholz minus worst opponent', 'chess-podium'); ?></dd>
                    <dt><code>sb</code></dt><dd><?php echo esc_html__('Sonneborn–Berger', 'chess-podium'); ?></dd>
                    <dt><code>rating</code></dt><dd><?php echo esc_html__('Rating — starting Elo', 'chess-podium'); ?></dd>
                    <dt><code>aro</code></dt><dd><?php echo esc_html__('ARO — Average Rating of Opponents', 'chess-podium'); ?></dd>
                    <dt><code>direct</code></dt><dd><?php echo esc_html__('Direct encounter — head-to-head result', 'chess-podium'); ?></dd>
                    <dt><code>win</code></dt><dd><?php echo esc_html__('WIN — number of wins', 'chess-podium'); ?></dd>
                    <dt><code>bwg</code></dt><dd><?php echo esc_html__('BWG — wins with black', 'chess-podium'); ?></dd>
                    <dt><code>ks</code></dt><dd><?php echo esc_html__('KS — Koya system (vs 50%+ scorers)', 'chess-podium'); ?></dd>
                    <dt><code>rep</code></dt><dd><?php echo esc_html__('REP — Rounds Elected to Play (games played, excl. byes)', 'chess-podium'); ?></dd>
                </dl>
            </div>
        </details>
        <?php
    }

    public static function insert_player(int $tournamentId, string $name, int $rating, ?string $fideId = null, ?string $country = null, ?string $title = null, ?string $uscfId = null, ?string $ecfId = null, ?int $startNumber = null, ?string $email = null, int $allowNotifications = 0): void
    {
        global $wpdb;
        $country = self::sanitize_country_code($country);
        $title = $title ? self::sanitize_fide_title($title) : null;
        $allowNotifications = max(0, min(1, (int) $allowNotifications));
        $cols = ['tournament_id', 'name', 'fide_id', 'country', 'rating'];
        $vals = [$tournamentId, $name, $fideId, $country ?: null, $rating];
        $fmts = ['%d', '%s', '%s', '%s', '%d'];
        if ($email !== null && $email !== '') {
            $e = sanitize_email($email);
            if ($e !== '' && is_email($e)) {
                $cols[] = 'email';
                $vals[] = $e;
                $fmts[] = '%s';
            }
        }
        $cols[] = 'allow_notifications';
        $vals[] = $allowNotifications;
        $fmts[] = '%d';
        $cols[] = 'created_at';
        $vals[] = current_time('mysql');
        $fmts[] = '%s';
        if ($startNumber !== null && $startNumber > 0) {
            $cols[] = 'start_number';
            $vals[] = $startNumber;
            $fmts[] = '%d';
        }
        if ($title !== null) {
            $cols[] = 'title';
            $vals[] = $title;
            $fmts[] = '%s';
        }
        if ($uscfId !== null && $uscfId !== '') {
            $cols[] = 'uscf_id';
            $vals[] = $uscfId;
            $fmts[] = '%s';
        }
        if ($ecfId !== null && $ecfId !== '') {
            $cols[] = 'ecf_id';
            $vals[] = $ecfId;
            $fmts[] = '%s';
        }
        $wpdb->insert(self::db_table('regina_players'), array_combine($cols, $vals), $fmts);
    }

    public static function sanitize_fide_title(?string $raw): ?string
    {
        if ($raw === null || trim($raw) === '') {
            return null;
        }
        $normalized = preg_replace('/\s+/', ' ', trim($raw));
        if (preg_match('/^(none|n\/a|—|-|–)$/iu', $normalized)) {
            return null;
        }
        $upper = strtoupper($normalized);
        static $fullTitleNames = [
            'GRANDMASTER' => 'GM',
            'INTERNATIONAL MASTER' => 'IM',
            'FIDE MASTER' => 'FM',
            'CANDIDATE MASTER' => 'CM',
            'WOMAN GRANDMASTER' => 'WGM',
            'WOMAN INTERNATIONAL MASTER' => 'WIM',
            'WOMAN FIDE MASTER' => 'WFM',
            'WOMAN CANDIDATE MASTER' => 'WCM',
        ];
        if (isset($fullTitleNames[$upper])) {
            return $fullTitleNames[$upper];
        }
        $t = strtoupper(trim(substr($normalized, 0, 10)));
        $valid = ['GM', 'IM', 'FM', 'CM', 'WGM', 'WIM', 'WFM', 'WCM'];
        return in_array($t, $valid, true) ? $t : null;
    }

    public static function sanitize_country_code(?string $code): ?string
    {
        if ($code === null || $code === '') {
            return null;
        }
        $code = strtoupper(trim($code));
        if (strlen($code) === 3 && ctype_alpha($code) && class_exists('ChessPodium_CountryHelper')) {
            $iso = ChessPodium_CountryHelper::fide_to_iso($code);
            if ($iso !== null) {
                return $iso;
            }
        }
        $code = substr($code, 0, 2);
        return (strlen($code) === 2 && ctype_alpha($code)) ? $code : null;
    }

    private static function update_player(int $tournamentId, int $playerId, string $name, int $rating, ?string $country = null, ?string $title = null, ?string $uscfId = null, ?string $ecfId = null, ?int $startNumber = null, bool $updateStartNumber = false, ?string $email = null, ?int $allowNotifications = null, ?int $photoAttachmentId = null): void
    {
        global $wpdb;
        $country = self::sanitize_country_code($country);
        $data = [
            'name' => $name,
            'rating' => $rating,
            'country' => $country ?: null,
            'title' => $title !== null ? $title : null,
            'uscf_id' => $uscfId !== null && $uscfId !== '' ? $uscfId : null,
            'ecf_id' => $ecfId !== null && $ecfId !== '' ? $ecfId : null,
        ];
        $formats = ['%s', '%d', '%s', '%s', '%s', '%s'];
        if ($photoAttachmentId !== null) {
            $data['photo_attachment_id'] = $photoAttachmentId > 0 ? $photoAttachmentId : null;
            $formats[] = '%d';
        }
        if ($updateStartNumber) {
            $data['start_number'] = $startNumber !== null && $startNumber > 0 ? $startNumber : null;
            $formats[] = '%d';
        }
        if ($email !== null) {
            $data['email'] = ($email !== '' && is_email(sanitize_email($email))) ? sanitize_email($email) : null;
            $formats[] = '%s';
        }
        if ($allowNotifications !== null) {
            $data['allow_notifications'] = $allowNotifications ? 1 : 0;
            $formats[] = '%d';
        }
        $wpdb->update(
            self::db_table('regina_players'),
            $data,
            [
                'id' => $playerId,
                'tournament_id' => $tournamentId,
            ],
            $formats,
            ['%d', '%d']
        );
    }

    private static function delete_player(int $tournamentId, int $playerId): bool
    {
        global $wpdb;
        if (self::player_used_in_pairings($tournamentId, $playerId)) {
            return false;
        }
        self::remove_fixed_board($tournamentId, $playerId);
        $deleted = $wpdb->delete(
            self::db_table('regina_players'),
            [
                'id' => $playerId,
                'tournament_id' => $tournamentId,
            ],
            ['%d', '%d']
        );
        return $deleted > 0;
    }

    private static function delete_tournament(int $tournamentId): bool
    {
        global $wpdb;
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return false;
        }
        $wpdb->delete(self::db_table('regina_pairings'), ['tournament_id' => $tournamentId], ['%d']);
        $wpdb->delete(self::db_table('regina_players'), ['tournament_id' => $tournamentId], ['%d']);
        $wpdb->delete(self::db_table('regina_fixed_boards'), ['tournament_id' => $tournamentId], ['%d']);
        $wpdb->delete(self::db_table('regina_tournament_photos'), ['tournament_id' => $tournamentId], ['%d']);
        $wpdb->delete(self::db_table('regina_tournament_pgns'), ['tournament_id' => $tournamentId], ['%d']);
        $deleted = $wpdb->delete(self::db_table('regina_tournaments'), ['id' => $tournamentId], ['%d']);
        if (class_exists('ChessPodium_Tournament_Communications')) {
            delete_option(ChessPodium_Tournament_Communications::queue_option_key($tournamentId));
            delete_option(ChessPodium_Tournament_Communications::extra_recipients_option_key($tournamentId));
        }
        return $deleted > 0;
    }

    private static function update_tournament_name(int $tournamentId, string $name): bool
    {
        global $wpdb;
        $updated = $wpdb->update(
            self::db_table('regina_tournaments'),
            ['name' => $name],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        );
        return $updated !== false;
    }

    private static function update_tournament_rating_settings(int $tournamentId, string $ratingSystem, ?string $countryCode): bool
    {
        global $wpdb;
        $ratingSystem = in_array($ratingSystem, ['fide', 'uscf', 'ecf'], true) ? $ratingSystem : 'fide';
        $countryCode = $countryCode !== null && $countryCode !== '' ? $countryCode : null;
        $updated = $wpdb->update(
            self::db_table('regina_tournaments'),
            ['rating_system' => $ratingSystem, 'country_code' => $countryCode],
            ['id' => $tournamentId],
            ['%s', '%s'],
            ['%d']
        );
        return $updated !== false;
    }

    private static function update_tournament_tiebreakers(int $tournamentId, string $tiebreakers, ?int $aroCutStrongest = null, ?int $aroCutWeakest = null, ?int $aroMinRating = null, int $buchholzCutStrongest = 0, int $buchholzCutWeakest = 0, int $sbCutStrongest = 0, int $sbCutWeakest = 0, string $pointsSystem = 'standard'): bool
    {
        global $wpdb;
        $tiebreakers = self::sanitize_tiebreakers($tiebreakers);
        $data = [
            'tiebreakers' => $tiebreakers,
            'buchholz_cut_strongest' => max(0, min(2, $buchholzCutStrongest)),
            'buchholz_cut_weakest' => max(0, min(2, $buchholzCutWeakest)),
            'sb_cut_strongest' => max(0, min(2, $sbCutStrongest)),
            'sb_cut_weakest' => max(0, min(2, $sbCutWeakest)),
            'points_system' => in_array($pointsSystem, ['standard', '310'], true) ? $pointsSystem : 'standard',
        ];
        $formats = ['%s', '%d', '%d', '%d', '%d', '%s'];
        if ($aroCutStrongest !== null) {
            $data['aro_cut_strongest'] = max(0, min(2, $aroCutStrongest));
            $formats[] = '%d';
        }
        if ($aroCutWeakest !== null) {
            $data['aro_cut_weakest'] = max(0, min(2, $aroCutWeakest));
            $formats[] = '%d';
        }
        if ($aroMinRating !== null) {
            $data['aro_min_rating'] = max(0, min(3000, $aroMinRating));
            $formats[] = '%d';
        }
        $updated = $wpdb->update(
            self::db_table('regina_tournaments'),
            $data,
            ['id' => $tournamentId],
            $formats,
            ['%d']
        );
        return $updated !== false;
    }

    private static function update_tournament_rounds_total(int $tournamentId, int $roundsTotal): bool
    {
        global $wpdb;
        $roundsTotal = max(1, min(self::MAX_ROUNDS_TOTAL, $roundsTotal));
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return false;
        }
        $currentRound = (int) $tournament->current_round;
        if ($roundsTotal < $currentRound) {
            return false;
        }
        $updated = $wpdb->update(
            self::db_table('regina_tournaments'),
            ['rounds_total' => $roundsTotal],
            ['id' => $tournamentId],
            ['%d'],
            ['%d']
        );
        return $updated !== false;
    }

    private static function update_tournament_type(int $tournamentId, string $type): bool
    {
        global $wpdb;
        if (!self::get_tournament($tournamentId)) {
            return false;
        }
        $type = in_array($type, ['swiss', 'round_robin', 'double_round_robin'], true) ? $type : 'swiss';
        $result = $wpdb->update(
            self::db_table('regina_tournaments'),
            ['tournament_type' => $type],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        );
        return $result !== false;
    }

    private static function update_tournament_pairing_engine(int $tournamentId, string $engine): bool
    {
        global $wpdb;
        $engine = in_array($engine, ['bbpPairings', 'javafo'], true) ? $engine : '';
        $updated = $wpdb->update(
            self::db_table('regina_tournaments'),
            ['pairing_engine' => $engine],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        );
        return $updated !== false;
    }

    private static function update_tournament_round2_pairing_tiebreak(int $tournamentId, string $tiebreak): bool
    {
        global $wpdb;
        $tiebreak = in_array($tiebreak, ['nr', 'rating'], true) ? $tiebreak : 'nr';
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            ['round2_pairing_tiebreak' => $tiebreak],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        ) !== false;
    }

    private static function update_tournament_round1_pairing_mode(int $tournamentId, string $mode): bool
    {
        global $wpdb;
        $mode = in_array($mode, ['dutch', 'split'], true) ? $mode : 'dutch';
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            ['round1_pairing_mode' => $mode],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        ) !== false;
    }

    private static function update_team_tournament_settings(int $tournamentId, bool $isTeam, int $boardsPerMatch, string $primaryScore): bool
    {
        global $wpdb;
        if ($tournamentId <= 0) {
            return false;
        }
        $boardsPerMatch = max(1, min(20, $boardsPerMatch));
        $primaryScore = in_array($primaryScore, ['mp', 'bp'], true) ? $primaryScore : 'mp';
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            [
                'is_team_tournament' => $isTeam ? 1 : 0,
                'team_boards' => $boardsPerMatch,
                'team_scoring' => $primaryScore,
            ],
            ['id' => $tournamentId],
            ['%d', '%d', '%s'],
            ['%d']
        ) !== false;
    }

    private static function update_tournament_pairing_acceleration(int $tournamentId, string $accel): bool
    {
        global $wpdb;
        $accel = in_array($accel, ['none', 'baku', 'fide_accel'], true) ? $accel : 'none';
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            ['pairing_acceleration' => $accel],
            ['id' => $tournamentId],
            ['%s'],
            ['%d']
        ) !== false;
    }

    private static function update_tournament_officials(int $tournamentId, string $chiefArbiter, string $deputyArbiter): bool
    {
        global $wpdb;
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            ['chief_arbiter' => $chiefArbiter, 'deputy_arbiter' => $deputyArbiter],
            ['id' => $tournamentId],
            ['%s', '%s'],
            ['%d']
        ) !== false;
    }

    /**
     * @param string[] $measureSlugs
     */
    private static function update_tournament_fair_play(int $tournamentId, bool $public, array $measureSlugs, string $officer, string $complaintUrl, string $additionalHtml): bool
    {
        global $wpdb;
        $json = class_exists('ChessPodium_FairPlayDisplay') ? ChessPodium_FairPlayDisplay::encode_measures($measureSlugs) : '[]';
        return $wpdb->update(
            self::db_table('regina_tournaments'),
            [
                'fair_play_public' => $public ? 1 : 0,
                'fair_play_measures' => $json,
                'fair_play_officer' => substr($officer, 0, 191),
                'fair_play_complaint_url' => substr($complaintUrl, 0, 500),
                'fair_play_additional' => $additionalHtml,
            ],
            ['id' => $tournamentId],
            ['%d', '%s', '%s', '%s', '%s'],
            ['%d']
        ) !== false;
    }

    private static function get_budget_entries(int $tournamentId): array
    {
        global $wpdb;
        $table = self::db_table('regina_tournament_budget');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE tournament_id = %d ORDER BY entry_date DESC, id DESC",
            $tournamentId
        ));
        return is_array($rows) ? $rows : [];
    }

    private static function add_budget_entry(int $tournamentId, string $date, string $description, float $amount, string $type = 'expense'): bool
    {
        global $wpdb;
        $type = $type === 'income' ? 'income' : 'expense';
        return (bool) $wpdb->insert(
            self::db_table('regina_tournament_budget'),
            [
                'tournament_id' => $tournamentId,
                'entry_date' => $date,
                'description' => substr($description, 0, 500),
                'amount' => $amount,
                'type' => $type,
                'created_at' => current_time('mysql'),
            ],
            ['%d', '%s', '%s', '%f', '%s', '%s']
        );
    }

    private static function delete_budget_entry(int $tournamentId, int $entryId): bool
    {
        global $wpdb;
        $deleted = $wpdb->delete(
            self::db_table('regina_tournament_budget'),
            ['id' => $entryId, 'tournament_id' => $tournamentId],
            ['%d', '%d']
        );
        return $deleted !== false;
    }

    private static function get_partner_hotels(int $tournamentId): array
    {
        global $wpdb;
        $table = self::db_table('regina_tournament_hotels');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE tournament_id = %d ORDER BY name ASC, id ASC",
            $tournamentId
        ));
        return is_array($rows) ? $rows : [];
    }

    private static function add_partner_hotel(int $tournamentId, string $name, ?string $city, ?int $stars, float $ratePerPresencePerDay, ?string $notes): bool
    {
        global $wpdb;
        $name = trim($name);
        if ($tournamentId <= 0 || $name === '') {
            return false;
        }
        $starsVal = $stars !== null ? max(1, min(5, (int) $stars)) : null;
        $rate = max(0.0, min(9999.0, (float) $ratePerPresencePerDay));
        return (bool) $wpdb->insert(
            self::db_table('regina_tournament_hotels'),
            [
                'tournament_id' => $tournamentId,
                'name' => $name,
                'city' => $city !== null && trim($city) !== '' ? trim($city) : null,
                'stars' => $starsVal,
                'rate_per_presence_per_day' => $rate,
                'notes' => $notes !== null && trim($notes) !== '' ? trim($notes) : null,
                'created_at' => current_time('mysql'),
            ],
            ['%d', '%s', '%s', '%d', '%f', '%s', '%s']
        );
    }

    private static function delete_partner_hotel(int $tournamentId, int $hotelId): bool
    {
        global $wpdb;
        if ($tournamentId <= 0 || $hotelId <= 0) {
            return false;
        }
        // Clear assignments referencing this hotel.
        $wpdb->update(
            self::db_table('regina_tournament_hotel_stays'),
            ['hotel_id' => 0, 'share_amount' => 0],
            ['tournament_id' => $tournamentId, 'hotel_id' => $hotelId],
            ['%d', '%f'],
            ['%d', '%d']
        );
        $deleted = $wpdb->delete(
            self::db_table('regina_tournament_hotels'),
            ['id' => $hotelId, 'tournament_id' => $tournamentId],
            ['%d', '%d']
        );
        return $deleted > 0;
    }

    private static function get_player_hotel_stays_indexed(int $tournamentId): array
    {
        global $wpdb;
        $table = self::db_table('regina_tournament_hotel_stays');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT * FROM $table WHERE tournament_id = %d",
            $tournamentId
        ));
        $out = [];
        if (is_array($rows)) {
            foreach ($rows as $r) {
                $pid = (int) ($r->player_id ?? 0);
                if ($pid > 0) {
                    $out[$pid] = $r;
                }
            }
        }
        return $out;
    }

    private static function compute_tournament_share(float $ratePerPresencePerDay, int $familyMembers, int $days): float
    {
        $rate = max(0.0, (float) $ratePerPresencePerDay);
        $fam = max(0, (int) $familyMembers);
        $d = max(0, (int) $days);
        $presences = 1 + $fam;
        $val = $rate * $presences * $d;
        return round($val, 2);
    }

    private static function save_bulk_player_hotel_stays(int $tournamentId, array $stays): bool
    {
        global $wpdb;
        if ($tournamentId <= 0) {
            return false;
        }
        $hotels = self::get_partner_hotels($tournamentId);
        $rateById = [];
        foreach ($hotels as $h) {
            $hid = (int) ($h->id ?? 0);
            if ($hid > 0) {
                $rateById[$hid] = (float) ($h->rate_per_presence_per_day ?? 0);
            }
        }
        $table = self::db_table('regina_tournament_hotel_stays');
        $savedAny = false;
        foreach ($stays as $playerIdRaw => $row) {
            $playerId = isset($row['player_id']) ? (int) $row['player_id'] : (int) $playerIdRaw;
            if ($playerId <= 0) {
                continue;
            }
            $hotelId = isset($row['hotel_id']) ? (int) $row['hotel_id'] : 0;
            $hotelId = $hotelId > 0 && isset($rateById[$hotelId]) ? $hotelId : 0;
            $family = isset($row['family_members']) ? max(0, min(50, (int) $row['family_members'])) : 0;
            $days = isset($row['days']) ? max(0, min(365, (int) $row['days'])) : 0;
            $hotelTotal = isset($row['hotel_total']) ? max(0.0, min(999999.0, (float) $row['hotel_total'])) : 0.0;
            $rate = $hotelId > 0 ? (float) $rateById[$hotelId] : 0.0;
            $computed = self::compute_tournament_share($rate, $family, $days);
            $share = isset($row['share_amount']) ? (float) $row['share_amount'] : $computed;
            $share = max(0.0, min(999999.0, round($share, 2)));

            $data = [
                'tournament_id' => $tournamentId,
                'player_id' => $playerId,
                'hotel_id' => $hotelId > 0 ? $hotelId : 0,
                'family_members' => $family,
                'days' => $days,
                'hotel_total' => $hotelTotal,
                'share_amount' => $share,
                'updated_at' => current_time('mysql'),
            ];
            $formats = ['%d', '%d', '%d', '%d', '%d', '%f', '%f', '%s'];

            $existingId = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM $table WHERE tournament_id = %d AND player_id = %d LIMIT 1",
                $tournamentId,
                $playerId
            ));
            if ($existingId) {
                $wpdb->update(
                    $table,
                    $data,
                    ['id' => (int) $existingId],
                    $formats,
                    ['%d']
                );
            } else {
                $wpdb->insert($table, $data, $formats);
            }
            $savedAny = true;
        }
        return $savedAny;
    }

    /**
     * Return match points for a result. Standard: win=1, draw=0.5, loss=0. 3,1,0: win=3, draw=1, loss=0.
     *
     * @param string $res Normalized result (1-0, 0-1, 1/2-1/2, 1F, 0F)
     * @param bool   $isWhite True if points for white player
     * @param string $pointsSystem 'standard' (1,0.5,0) or '310' (3,1,0)
     * @return float
     */
    private static function get_points_for_result(string $res, bool $isWhite, string $pointsSystem = 'standard'): float
    {
        $scale = ($pointsSystem === '310') ? 3.0 : 1.0;
        $drawScale = ($pointsSystem === '310') ? 1.0 : 0.5;
        if (in_array($res, ['1-0', '1F'], true)) {
            return $isWhite ? $scale : ($res === '1F' ? $drawScale : 0.0);
        }
        if (in_array($res, ['0-1', '0F'], true)) {
            return $isWhite ? ($res === '0F' ? $drawScale : 0.0) : $scale;
        }
        if ($res === '1/2-1/2') {
            return $drawScale;
        }
        return 0.0;
    }

    /** Sum array values after excluding strongest N and weakest M (Swiss Manager style). */
    private static function apply_cut_sum(array $values, int $cutStrongest, int $cutWeakest): float
    {
        if (count($values) === 0) {
            return 0.0;
        }
        $sorted = $values;
        rsort($sorted);
        for ($i = 0; $i < $cutStrongest && count($sorted) > 0; $i++) {
            array_shift($sorted);
        }
        sort($sorted);
        for ($i = 0; $i < $cutWeakest && count($sorted) > 0; $i++) {
            array_shift($sorted);
        }
        return (float) array_sum($sorted);
    }

    /** Sum weighted contributions after excluding by sort key (strongest/weakest by score). */
    private static function apply_cut_sum_weighted(array $contributions, array $sortKeys, int $cutStrongest, int $cutWeakest): float
    {
        if (count($contributions) === 0 || count($contributions) !== count($sortKeys)) {
            return 0.0;
        }
        $pairs = [];
        foreach ($contributions as $i => $c) {
            $pairs[] = ['contrib' => $c, 'key' => $sortKeys[$i] ?? 0];
        }
        usort($pairs, static fn ($a, $b) => $b['key'] <=> $a['key']);
        for ($i = 0; $i < $cutStrongest && count($pairs) > 0; $i++) {
            array_shift($pairs);
        }
        usort($pairs, static fn ($a, $b) => $a['key'] <=> $b['key']);
        for ($i = 0; $i < $cutWeakest && count($pairs) > 0; $i++) {
            array_shift($pairs);
        }
        return (float) array_sum(array_column($pairs, 'contrib'));
    }

    /**
     * Normalize result string for consistent comparison (handles ½-½, 0.5-0.5, =)
     */
    public static function normalize_result(string $res): string
    {
        $res = trim($res);
        if ($res === '') {
            return '';
        }
        if ($res === '=' || $res === '½-½' || $res === '0.5-0.5' || preg_match('/^0\.5\s*-\s*0\.5$/i', $res)) {
            return '1/2-1/2';
        }
        return $res;
    }

    private static function update_pairing_result(int $pairingId, string $result): void
    {
        global $wpdb;
        $result = self::normalize_result($result);
        $valid = ['', '1-0', '0-1', '1/2-1/2', '1F', '0F'];
        if (!in_array($result, $valid, true)) {
            return;
        }
        $data = ['result' => $result === '' ? null : $result];
        if ($result !== '') {
            $data['is_live'] = 0;
            $data['live_pgn'] = null;
            $data['live_fen'] = null;
        }
        $wpdb->update(
            self::db_table('regina_pairings'),
            $data,
            ['id' => $pairingId],
            $result !== '' ? ['%s', '%d', '%s', '%s'] : ['%s'],
            ['%d']
        );
    }

    /**
     * Update pairing result and optionally bye_points (for bye pairings).
     *
     * @param int $pairingId
     * @param string $result
     * @param float|null $byePoints For bye pairings only
     */
    private static function update_pairing_result_and_bye(int $pairingId, string $result, ?float $byePoints = null): void
    {
        global $wpdb;
        $result = self::normalize_result($result);
        $valid = ['', '1-0', '0-1', '1/2-1/2', '1F', '0F', 'BYE'];
        if (!in_array($result, $valid, true)) {
            return;
        }
        $data = ['result' => $result === '' ? null : $result];
        $fmt = ['%s'];
        if ($byePoints !== null) {
            $data['bye_points'] = max(0.0, min(1.0, $byePoints));
            $fmt[] = '%f';
        }
        $wpdb->update(
            self::db_table('regina_pairings'),
            $data,
            ['id' => $pairingId],
            $fmt,
            ['%d']
        );
    }

    /**
     * Save manually edited pairings for a round.
     * Validates that each player appears at most once (white or black) per round.
     * Handles: black='bye' (convert to bye for both players), add_bye_players (add new byes).
     *
     * @param array<int, array{white: int|string, black: int|string, result?: string}> $pairings pairing_id => {white, black, result?}
     * @param array<int, float> $addByes player_id => points (0.5 or 1)
     */
    private static function save_pairings_for_round(int $tournamentId, int $roundNo, array $pairings, array $addByes = []): bool
    {
        global $wpdb;
        $table = self::db_table('regina_pairings');
        $existing = $wpdb->get_results($wpdb->prepare(
            "SELECT id, white_player_id, black_player_id, is_bye FROM $table WHERE tournament_id = %d AND round_no = %d",
            $tournamentId,
            $roundNo
        ));
        $validIds = [];
        $existingById = [];
        $playersInOriginalRound = [];
        foreach ($existing as $row) {
            $validIds[(int) $row->id] = true;
            $existingById[(int) $row->id] = $row;
            $w = (int) $row->white_player_id;
            $b = (int) $row->black_player_id;
            if ($w > 0) $playersInOriginalRound[$w] = true;
            if ($b > 0) $playersInOriginalRound[$b] = true;
        }

        $usedWhite = [];
        $usedBlack = [];
        $updates = [];
        $extraByesToAdd = [];
        $forfeitPairingsToInsert = [];
        $pairingIdsToDelete = [];

        foreach ($pairings as $pairingId => $data) {
            $pairingId = (int) $pairingId;
            if (!isset($validIds[$pairingId])) {
                continue;
            }
            $whiteRaw = $data['white'] ?? 0;
            $blackRaw = $data['black'] ?? 0;
            $white = is_numeric($whiteRaw) ? (int) $whiteRaw : 0;
            $black = ($blackRaw === 'bye' || $blackRaw === '0') ? 0 : (is_numeric($blackRaw) ? (int) $blackRaw : 0);
            $convertToBye = ($blackRaw === 'bye');
            $result = isset($data['result']) ? self::normalize_result(sanitize_text_field((string) $data['result'])) : null;

            $row = $existingById[$pairingId] ?? null;
            if (!$row) {
                continue;
            }
            $isBye = (int) $row->is_bye === 1;

            if ($isBye) {
                if ($white > 0 && $black === 0) {
                    $updates[$pairingId] = ['white' => $white, 'black' => 0, 'is_bye' => 1, 'bye_points' => 0.5, 'result' => $result];
                    $usedWhite[$white] = true;
                }
                continue;
            }

            if ($convertToBye && $white > 0) {
                $origBlack = (int) $row->black_player_id;
                $updates[$pairingId] = ['white' => $white, 'black' => 0, 'is_bye' => 1, 'bye_points' => 0.5, 'result' => 'BYE'];
                $usedWhite[$white] = true;
                if ($origBlack > 0) {
                    $extraByesToAdd[] = ['player_id' => $origBlack, 'points' => 0.5];
                }
                continue;
            }

            if ($white <= 0 || $black <= 0) {
                return false;
            }
            if ($white === $black) {
                return false;
            }
            if (isset($usedWhite[$white]) || isset($usedBlack[$white])) {
                return false;
            }
            if (isset($usedWhite[$black]) || isset($usedBlack[$black])) {
                return false;
            }

            $whiteInRound = isset($playersInOriginalRound[$white]);
            $blackInRound = isset($playersInOriginalRound[$black]);
            $playerNotInRound = !$whiteInRound || !$blackInRound;
            $resultIsForfeit = in_array($result, ['1F', '0F'], true);
            $isForfeitPairing = $playerNotInRound || $resultIsForfeit;

            if ($isForfeitPairing) {
                if ($playerNotInRound) {
                    $absent = !$whiteInRound ? $white : $black;
                    $present = !$whiteInRound ? $black : $white;
                    $forfeitResult = !$whiteInRound ? '0F' : '1F';
                    $origWhite = (int) $row->white_player_id;
                    $origBlack = (int) $row->black_player_id;
                    if ($origWhite > 0 && $origWhite !== $present && $origWhite !== $absent) {
                        $extraByesToAdd[] = ['player_id' => $origWhite, 'points' => 0.5];
                    }
                    if ($origBlack > 0 && $origBlack !== $present && $origBlack !== $absent) {
                        $extraByesToAdd[] = ['player_id' => $origBlack, 'points' => 0.5];
                    }
                } else {
                    $forfeitResult = $result;
                }
                $usedWhite[$white] = true;
                $usedBlack[$black] = true;
                $forfeitPairingsToInsert[] = [
                    'white' => $white,
                    'black' => $black,
                    'result' => in_array($result, ['1-0', '0-1', '1F', '0F'], true) ? $result : $forfeitResult,
                ];
                $pairingIdsToDelete[] = $pairingId;
            } else {
                $usedWhite[$white] = true;
                $usedBlack[$black] = true;
                $updates[$pairingId] = ['white' => $white, 'black' => $black, 'is_bye' => 0, 'bye_points' => null, 'result' => $result];
            }
        }

        foreach ($addByes as $pid => $pts) {
            $pid = (int) $pid;
            if ($pid > 0 && !isset($usedWhite[$pid]) && !isset($usedBlack[$pid])) {
                $extraByesToAdd[] = ['player_id' => $pid, 'points' => (float) $pts];
            }
        }

        foreach ($updates as $pairingId => $u) {
            $data = [
                'white_player_id' => $u['white'],
                'black_player_id' => $u['black'],
            ];
            $fmt = ['%d', '%d'];
            if (isset($u['is_bye'])) {
                $data['is_bye'] = (int) $u['is_bye'];
                $fmt[] = '%d';
            }
            if (array_key_exists('bye_points', $u)) {
                $data['bye_points'] = $u['bye_points'] === null ? null : (float) $u['bye_points'];
                $fmt[] = '%f';
            }
            if (isset($u['result'])) {
                $validRes = ['', '1-0', '0-1', '1/2-1/2', '1F', '0F', 'BYE'];
                if (in_array($u['result'], $validRes, true)) {
                    $data['result'] = $u['result'] === '' ? null : $u['result'];
                    $fmt[] = '%s';
                }
            }
            $wpdb->update($table, $data, ['id' => $pairingId], $fmt, ['%d']);
        }

        foreach ($pairingIdsToDelete as $pid) {
            $wpdb->delete($table, ['id' => $pid], ['%d']);
        }
        foreach ($forfeitPairingsToInsert as $fp) {
            $res = in_array($fp['result'], ['1-0', '0-1', '1F', '0F'], true) ? $fp['result'] : '1F';
            $wpdb->insert($table, [
                'tournament_id' => $tournamentId,
                'round_no' => $roundNo,
                'white_player_id' => (int) $fp['white'],
                'black_player_id' => (int) $fp['black'],
                'is_bye' => 0,
                'result' => $res,
                'created_at' => current_time('mysql'),
            ], ['%d', '%d', '%d', '%d', '%d', '%s', '%s']);
        }

        foreach ($extraByesToAdd as $bye) {
            $pid = (int) $bye['player_id'];
            $pts = (float) $bye['points'];
            if ($pid <= 0 || isset($usedWhite[$pid])) {
                continue;
            }
            $usedWhite[$pid] = true;
            $wpdb->insert($table, [
                'tournament_id' => $tournamentId,
                'round_no' => $roundNo,
                'white_player_id' => $pid,
                'black_player_id' => 0,
                'is_bye' => 1,
                'result' => 'BYE',
                'bye_points' => $pts,
                'created_at' => current_time('mysql'),
            ], ['%d', '%d', '%d', '%d', '%d', '%s', '%f', '%s']);
        }
        return true;
    }

    public static function get_tournaments(): array
    {
        global $wpdb;
        return $wpdb->get_results('SELECT * FROM ' . self::db_table('regina_tournaments') . ' ORDER BY id DESC'); // phpcs:ignore
    }

    public static function get_tournament(int $id): ?object
    {
        global $wpdb;
        $row = $wpdb->get_row($wpdb->prepare('SELECT * FROM ' . self::db_table('regina_tournaments') . ' WHERE id = %d', $id));
        return $row ?: null;
    }

    /** Returns the tournament in progress, or the latest tournament. For registration shortcode when no ID is specified. */
    public static function get_current_tournament(): ?object
    {
        $t = self::get_tournament_in_progress();
        return $t ?: self::get_latest_tournament();
    }

    private static function get_latest_tournament(): ?object
    {
        global $wpdb;
        $row = $wpdb->get_row('SELECT * FROM ' . self::db_table('regina_tournaments') . ' ORDER BY id DESC LIMIT 1'); // phpcs:ignore
        return $row ?: null;
    }

    /** Returns the latest tournament that is still in progress (current_round < rounds_total). */
    private static function get_tournament_in_progress(): ?object
    {
        global $wpdb;
        $table = self::db_table('regina_tournaments');
        $row = $wpdb->get_row(
            "SELECT * FROM {$table} WHERE current_round < rounds_total ORDER BY id DESC LIMIT 1" // phpcs:ignore
        );
        return $row ?: null;
    }

    public static function get_players(int $tournamentId): array
    {
        $key = self::cache_key($tournamentId, 'players');
        $cached = self::cache_get($key);
        if (is_array($cached)) {
            return $cached;
        }
        global $wpdb;
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT * FROM ' . self::db_table('regina_players') . ' WHERE tournament_id = %d ORDER BY rating DESC, name ASC',
                $tournamentId
            )
        );
        self::cache_set($key, $rows, 300);
        return $rows;
    }

    private static function get_player_by_fide_id(int $tournamentId, string $fideId): ?object
    {
        global $wpdb;
        $row = $wpdb->get_row(
            $wpdb->prepare(
                'SELECT * FROM ' . self::db_table('regina_players') . ' WHERE tournament_id = %d AND fide_id = %s LIMIT 1',
                $tournamentId,
                $fideId
            )
        );
        return $row ?: null;
    }

    public static function get_pairings_for_round(int $tournamentId, int $round): array
    {
        global $wpdb;
        if ($round <= 0) {
            return [];
        }
        $key = self::cache_key($tournamentId, 'pairings_round', (string) $round);
        $cached = self::cache_get($key);
        if (is_array($cached)) {
            return $cached;
        }
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT p.*, wp.name AS white_name, wp.country AS white_country, wp.title AS white_title,
                bp.name AS black_name, bp.country AS black_country, bp.title AS black_title
                FROM $pairings p
                LEFT JOIN $players wp ON p.white_player_id = wp.id
                LEFT JOIN $players bp ON p.black_player_id = bp.id
                WHERE p.tournament_id = %d AND p.round_no = %d
                ORDER BY (CASE WHEN p.is_bye = 1 OR p.result IN ('1F','0F') THEN 1 ELSE 0 END) ASC, p.id ASC",
                $tournamentId,
                $round
            )
        );
        self::cache_set($key, $rows, 120);
        return $rows;
    }

    private static function get_live_pairings_for_tournament(int $tournamentId, int $currentRound = 0): array
    {
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $pgns = self::db_table('regina_tournament_pgns');
        // Only treat "is_live" as live while the game is still in progress.
        // Finished games should still appear when a PGN is available (either live_pgn or archived regina_tournament_pgns).
        $liveOrPgn = "((p.is_live = 1 AND (p.result IS NULL OR p.result = '')) OR (p.live_pgn IS NOT NULL AND p.live_pgn != '') OR EXISTS (SELECT 1 FROM $pgns tp WHERE tp.tournament_id = p.tournament_id AND tp.pairing_id = p.id))";
        if ($currentRound > 0) {
            return $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT p.*, wp.name AS white_name, wp.country AS white_country, wp.title AS white_title,
                    bp.name AS black_name, bp.country AS black_country, bp.title AS black_title
                    FROM $pairings p
                    LEFT JOIN $players wp ON p.white_player_id = wp.id
                    LEFT JOIN $players bp ON p.black_player_id = bp.id
                    WHERE p.tournament_id = %d AND p.round_no = %d AND p.is_bye = 0 AND $liveOrPgn
                    ORDER BY p.is_live DESC, p.id ASC",
                    $tournamentId,
                    $currentRound
                )
            );
        }
        return $wpdb->get_results(
            $wpdb->prepare(
                "SELECT p.*, wp.name AS white_name, wp.country AS white_country, wp.title AS white_title,
                bp.name AS black_name, bp.country AS black_country, bp.title AS black_title
                FROM $pairings p
                LEFT JOIN $players wp ON p.white_player_id = wp.id
                LEFT JOIN $players bp ON p.black_player_id = bp.id
                WHERE p.tournament_id = %d AND p.is_bye = 0 AND $liveOrPgn
                ORDER BY p.round_no ASC, p.is_live DESC, p.id ASC",
                $tournamentId
            )
        );
    }

    public static function get_pairings(int $tournamentId): array
    {
        $key = self::cache_key($tournamentId, 'pairings');
        $cached = self::cache_get($key);
        if (is_array($cached)) {
            return $cached;
        }
        global $wpdb;
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                'SELECT * FROM ' . self::db_table('regina_pairings') . ' WHERE tournament_id = %d ORDER BY round_no ASC, id ASC',
                $tournamentId
            )
        );
        self::cache_set($key, $rows, 120);
        return $rows;
    }

    private static function get_pairings_detailed(int $tournamentId): array
    {
        $key = self::cache_key($tournamentId, 'pairings_detailed');
        $cached = self::cache_get($key);
        if (is_array($cached)) {
            return $cached;
        }
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $players = self::db_table('regina_players');
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT p.*, wp.name AS white_name, wp.country AS white_country, wp.title AS white_title,
                bp.name AS black_name, bp.country AS black_country, bp.title AS black_title
                FROM $pairings p
                LEFT JOIN $players wp ON p.white_player_id = wp.id
                LEFT JOIN $players bp ON p.black_player_id = bp.id
                WHERE p.tournament_id = %d
                ORDER BY p.round_no ASC, p.id ASC",
                $tournamentId
            )
        );
        self::cache_set($key, $rows, 120);
        return $rows;
    }

    /**
     * Create the next round’s pairings from a Lichess broadcast round PGN URL (no pairing engine).
     *
     * @return array{ok: bool, message?: string, games?: int}
     */
    private static function import_next_round_from_lichess_pgn_url(int $tournamentId, string $rawUrl): array
    {
        self::extend_long_operation_budget(300);
        if (!class_exists('ChessPodium_LichessRoundImport') || !class_exists('ChessPodium_PgnLive')) {
            return ['ok' => false, 'message' => __('Chess Podium modules not loaded.', 'chess-podium')];
        }
        $pgnUrl = ChessPodium_LichessRoundImport::normalize_pgn_url($rawUrl);
        if ($pgnUrl === '') {
            return ['ok' => false, 'message' => __('Invalid Lichess URL. Use the round PGN link (…/api/broadcast/round/XXXX.pgn) or a lichess.org/broadcast/… URL whose last path segment is the round ID.', 'chess-podium')];
        }
        // Some hosting stacks/plugins globally clamp WP HTTP timeouts (often to 10s),
        // which breaks Lichess imports for slow connections or large broadcasts.
        // Force a safer timeout just for this Lichess PGN request.
        $forceTimeout = static function (array $args, string $url) use ($pgnUrl): array {
            if ($url === $pgnUrl || strpos($url, 'https://lichess.org/api/broadcast/round/') === 0) {
                $args['timeout'] = 120;
            }
            return $args;
        };
        add_filter('http_request_args', $forceTimeout, 50, 2);
        try {
            $response = wp_remote_get(
                $pgnUrl,
                [
                    'timeout' => 120,
                    'redirection' => 3,
                    'user-agent' => 'ChessPodium/1.9.3 (+WordPress; Lichess round import)',
                    'headers' => [
                        'Accept' => 'application/x-chess-pgn, text/plain, */*',
                        'Accept-Encoding' => 'identity',
                    ],
                ]
            );
        } finally {
            remove_filter('http_request_args', $forceTimeout, 50);
        }
        if (is_wp_error($response)) {
            // Retry once with a longer timeout if we hit a connection timeout.
            if (strpos($response->get_error_message(), 'cURL error 28') !== false) {
                $forceTimeout2 = static function (array $args, string $url) use ($pgnUrl): array {
                    if ($url === $pgnUrl || strpos($url, 'https://lichess.org/api/broadcast/round/') === 0) {
                        $args['timeout'] = 240;
                    }
                    return $args;
                };
                add_filter('http_request_args', $forceTimeout2, 50, 2);
                try {
                    $response2 = wp_remote_get(
                        $pgnUrl,
                        [
                            'timeout' => 240,
                            'redirection' => 3,
                            'user-agent' => 'ChessPodium/1.9.3 (+WordPress; Lichess round import retry)',
                            'headers' => [
                                'Accept' => 'application/x-chess-pgn, text/plain, */*',
                                'Accept-Encoding' => 'identity',
                            ],
                        ]
                    );
                } finally {
                    remove_filter('http_request_args', $forceTimeout2, 50);
                }
                if (!is_wp_error($response2)) {
                    $response = $response2;
                } else {
                    return ['ok' => false, 'message' => $response2->get_error_message()];
                }
            } else {
            return ['ok' => false, 'message' => $response->get_error_message()];
            }
        }
        if ((int) wp_remote_retrieve_response_code($response) !== 200) {
            return ['ok' => false, 'message' => sprintf(__('HTTP %d when fetching PGN.', 'chess-podium'), (int) wp_remote_retrieve_response_code($response))];
        }
        $body = (string) wp_remote_retrieve_body($response);
        if (trim($body) === '') {
            return ['ok' => false, 'message' => __('Empty PGN response.', 'chess-podium')];
        }

        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return ['ok' => false, 'message' => __('Tournament not found.', 'chess-podium')];
        }
        if ((int) $tournament->current_round >= (int) $tournament->rounds_total) {
            return ['ok' => false, 'message' => __('All rounds are already generated.', 'chess-podium')];
        }
        $nextRound = (int) $tournament->current_round + 1;

        global $wpdb;
        $pairingsTable = self::db_table('regina_pairings');
        $existing = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT COUNT(*) FROM $pairingsTable WHERE tournament_id = %d AND round_no = %d",
            $tournamentId,
            $nextRound
        ));
        if ($existing > 0) {
            return ['ok' => false, 'message' => __('Pairings for the next round already exist. Roll back the current round first or remove that round’s pairings.', 'chess-podium')];
        }

        $allPlayers = self::get_players($tournamentId);
        if (count($allPlayers) < 2) {
            return ['ok' => false, 'message' => __('Need at least two players.', 'chess-podium')];
        }

        $byePointsDefault = isset($tournament->bye_points) ? (float) $tournament->bye_points : 1.0;
        $statusMap = ChessPodium_PlayerStatus::get_all_for_round($tournamentId, $nextRound);
        $withdrawnIds = ChessPodium_PlayerStatus::get_withdrawn_before_round($tournamentId, $nextRound);
        $withdrawn = array_fill_keys($withdrawnIds, true);
        $byeRequested = [];
        $absent = [];
        foreach ($statusMap as $pid => $status) {
            if ($status === ChessPodium_PlayerStatus::STATUS_WITHDRAWN) {
                $withdrawn[(int) $pid] = true;
            } elseif ($status === ChessPodium_PlayerStatus::STATUS_BYE_REQUESTED) {
                $byeRequested[$pid] = true;
            } elseif ($status === ChessPodium_PlayerStatus::STATUS_ABSENT) {
                $absent[(int) $pid] = true;
            }
        }
        $absentQueue = array_values(array_keys($absent));

        $players = array_values(array_filter($allPlayers, static function ($p) use ($withdrawn) {
            return !isset($withdrawn[(int) $p->id]);
        }));
        if (count($players) < 2) {
            return ['ok' => false, 'message' => __('Need at least two non-withdrawn players.', 'chess-podium')];
        }

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

        $games = ChessPodium_PgnLive::split_games($body);
        if (empty($games)) {
            return ['ok' => false, 'message' => __('No games found in PGN.', 'chess-podium')];
        }

        $pairRows = [];
        $usedIds = [];
        foreach ($games as $game) {
            $h = ChessPodium_PgnLive::parse_headers_for_import($game);
            $wName = trim((string) ($h['white'] ?? ''));
            $bName = trim((string) ($h['black'] ?? ''));
            $wFide = isset($h['whitefideid']) ? (string) $h['whitefideid'] : null;
            $bFide = isset($h['blackfideid']) ? (string) $h['blackfideid'] : null;
            $whiteP = ChessPodium_PgnLive::find_player_for_broadcast_header($wName, $wFide, $remainingPlayers);
            $blackP = ChessPodium_PgnLive::find_player_for_broadcast_header($bName, $bFide, $remainingPlayers);
            if ($whiteP === null || $blackP === null) {
                return ['ok' => false, 'message' => sprintf(
                    /* translators: 1: white header, 2: black header */
                    __('Could not match players for a game (White: %1$s vs Black: %2$s). Check names or FIDE IDs against your roster.', 'chess-podium'),
                    $wName !== '' ? $wName : '—',
                    $bName !== '' ? $bName : '—'
                )];
            }
            $wid = (int) $whiteP->id;
            $bid = (int) $blackP->id;
            if ($wid === $bid) {
                return ['ok' => false, 'message' => __('Invalid game: White and Black are the same player.', 'chess-podium')];
            }
            if (isset($usedIds[$wid]) || isset($usedIds[$bid])) {
                return ['ok' => false, 'message' => __('A player appears in more than one game in the PGN.', 'chess-podium')];
            }
            $usedIds[$wid] = true;
            $usedIds[$bid] = true;

            $pgnResult = trim((string) ($h['result'] ?? '*'));
            $result = null;
            if ($pgnResult !== '' && $pgnResult !== '*') {
                $norm = self::normalize_result($pgnResult);
                if (in_array($norm, ['1-0', '0-1', '1/2-1/2'], true)) {
                    $result = $norm;
                }
            }

            $pgnClean = trim($game);
            $pairRows[] = [
                'white' => $wid,
                'black' => $bid,
                'is_bye' => 0,
                'bye_points' => null,
                'result' => $result,
                'live_pgn' => $pgnClean !== '' ? $pgnClean : null,
                'is_live' => ($result === null && $pgnClean !== '') ? 1 : 0,
            ];
        }

        $expectedIds = array_map(static fn ($p) => (int) $p->id, $remainingPlayers);
        sort($expectedIds);
        $matched = array_keys($usedIds);
        sort($matched);
        $missing = array_values(array_diff($expectedIds, $matched));
        if (count($missing) > 1) {
            return ['ok' => false, 'message' => __('Not all active players appear in the PGN. For an odd number of players, exactly one may be unpaired (bye).', 'chess-podium')];
        }
        if (count($missing) === 1) {
            $byeId = (int) $missing[0];
            if (!empty($absentQueue)) {
                $absentId = (int) array_shift($absentQueue);
                $pairRows[] = [
                    'white' => $byeId,
                    'black' => $absentId,
                    'is_bye' => 0,
                    'bye_points' => null,
                    'result' => '1F',
                    'live_pgn' => null,
                    'is_live' => 0,
                ];
            } else {
                $pairRows[] = [
                    'white' => $byeId,
                    'black' => 0,
                    'is_bye' => 1,
                    'bye_points' => $byePointsDefault,
                    'result' => null,
                    'live_pgn' => null,
                    'is_live' => 0,
                ];
            }
        }

        foreach (array_keys($byeRequested) as $pid) {
            $pairRows[] = [
                'white' => (int) $pid,
                'black' => 0,
                'is_bye' => 1,
                'bye_points' => 0.5,
                'result' => null,
                'live_pgn' => null,
                'is_live' => 0,
            ];
        }
        foreach (array_keys($withdrawn) as $wpid) {
            $pairRows[] = [
                'white' => (int) $wpid,
                'black' => 0,
                'is_bye' => 1,
                'bye_points' => 0.0,
                'result' => 'NOT_PAIRED',
                'live_pgn' => null,
                'is_live' => 0,
            ];
        }

        $pairRows = self::apply_fixed_board_assignments($pairRows, $tournamentId, $nextRound);

        $persistentBoards = self::get_persistent_live_boards($tournamentId);
        if (!empty($persistentBoards)) {
            $persistSet = array_fill_keys($persistentBoards, true);
            foreach ($pairRows as $i => $pair) {
                $boardNo = $i + 1;
                if (!empty($persistSet[$boardNo]) && empty($pair['is_bye']) && (int) ($pair['black'] ?? 0) > 0) {
                    $pairRows[$i]['is_live'] = 1;
                }
            }
        }

        foreach ($pairRows as $pair) {
            $res = !empty($pair['is_bye'])
                ? (isset($pair['result']) && $pair['result'] === 'NOT_PAIRED' ? 'NOT_PAIRED' : 'BYE')
                : ($pair['result'] ?? null);
            $insertData = [
                'tournament_id' => $tournamentId,
                'round_no' => $nextRound,
                'white_player_id' => (int) $pair['white'],
                'black_player_id' => (int) $pair['black'],
                'is_bye' => (int) $pair['is_bye'],
                'is_live' => !empty($pair['is_live']) ? 1 : 0,
                'result' => $res,
                'created_at' => current_time('mysql'),
            ];
            $insertFmt = ['%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s'];
            if (isset($pair['bye_points'])) {
                $insertData['bye_points'] = (float) $pair['bye_points'];
                $insertFmt[] = '%f';
            }
            if (!empty($pair['live_pgn'])) {
                $insertData['live_pgn'] = $pair['live_pgn'];
                $insertFmt[] = '%s';
            }
            $wpdb->insert($pairingsTable, $insertData, $insertFmt);
        }

        $wpdb->update(
            self::db_table('regina_tournaments'),
            ['current_round' => $nextRound],
            ['id' => $tournamentId],
            ['%d'],
            ['%d']
        );
        self::$lastPairingSource = 'lichess_pgn';
        set_transient('chess_podium_pairing_source_' . $tournamentId, 'lichess_pgn', 120);

        return ['ok' => true, 'games' => count($games)];
    }

    private static function generate_next_round(int $tournamentId): bool
    {
        global $wpdb;
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return false;
        }
        if ((int) $tournament->current_round >= (int) $tournament->rounds_total) {
            return false;
        }

        $allPlayers = self::get_players($tournamentId);
        if (count($allPlayers) < 2) {
            return false;
        }

        $nextRound = (int) $tournament->current_round + 1;
        $byePointsDefault = isset($tournament->bye_points) ? (float) $tournament->bye_points : 1.0;

        $statusMap = ChessPodium_PlayerStatus::get_all_for_round($tournamentId, $nextRound);
        $withdrawnIds = ChessPodium_PlayerStatus::get_withdrawn_before_round($tournamentId, $nextRound);
        $withdrawn = array_fill_keys($withdrawnIds, true);
        $byeRequested = [];
        $absent = [];
        foreach ($statusMap as $pid => $status) {
            if ($status === ChessPodium_PlayerStatus::STATUS_WITHDRAWN) {
                $withdrawn[(int) $pid] = true;
            } elseif ($status === ChessPodium_PlayerStatus::STATUS_BYE_REQUESTED) {
                $byeRequested[$pid] = true;
            } elseif ($status === ChessPodium_PlayerStatus::STATUS_ABSENT) {
                $absent[(int) $pid] = true;
            }
        }
        $absentIds = array_keys($absent);

        $players = array_values(array_filter($allPlayers, static function ($p) use ($withdrawn) {
            return !isset($withdrawn[(int) $p->id]);
        }));
        if (count($players) < 2) {
            return false;
        }

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

        $type = isset($tournament->tournament_type) ? (string) $tournament->tournament_type : 'swiss';
        if ($type === 'round_robin' || $type === 'double_round_robin') {
            $roundPairs = self::pair_round_robin($remainingPlayers, $nextRound, $type === 'double_round_robin');
        } else {
            $standings = self::compute_standings($tournamentId);
            $roundPairs = self::pair_swiss($remainingPlayers, $standings, $tournamentId, $nextRound, $withdrawn, $byeRequested, $absent);
        }

        if (empty($roundPairs) && count($remainingPlayers) > 0) {
            return false;
        }

        // Order like chess-results: games + pairing-allocated bye first (boards 1..N), then bye requested, then withdrawn.
        // This ensures board numbers match chess-results (fixed board 34 = same physical table).
        $pairings = [];
        foreach ($roundPairs as $pair) {
            $isBye = !empty($pair['is_bye']);
            $white = (int) $pair['white'];
            $black = (int) $pair['black'];
            if ($isBye && $black === 0 && !empty($absentIds)) {
                $absentId = array_shift($absentIds);
                $pairings[] = [
                    'white' => $white,
                    'black' => $absentId,
                    'is_bye' => 0,
                    'bye_points' => null,
                    'result' => '1F',
                ];
                continue;
            }
            $byePts = $isBye ? $byePointsDefault : null;
            $pairings[] = [
                'white' => $white,
                'black' => $black,
                'is_bye' => $isBye ? 1 : 0,
                'bye_points' => $byePts,
            ];
        }
        foreach (array_keys($byeRequested) as $pid) {
            $pairings[] = [
                'white' => $pid,
                'black' => 0,
                'is_bye' => 1,
                'bye_points' => 0.5,
            ];
        }
        foreach (array_keys($withdrawn) as $wpid) {
            $pairings[] = [
                'white' => (int) $wpid,
                'black' => 0,
                'is_bye' => 1,
                'bye_points' => 0.0,
                'result' => 'NOT_PAIRED',
            ];
        }

        $pairings = self::apply_fixed_board_assignments($pairings, $tournamentId, $nextRound);

        // Persist DGT/live boards across rounds: if a physical board was flagged once, keep it flagged.
        $persistentBoards = self::get_persistent_live_boards($tournamentId);
        if (!empty($persistentBoards)) {
            $persistSet = array_fill_keys($persistentBoards, true);
            foreach ($pairings as $i => $pair) {
                $boardNo = $i + 1;
                if (!empty($persistSet[$boardNo]) && empty($pair['is_bye']) && (int) ($pair['black'] ?? 0) > 0) {
                    $pairings[$i]['is_live'] = 1;
                }
            }
        }

        foreach ($pairings as $pair) {
            $result = $pair['is_bye'] ? (isset($pair['result']) && $pair['result'] === 'NOT_PAIRED' ? 'NOT_PAIRED' : 'BYE') : (isset($pair['result']) ? $pair['result'] : null);
            $insertData = [
                'tournament_id' => $tournamentId,
                'round_no' => $nextRound,
                'white_player_id' => (int) $pair['white'],
                'black_player_id' => (int) $pair['black'],
                'is_bye' => (int) $pair['is_bye'],
                'is_live' => !empty($pair['is_live']) ? 1 : 0,
                'result' => $result,
                'created_at' => current_time('mysql'),
            ];
            $insertFmt = ['%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s'];
            if (isset($pair['bye_points'])) {
                $insertData['bye_points'] = (float) $pair['bye_points'];
                $insertFmt[] = '%f';
            }
            $wpdb->insert(self::db_table('regina_pairings'), $insertData, $insertFmt);
        }

        $wpdb->update(
            self::db_table('regina_tournaments'),
            ['current_round' => $nextRound],
            ['id' => $tournamentId],
            ['%d'],
            ['%d']
        );
        if (self::$lastPairingSource !== null) {
            set_transient('chess_podium_pairing_source_' . $tournamentId, self::$lastPairingSource, 120);
        }
        return true;
    }

    private static function rollback_current_round(int $tournamentId): bool
    {
        global $wpdb;
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return false;
        }

        $currentRound = (int) $tournament->current_round;
        if ($currentRound <= 0) {
            return false;
        }

        $wpdb->delete(
            self::db_table('regina_pairings'),
            [
                'tournament_id' => $tournamentId,
                'round_no' => $currentRound,
            ],
            ['%d', '%d']
        );

        $wpdb->update(
            self::db_table('regina_tournaments'),
            ['current_round' => $currentRound - 1],
            ['id' => $tournamentId],
            ['%d'],
            ['%d']
        );
        return true;
    }

    /**
     * Swap white/black for all pairings in the current round. Also swaps results (1-0↔0-1, 1F↔0F).
     *
     * @param int $tournamentId Tournament ID.
     * @return bool True on success.
     */
    private static function swap_round_colors(int $tournamentId): bool
    {
        global $wpdb;
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return false;
        }
        $currentRound = (int) $tournament->current_round;
        if ($currentRound <= 0) {
            return false;
        }

        $pairings = self::get_pairings_for_round($tournamentId, $currentRound);
        $table = self::db_table('regina_pairings');
        $resultSwap = ['1-0' => '0-1', '0-1' => '1-0', '1F' => '0F', '0F' => '1F'];

        foreach ($pairings as $p) {
            if ((int) $p->is_bye === 1) {
                continue;
            }
            $white = (int) $p->white_player_id;
            $black = (int) $p->black_player_id;
            $res = trim((string) ($p->result ?? ''));
            $newRes = $resultSwap[$res] ?? $res;

            $wpdb->update(
                $table,
                [
                    'white_player_id' => $black,
                    'black_player_id' => $white,
                    'result' => $newRes,
                ],
                ['id' => (int) $p->id],
                ['%d', '%d', '%s'],
                ['%d']
            );
        }
        return true;
    }

    private static function save_results_for_tournament(int $tournamentId, array $results, array $live = [], int $editRound = 0): bool
    {
        global $wpdb;
        $validResults = ['', '1-0', '0-1', '1/2-1/2', '1F', '0F'];
        $pairingIds = self::get_pairing_ids_for_tournament($tournamentId);
        $validPairingIds = array_flip($pairingIds);
        $isPro = class_exists('ChessPodium_License') && ChessPodium_License::is_pro();
        $pgnsTable = self::db_table('regina_tournament_pgns');
        $pairingsTable = self::db_table('regina_pairings');
        $playersTable = self::db_table('regina_players');

        foreach ($results as $pairingId => $result) {
            $pairingId = (int) $pairingId;
            $result = self::normalize_result(sanitize_text_field((string) $result));
            if (!isset($validPairingIds[$pairingId]) || !in_array($result, $validResults, true)) {
                continue;
            }
            self::update_pairing_result($pairingId, $result);
            if ($isPro) {
                $isLive = isset($live[$pairingId]) && ($live[$pairingId] === '1' || $live[$pairingId] === 1);
                $wpdb->update(
                    $pairingsTable,
                    ['is_live' => $isLive ? 1 : 0],
                    ['id' => $pairingId],
                    ['%d'],
                    ['%d']
                );
            }

            // If a finished result is saved manually and we already have a live PGN for this pairing,
            // persist it into the PGN archive table so it remains visible after the event ends.
            if ($result !== '' && $editRound > 0) {
                $pgnRow = $wpdb->get_row($wpdb->prepare(
                    "SELECT p.id, p.tournament_id, p.round_no, p.pairing_id, p.white_name, p.black_name, p.result, p.pgn_content
                    FROM $pgnsTable p
                    WHERE p.tournament_id = %d AND p.pairing_id = %d
                    LIMIT 1",
                    $tournamentId,
                    $pairingId
                ));
                if (!$pgnRow) {
                    $pair = $wpdb->get_row($wpdb->prepare(
                        "SELECT p.id, p.tournament_id, p.round_no, p.live_pgn, wp.name AS white_name, bp.name AS black_name
                        FROM $pairingsTable p
                        LEFT JOIN $playersTable wp ON p.white_player_id = wp.id
                        LEFT JOIN $playersTable bp ON p.black_player_id = bp.id
                        WHERE p.id = %d AND p.tournament_id = %d AND p.round_no = %d AND p.is_bye = 0
                        LIMIT 1",
                        $pairingId,
                        $tournamentId,
                        $editRound
                    ));
                    if ($pair && is_string($pair->live_pgn) && trim((string) $pair->live_pgn) !== '') {
                        $wpdb->insert(
                            $pgnsTable,
                            [
                                'tournament_id' => $tournamentId,
                                'round_no' => $editRound,
                                'pairing_id' => $pairingId,
                                'white_name' => isset($pair->white_name) ? (string) $pair->white_name : null,
                                'black_name' => isset($pair->black_name) ? (string) $pair->black_name : null,
                                'result' => $result,
                                'pgn_content' => trim((string) $pair->live_pgn),
                                'created_at' => current_time('mysql'),
                            ],
                            ['%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s']
                        );
                    }
                }
            }
        }
        if ($isPro && !empty($live) && $editRound > 0) {
            $roundPairings = self::get_pairings_for_round($tournamentId, $editRound);
            foreach ($roundPairings as $p) {
                $pairingId = (int) $p->id;
                if ((int) ($p->is_bye ?? 0) === 1) {
                    continue;
                }
                $isLive = isset($live[$pairingId]) && ($live[$pairingId] === '1' || $live[$pairingId] === 1);
                $wpdb->update(
                    $pairingsTable,
                    ['is_live' => $isLive ? 1 : 0],
                    ['id' => $pairingId],
                    ['%d'],
                    ['%d']
                );
            }
        }
        self::bump_cache_version($tournamentId);
        return true;
    }

    /**
     * Import results from chess-results.com Excel export (.xlsx).
     * Maps results by board order (board 1 = first pairing, etc.).
     *
     * @param int $tournamentId
     * @param string $filePath Temp path to uploaded .xlsx file
     * @param int $roundNo Round to apply to
     * @return array{success: bool, count?: int, error?: string}
     */
    private static function import_results_from_chess_results_excel(int $tournamentId, string $filePath, int $roundNo = 0): array
    {
        if (!is_readable($filePath) || filesize($filePath) < 100) {
            return ['success' => false, 'error' => 'import_cr_error'];
        }

        $parsed = ChessPodium_ChessResultsExcelImport::parse($filePath);
        if (!$parsed['success'] || empty($parsed['results'])) {
            return [
                'success' => false,
                'error' => $parsed['error'] ?? 'import_cr_error',
            ];
        }

        if ($roundNo <= 0) {
            return ['success' => false, 'error' => 'import_cr_no_round'];
        }

        $pairings = self::get_pairings_for_round($tournamentId, $roundNo);
        if (empty($pairings)) {
            return ['success' => false, 'error' => 'import_cr_no_pairings'];
        }

        $count = 0;
        foreach ($parsed['results'] as $board => $entry) {
            $idx = $board - 1;
            if ($idx < 0 || $idx >= count($pairings)) {
                continue;
            }
            $pairing = $pairings[$idx];
            $pairingId = (int) $pairing->id;
            $result = $entry['result'] ?? '';
            $byePoints = $entry['bye_points'] ?? null;

            if ((int) $pairing->is_bye === 1) {
                self::update_pairing_result_and_bye($pairingId, 'BYE', $byePoints ?? 0.5);
            } else {
                self::update_pairing_result($pairingId, $result);
            }
            $count++;
        }

        return ['success' => true, 'count' => $count];
    }

    private static function bulk_import_players_file(int $tournamentId, string $filePath, string $fileName): array
    {
        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
        if ($ext === 'xlsx') {
            return self::bulk_import_players_xlsx($tournamentId, $filePath);
        }
        return self::bulk_import_players_csv($tournamentId, $filePath);
    }

    private static function bulk_import_players_xlsx(int $tournamentId, string $filePath): array
    {
        $rows = ChessPodium_XlsxReader::read($filePath);
        if ($rows === false) {
            $err = ChessPodium_XlsxReader::getLastError() ?? 'Invalid xlsx file';
            return ['success' => false, 'message' => $err];
        }
        if (empty($rows)) {
            return ['success' => false, 'message' => 'Empty file'];
        }
        $header = array_map('trim', array_map('strval', $rows[0]));
        $colMap = self::map_chess_results_columns($header);
        $hasHeader = $colMap['name'] !== null || $colMap['rating'] !== null || $colMap['country'] !== null;
        $dataRows = $hasHeader ? array_slice($rows, 1) : $rows;
        $count = 0;
        foreach ($dataRows as $row) {
            $name = $colMap['name'] !== null ? trim((string) ($row[$colMap['name']] ?? '')) : trim((string) ($row[0] ?? ''));
            if ($name === '' || in_array(strtolower($name), ['nome', 'name', 'player', 'nr'], true)) {
                continue;
            }
            if (!ChessPodium_License::can_add_players(count(self::get_players($tournamentId)), 1)) {
                break;
            }
            $rating = 0;
            if ($colMap['rating'] !== null) {
                $r = $row[$colMap['rating']] ?? 0;
                $rating = max(0, min(3500, (int) $r));
            }
            $fideId = null;
            if ($colMap['fide_id'] !== null) {
                $fideId = self::sanitize_fide_id((string) ($row[$colMap['fide_id']] ?? ''));
                if ($fideId === '') {
                    $fideId = null;
                }
            }
            $country = null;
            if ($colMap['country'] !== null) {
                $country = self::sanitize_country_code(trim((string) ($row[$colMap['country']] ?? '')));
            }
            $title = null;
            if ($colMap['title'] !== null) {
                $title = self::sanitize_fide_title(trim((string) ($row[$colMap['title']] ?? '')));
            }
            self::insert_player($tournamentId, $name, $rating, $fideId, $country, $title, null, null);
            $count++;
        }
        return ['success' => true, 'count' => $count];
    }

    /**
     * Map chess-results / Excel column headers to Chess Podium fields.
     *
     * @param array<string> $headers First row headers
     * @return array{name:?int,rating:?int,fide_id:?int,country:?int,title:?int}
     */
    private static function map_chess_results_columns(array $headers): array
    {
        $map = ['name' => null, 'rating' => null, 'fide_id' => null, 'country' => null, 'title' => null];
        $nameKeys = ['name', 'nome', 'giocatore', 'player', 'spieler', 'joueur', 'jugador'];
        $ratingKeys = ['rtg', 'rtgf', 'elo', 'rating', 'rating fide'];
        $fideKeys = ['fide id', 'fideid', 'id fide', 'id', 'fide'];
        $countryKeys = ['fed', 'federation', 'country', 'nazione', 'nazionalità', 'nazionalita', 'land', 'pays', 'país'];
        $titleKeys = ['title', 'titolo', 'titel', 'titre', 'título'];
        foreach ($headers as $i => $h) {
            $h = strtolower(trim((string) $h));
            if ($h === '') {
                continue;
            }
            $map['name'] ??= in_array($h, $nameKeys, true) || stripos($h, 'name') !== false ? $i : null;
            $map['rating'] ??= in_array($h, $ratingKeys, true) || stripos($h, 'rtg') !== false || stripos($h, 'elo') !== false ? $i : null;
            $map['fide_id'] ??= in_array($h, $fideKeys, true) || stripos($h, 'fide') !== false ? $i : null;
            $map['country'] ??= in_array($h, $countryKeys, true) || stripos($h, 'fed') !== false || stripos($h, 'nazional') !== false ? $i : null;
            $map['title'] ??= in_array($h, $titleKeys, true) ? $i : null;
        }
        $numCols = count(array_filter($headers, static fn ($x) => trim((string) $x) !== '')) ?: count($headers);
        if ($map['name'] === null || $map['rating'] === null || $map['fide_id'] === null || $map['country'] === null) {
            if ($numCols >= 4) {
                $map['name'] ??= 0;
                $map['fide_id'] ??= 1;
                $map['country'] ??= 2;
                $map['rating'] ??= 3;
            } elseif ($numCols >= 2) {
                $map['name'] ??= 0;
                $map['rating'] ??= 1;
            }
        }
        return $map;
    }

    private static function bulk_import_players_csv(int $tournamentId, string $filePath): array
    {
        $handle = fopen($filePath, 'r');
        if ($handle === false) {
            return ['success' => false];
        }
        $count = 0;
        $delimiter = ';';
        while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
            if (count($row) === 1 && strpos($row[0], ',') !== false) {
                $row = str_getcsv($row[0], ',');
            }
            if (empty($row)) {
                continue;
            }
            $name = trim((string) $row[0]);
            if ($name === '' || in_array(strtolower($name), ['nome', 'name', 'player'], true)) {
                continue;
            }
            if (!ChessPodium_License::can_add_players(count(self::get_players($tournamentId)), 1)) {
                break;
            }
            $rating = isset($row[1]) ? max(0, min(3500, (int) $row[1])) : 0;
            $fideId = isset($row[2]) ? self::sanitize_fide_id((string) $row[2]) : null;
            if ($fideId === '') {
                $fideId = null;
            }
            $country = isset($row[3]) ? self::sanitize_country_code(trim((string) $row[3])) : null;
            $uscfId = null;
            $ecfId = null;
            $title = null;
            if (count($row) >= 7) {
                $uscfId = isset($row[4]) ? self::sanitize_uscf_id((string) $row[4]) : null;
                $ecfId = isset($row[5]) ? self::sanitize_ecf_id((string) $row[5]) : null;
                $title = isset($row[6]) ? self::sanitize_fide_title(trim((string) $row[6])) : null;
            } elseif (count($row) >= 5) {
                $title = isset($row[4]) ? self::sanitize_fide_title(trim((string) $row[4])) : null;
            }
            if ($uscfId === '') {
                $uscfId = null;
            }
            if ($ecfId === '') {
                $ecfId = null;
            }
            self::insert_player($tournamentId, $name, $rating, $fideId, $country, $title, $uscfId, $ecfId);
            $count++;
        }
        fclose($handle);
        return ['success' => true, 'count' => $count];
    }

    private static function get_pairing_ids_for_tournament(int $tournamentId): array
    {
        global $wpdb;
        $ids = $wpdb->get_col(
            $wpdb->prepare(
                'SELECT id FROM ' . self::db_table('regina_pairings') . ' WHERE tournament_id = %d',
                $tournamentId
            )
        );
        return array_map('intval', (array) $ids);
    }

    private static function get_venue_photo_url(int $tournamentId): ?string
    {
        if (!class_exists('ChessPodium_PdfBrochure')) {
            return null;
        }
        $brochureMeta = ChessPodium_PdfBrochure::get_all_meta($tournamentId);
        $venuePhotos = isset($brochureMeta['brochure_venue_photos']) ? json_decode($brochureMeta['brochure_venue_photos'], true) : [];
        if (!is_array($venuePhotos) || empty($venuePhotos)) {
            return null;
        }
        $first = $venuePhotos[0];
        if (is_string($first)) {
            return $first;
        }
        if (is_array($first) && isset($first['url']) && is_string($first['url'])) {
            return $first['url'];
        }
        return null;
    }

    private static function publish_tournament_to_folder(int $tournamentId): array
    {
        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            return ['success' => false];
        }
        $players = self::get_players($tournamentId);
        $standings = self::compute_standings($tournamentId);
        $allPairings = self::get_pairings_detailed($tournamentId);
        $photos = self::get_photos($tournamentId);
        $pgns = self::get_pgns($tournamentId);
        $venuePhotoUrl = self::get_venue_photo_url($tournamentId);
        return ChessPodium_ExportEngine::generate_export($tournament, $players, $standings, $allPairings, $photos, $pgns, $venuePhotoUrl);
    }

    private static function insert_photo(int $tournamentId, string $url, string $caption): void
    {
        global $wpdb;
        $maxOrder = (int) $wpdb->get_var($wpdb->prepare('SELECT COALESCE(MAX(sort_order), 0) FROM ' . self::db_table('regina_tournament_photos') . ' WHERE tournament_id = %d', $tournamentId));
        $wpdb->insert(self::db_table('regina_tournament_photos'), ['tournament_id' => $tournamentId, 'photo_url' => $url, 'caption' => $caption, 'sort_order' => $maxOrder + 1, 'created_at' => current_time('mysql')], ['%d', '%s', '%s', '%d', '%s']);
    }

    private static function handle_bulk_photo_upload(int $tournamentId, array $files, string $caption): int
    {
        $count = 0;
        $tmpNames = $files['tmp_name'] ?? [];
        $names = $files['name'] ?? [];
        $errors = $files['error'] ?? [];
        $sizes = $files['size'] ?? [];

        if (!is_array($tmpNames)) {
            return 0;
        }

        foreach ($tmpNames as $i => $tmpName) {
            if (empty($tmpName) || !is_uploaded_file($tmpName)) {
                continue;
            }
            if (isset($errors[$i]) && $errors[$i] !== UPLOAD_ERR_OK) {
                continue;
            }
            $name = sanitize_file_name($names[$i] ?? '');
            $checked = wp_check_filetype($name, null);
            $file = [
                'name' => $name,
                'type' => $checked['type'] ?: '',
                'tmp_name' => $tmpName,
                'error' => (int) ($errors[$i] ?? 0),
                'size' => (int) ($sizes[$i] ?? 0),
            ];
            $attachId = self::handle_photo_upload($tournamentId, $file);
            if ($attachId > 0) {
                $url = wp_get_attachment_url($attachId);
                self::insert_photo($tournamentId, $url, $caption);
                $count++;
            }
        }
        return $count;
    }

    private static function handle_photo_upload(int $tournamentId, array $file): int
    {
        $path = $file['tmp_name'] ?? '';
        $name = isset($file['name']) ? sanitize_file_name($file['name']) : '';
        $checked = wp_check_filetype($path, null);
        if (!empty($checked['type'])) {
            $file['type'] = $checked['type'];
        }
        if (!function_exists('wp_handle_upload')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        $upload = wp_handle_upload($file, ['test_form' => false]);
        if (isset($upload['error'])) {
            return 0;
        }
        $mime = isset($upload['type']) && $upload['type'] !== '' ? $upload['type'] : ($checked['type'] ?? '');
        $attachId = wp_insert_attachment(['post_mime_type' => $mime, 'post_title' => sanitize_file_name(basename($upload['file'])), 'post_content' => '', 'post_status' => 'inherit'], $upload['file']);
        if (is_wp_error($attachId)) {
            return 0;
        }
        require_once ABSPATH . 'wp-admin/includes/image.php';
        wp_generate_attachment_metadata($attachId, $upload['file']);
        return (int) $attachId;
    }

    private static function delete_photo(int $tournamentId, int $photoId): void
    {
        global $wpdb;
        $wpdb->delete(self::db_table('regina_tournament_photos'), ['id' => $photoId, 'tournament_id' => $tournamentId], ['%d', '%d']);
    }

    public static function get_photos(int $tournamentId): array
    {
        global $wpdb;
        return $wpdb->get_results($wpdb->prepare('SELECT * FROM ' . self::db_table('regina_tournament_photos') . ' WHERE tournament_id = %d ORDER BY sort_order ASC, id ASC', $tournamentId));
    }

    private static function insert_pgn(int $tournamentId, int $round, string $content, ?string $white = null, ?string $black = null, ?string $result = null): void
    {
        global $wpdb;
        $wpdb->insert(self::db_table('regina_tournament_pgns'), ['tournament_id' => $tournamentId, 'round_no' => $round, 'white_name' => $white, 'black_name' => $black, 'result' => $result, 'pgn_content' => $content, 'created_at' => current_time('mysql')], ['%d', '%d', '%s', '%s', '%s', '%s', '%s']);
    }

    private static function import_pgn_file(int $tournamentId, string $filePath, int $defaultRound): int
    {
        $content = file_get_contents($filePath);
        if ($content === false) {
            return 0;
        }
        $games = self::split_pgn_games($content);
        return self::import_pgn_games_with_round_detection($tournamentId, $games, $defaultRound);
    }

    /** Import games, auto-assigning round from pairings when White/Black match, then sort by round. */
    private static function import_pgn_games_with_round_detection(int $tournamentId, array $games, int $defaultRound): int
    {
        if (empty($games)) {
            return 0;
        }
        $pairingMap = self::build_pairing_round_map($tournamentId);
        $withRounds = [];
        foreach ($games as $game) {
            $meta = self::parse_pgn_headers($game);
            $w = trim((string) ($meta['white'] ?? ''));
            $b = trim((string) ($meta['black'] ?? ''));
            $round = $defaultRound;
            if ($w !== '' && $b !== '') {
                $key = self::normalize_pgn_name_for_match($w) . '|' . self::normalize_pgn_name_for_match($b);
                if (isset($pairingMap[$key])) {
                    $round = $pairingMap[$key];
                }
            }
            $withRounds[] = ['content' => $game, 'meta' => $meta, 'round' => $round];
        }
        usort($withRounds, static fn ($a, $b) => $a['round'] <=> $b['round']);
        foreach ($withRounds as $g) {
            self::insert_pgn($tournamentId, $g['round'], $g['content'], $g['meta']['white'] ?? null, $g['meta']['black'] ?? null, $g['meta']['result'] ?? null);
        }
        return count($withRounds);
    }

    private static function handle_bulk_pgn_upload(int $tournamentId, array $files, int $defaultRound): int
    {
        $allGames = [];
        $tmpNames = $files['tmp_name'] ?? [];
        $errors = $files['error'] ?? [];
        if (!is_array($tmpNames)) {
            return 0;
        }
        foreach ($tmpNames as $i => $tmpName) {
            if (empty($tmpName) || !is_uploaded_file($tmpName)) {
                continue;
            }
            if (isset($errors[$i]) && $errors[$i] !== UPLOAD_ERR_OK) {
                continue;
            }
            $content = file_get_contents($tmpName);
            if ($content !== false) {
                $allGames = array_merge($allGames, self::split_pgn_games($content));
            }
        }
        return self::import_pgn_games_with_round_detection($tournamentId, $allGames, $defaultRound);
    }

    private static function split_pgn_games(string $content): array
    {
        $games = [];
        $parts = preg_split('/\n\s*\n\s*\n/', trim($content));
        foreach ($parts as $p) {
            $p = trim($p);
            if ($p !== '' && preg_match('/\[Event/', $p)) {
                $games[] = $p;
            }
        }
        if (empty($games) && trim($content) !== '') {
            $games[] = $content;
        }
        return $games;
    }

    private static function parse_pgn_headers(string $pgn): array
    {
        $out = [];
        if (preg_match('/\[White\s+"([^"]*)"\]/', $pgn, $m)) {
            $out['white'] = $m[1];
        }
        if (preg_match('/\[Black\s+"([^"]*)"\]/', $pgn, $m)) {
            $out['black'] = $m[1];
        }
        if (preg_match('/\[Result\s+"([^"]*)"\]/', $pgn, $m)) {
            $out['result'] = $m[1];
        }
        return $out;
    }

    /** Normalize player name for matching PGN vs tournament pairings (strip titles, handle "Last, First" form). */
    private static function normalize_pgn_name_for_match(string $name): string
    {
        $n = preg_replace('/\b(GM|IM|FM|WGM|WIM|WFM|CM|WCM)\s*/i', '', trim($name));
        $n = preg_replace('/\s+/', ' ', $n);
        if (preg_match('/^([^,]+),\s*(.+)$/', $n, $m)) {
            $n = trim($m[2]) . ' ' . trim($m[1]);
        }
        return strtolower($n);
    }

    /** Build map: "white|black" (normalized) => round_no from tournament pairings. */
    private static function build_pairing_round_map(int $tournamentId): array
    {
        $pairings = self::get_pairings_detailed($tournamentId);
        $map = [];
        foreach ($pairings as $p) {
            if ((int) $p->is_bye === 1) {
                continue;
            }
            $w = trim((string) ($p->white_name ?? ''));
            $b = trim((string) ($p->black_name ?? ''));
            if ($w === '' || $b === '') {
                continue;
            }
            $key = self::normalize_pgn_name_for_match($w) . '|' . self::normalize_pgn_name_for_match($b);
            $map[$key] = (int) $p->round_no;
            $keyRev = self::normalize_pgn_name_for_match($b) . '|' . self::normalize_pgn_name_for_match($w);
            $map[$keyRev] = (int) $p->round_no;
        }
        return $map;
    }

    private static function delete_pgn(int $tournamentId, int $pgnId): void
    {
        global $wpdb;
        $wpdb->delete(self::db_table('regina_tournament_pgns'), ['id' => $pgnId, 'tournament_id' => $tournamentId], ['%d', '%d']);
    }

    private static function update_pgn_round(int $tournamentId, int $pgnId, int $round): bool
    {
        global $wpdb;
        return (bool) $wpdb->update(
            self::db_table('regina_tournament_pgns'),
            ['round_no' => $round],
            ['id' => $pgnId, 'tournament_id' => $tournamentId],
            ['%d'],
            ['%d', '%d']
        );
    }

    private static function bulk_update_pgn_rounds(int $tournamentId, array $pgnRounds): int
    {
        $updated = 0;
        foreach ($pgnRounds as $pgnId => $round) {
            $pgnId = (int) $pgnId;
            $round = max(1, (int) $round);
            if ($pgnId > 0 && self::update_pgn_round($tournamentId, $pgnId, $round)) {
                $updated++;
            }
        }
        return $updated;
    }

    private static function delete_all_pgns(int $tournamentId): int
    {
        global $wpdb;
        $table = self::db_table('regina_tournament_pgns');
        $count = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $table WHERE tournament_id = %d", $tournamentId));
        $wpdb->delete($table, ['tournament_id' => $tournamentId], ['%d']);
        return $count;
    }

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

    /**
     * Fetch latest archived PGN content by pairing_id (for live boards fallback).
     *
     * @return array<int,string> pairing_id => pgn_content
     */
    private static function get_latest_archived_pgns_by_pairing_ids(int $tournamentId, array $pairingIds): array
    {
        $tournamentId = (int) $tournamentId;
        if ($tournamentId <= 0) {
            return [];
        }
        $ids = array_values(array_unique(array_map('intval', array_filter($pairingIds, static fn ($v) => (int) $v > 0))));
        if (empty($ids)) {
            return [];
        }
        global $wpdb;
        $table = self::db_table('regina_tournament_pgns');
        $placeholders = implode(',', array_fill(0, count($ids), '%d'));
        $sql = "SELECT pairing_id, pgn_content
            FROM $table
            WHERE tournament_id = %d AND pairing_id IN ($placeholders)
            ORDER BY id DESC";
        $params = array_merge([$tournamentId], $ids);
        $rows = $wpdb->get_results($wpdb->prepare($sql, $params)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $out = [];
        foreach ($rows as $r) {
            $pid = (int) ($r->pairing_id ?? 0);
            if ($pid > 0 && !isset($out[$pid]) && is_string($r->pgn_content ?? null) && trim((string) $r->pgn_content) !== '') {
                $out[$pid] = (string) $r->pgn_content;
            }
        }
        return $out;
    }

    public static function get_published_tournaments(): array
    {
        $tournaments = self::get_tournaments();
        $published = [];
        $logoUrl = self::get_default_logo_url();
        foreach ($tournaments as $t) {
            $dir = ChessPodium_ExportEngine::get_export_dir($t);
            if (!file_exists($dir . '/index.html')) {
                continue;
            }
            $url = ChessPodium_ExportEngine::get_export_url($t) . '/index.html';
            $photos = self::get_photos((int) $t->id);
            $brochureLogo = class_exists('ChessPodium_PdfBrochure') ? (ChessPodium_PdfBrochure::get_meta((int) $t->id, 'brochure_logo') ?: '') : '';
            $previewUrl = !empty($photos) ? $photos[0]->photo_url : ($brochureLogo !== '' ? $brochureLogo : $logoUrl);
            $published[] = [
                'id' => (int) $t->id,
                'name' => (string) $t->name,
                'url' => $url,
                'preview_url' => $previewUrl,
                'rounds' => (int) $t->current_round,
                'external' => false,
            ];
        }
        $external = self::get_external_tournaments();
        foreach ($external as $e) {
            $published[] = [
                'id' => $e['id'],
                'name' => $e['name'],
                'url' => $e['url'],
                'preview_url' => $e['preview_url'] ?: $logoUrl,
                'rounds' => (int) ($e['rounds'] ?? 0),
                'external' => true,
            ];
        }
        return $published;
    }

    private static function get_external_tournaments(): array
    {
        $data = get_option('chess_podium_external_tournaments', []);
        return is_array($data) ? $data : [];
    }

    private static function get_external_tournament_by_id(string $id): ?array
    {
        foreach (self::get_external_tournaments() as $e) {
            if (($e['id'] ?? '') === $id) {
                return $e;
            }
        }
        return null;
    }

    private static function get_external_preview_url_from_post(): ?string
    {
        if (!empty($_FILES['ext_preview_file']['tmp_name']) && is_uploaded_file($_FILES['ext_preview_file']['tmp_name'])) {
            if (!function_exists('wp_handle_upload')) {
                require_once ABSPATH . 'wp-admin/includes/file.php';
            }
            $upload = wp_handle_upload($_FILES['ext_preview_file'], ['test_form' => false]);
            if (!isset($upload['error']) && !empty($upload['url'])) {
                $attachId = wp_insert_attachment([
                    'post_mime_type' => $upload['type'],
                    'post_title' => sanitize_file_name(basename($upload['file'])),
                    'post_content' => '',
                    'post_status' => 'inherit',
                ], $upload['file']);
                if (!is_wp_error($attachId)) {
                    require_once ABSPATH . 'wp-admin/includes/image.php';
                    wp_generate_attachment_metadata($attachId, $upload['file']);
                }
                return $upload['url'];
            }
        }
        $url = isset($_POST['ext_preview']) ? esc_url_raw(trim(wp_unslash($_POST['ext_preview']))) : '';
        return $url !== '' ? $url : null;
    }

    private static function get_external_brochure_url_from_post(): string
    {
        if (!empty($_FILES['ext_brochure_file']['tmp_name']) && is_uploaded_file($_FILES['ext_brochure_file']['tmp_name'])) {
            $ext = strtolower(pathinfo(sanitize_file_name($_FILES['ext_brochure_file']['name'] ?? ''), PATHINFO_EXTENSION));
            if ($ext === 'pdf') {
                if (!function_exists('wp_handle_upload')) {
                    require_once ABSPATH . 'wp-admin/includes/file.php';
                }
                $upload = wp_handle_upload($_FILES['ext_brochure_file'], ['test_form' => false]);
                if (!isset($upload['error']) && !empty($upload['url'])) {
                    return $upload['url'];
                }
            }
        }
        $url = isset($_POST['ext_brochure_url']) ? esc_url_raw(trim(wp_unslash($_POST['ext_brochure_url']))) : '';
        return $url;
    }

    private static function add_external_tournament(string $name, string $url, ?string $previewUrl = null, int $rounds = 0, ?string $brochureUrl = null): void
    {
        $list = self::get_external_tournaments();
        $id = 'ext_' . (string) (time() . '_' . wp_rand(100, 999));
        $list[] = [
            'id' => $id,
            'name' => $name,
            'url' => $url,
            'preview_url' => $previewUrl ?? '',
            'rounds' => $rounds,
            'brochure_url' => $brochureUrl ?? '',
        ];
        update_option('chess_podium_external_tournaments', $list);
    }

    private static function update_external_tournament(string $id, string $name, string $url, ?string $previewUrl = null, int $rounds = 0, ?string $brochureUrl = null): void
    {
        $list = self::get_external_tournaments();
        foreach ($list as $i => $e) {
            if (($e['id'] ?? '') === $id) {
                $list[$i] = [
                    'id' => $id,
                    'name' => $name,
                    'url' => $url,
                    'preview_url' => $previewUrl ?? ($e['preview_url'] ?? ''),
                    'rounds' => $rounds,
                    'brochure_url' => $brochureUrl ?? ($e['brochure_url'] ?? ''),
                ];
                update_option('chess_podium_external_tournaments', $list);
                return;
            }
        }
    }

    private static function delete_external_tournament(string $id): void
    {
        $list = self::get_external_tournaments();
        $list = array_filter($list, static fn ($e) => ($e['id'] ?? '') !== $id);
        update_option('chess_podium_external_tournaments', array_values($list));
    }

    public static function get_default_logo_url(): string
    {
        return plugins_url('assets/logo-chess-podium.svg', __FILE__);
    }

    private static function maybe_create_tornei_page(): void
    {
        $optionKey = 'chess_podium_tornei_page_id';
        $pageId = (int) get_option($optionKey, 0);
        if ($pageId <= 0) {
            $legacyId = (int) get_option('checkmate_manager_tornei_page_id', 0);
            if ($legacyId > 0) {
                update_option($optionKey, $legacyId);
                delete_option('checkmate_manager_tornei_page_id');
                $pageId = $legacyId;
            }
        }
        if ($pageId > 0) {
            $page = get_post($pageId);
            if ($page && $page->post_status === 'publish') {
                return;
            }
        }
        $authorId = 1;
        $admin = get_users(['role' => 'administrator', 'number' => 1, 'orderby' => 'ID']);
        if (!empty($admin)) {
            $authorId = (int) $admin[0]->ID;
        }
        $pageId = wp_insert_post([
            'post_title' => __('Tournaments', 'chess-podium'),
            'post_name' => 'tornei',
            'post_content' => '<!-- wp:shortcode -->[chess_podium_tornei]<!-- /wp:shortcode -->',
            'post_status' => 'publish',
            'post_type' => 'page',
            'post_author' => $authorId,
            'comment_status' => 'closed',
        ]);
        if (is_wp_error($pageId)) {
            return;
        }
        update_option($optionKey, (int) $pageId);
        self::add_tornei_page_to_menu((int) $pageId);
    }

    private static function add_tornei_page_to_menu(int $pageId): void
    {
        $menus = wp_get_nav_menus();
        if (empty($menus)) {
            return;
        }
        $menu = reset($menus);
        $menuId = (int) $menu->term_id;
        $existing = wp_get_nav_menu_items($menuId);
        foreach ((array) $existing as $item) {
            if ((int) $item->object_id === $pageId && $item->object === 'page') {
                return;
            }
        }
        wp_update_nav_menu_item($menuId, 0, [
            'menu-item-type' => 'post_type',
            'menu-item-object' => 'page',
            'menu-item-object-id' => $pageId,
            'menu-item-status' => 'publish',
            'menu-item-position' => 5,
        ]);
    }

    private static function is_round_robin_family(?object $tournament): bool
    {
        $t = $tournament && isset($tournament->tournament_type) ? (string) $tournament->tournament_type : '';
        return $t === 'round_robin' || $t === 'double_round_robin';
    }

    private static function tournament_type_label(?object $tournament): string
    {
        $t = $tournament && isset($tournament->tournament_type) ? (string) $tournament->tournament_type : 'swiss';
        if ($t === 'round_robin') {
            return __('Round Robin', 'chess-podium');
        }
        if ($t === 'double_round_robin') {
            return __('Double Round Robin', 'chess-podium');
        }
        return __('Swiss', 'chess-podium');
    }

    /**
     * FIDE Berger tables (Handbook C05 Annex 1). Round 1 for 10 players: 1-10, 2-9, 3-8, 4-7, 5-6.
     * Each entry: [round => [[white_num, black_num], ...]]. For odd N, highest number = bye.
     */
    private static function get_berger_table(int $n): array
    {
        $tables = [
            4 => [1 => [[1, 4], [2, 3]], 2 => [[4, 3], [1, 2]], 3 => [[2, 4], [3, 1]]],
            6 => [1 => [[1, 6], [2, 5], [3, 4]], 2 => [[6, 4], [5, 3], [1, 2]], 3 => [[2, 6], [3, 1], [4, 5]], 4 => [[6, 5], [1, 4], [2, 3]], 5 => [[3, 6], [4, 2], [5, 1]]],
            8 => [1 => [[1, 8], [2, 7], [3, 6], [4, 5]], 2 => [[8, 5], [6, 4], [7, 3], [1, 2]], 3 => [[2, 8], [3, 1], [4, 7], [5, 6]], 4 => [[8, 6], [7, 5], [1, 4], [2, 3]], 5 => [[3, 8], [4, 2], [5, 1], [6, 7]], 6 => [[8, 7], [1, 6], [2, 5], [3, 4]], 7 => [[4, 8], [5, 3], [6, 2], [7, 1]]],
            10 => [1 => [[1, 10], [2, 9], [3, 8], [4, 7], [5, 6]], 2 => [[10, 6], [7, 5], [8, 4], [9, 3], [1, 2]], 3 => [[2, 10], [3, 1], [4, 9], [5, 8], [6, 7]], 4 => [[10, 7], [8, 6], [9, 5], [1, 4], [2, 3]], 5 => [[3, 10], [4, 2], [5, 1], [6, 9], [7, 8]], 6 => [[10, 8], [9, 7], [1, 6], [2, 5], [3, 4]], 7 => [[4, 10], [5, 3], [6, 2], [7, 1], [8, 9]], 8 => [[10, 9], [1, 8], [2, 7], [3, 6], [4, 5]], 9 => [[5, 10], [6, 4], [7, 3], [8, 2], [9, 1]]],
            12 => [1 => [[1, 12], [2, 11], [3, 10], [4, 9], [5, 8], [6, 7]], 2 => [[12, 7], [8, 6], [9, 5], [10, 4], [11, 3], [1, 2]], 3 => [[2, 12], [3, 1], [4, 11], [5, 10], [6, 9], [7, 8]], 4 => [[12, 8], [9, 7], [10, 6], [11, 5], [1, 4], [2, 3]], 5 => [[3, 12], [4, 2], [5, 1], [6, 11], [7, 10], [8, 9]], 6 => [[12, 9], [10, 8], [11, 7], [1, 6], [2, 5], [3, 4]], 7 => [[4, 12], [5, 3], [6, 2], [7, 1], [8, 11], [9, 10]], 8 => [[12, 10], [11, 9], [1, 8], [2, 7], [3, 6], [4, 5]], 9 => [[5, 12], [6, 4], [7, 3], [8, 2], [9, 1], [10, 11]], 10 => [[12, 11], [1, 10], [2, 9], [3, 8], [4, 7], [5, 6]], 11 => [[6, 12], [7, 5], [8, 4], [9, 3], [10, 2], [11, 1]]],
            14 => [1 => [[1, 14], [2, 13], [3, 12], [4, 11], [5, 10], [6, 9], [7, 8]], 2 => [[14, 8], [9, 7], [10, 6], [11, 5], [12, 4], [13, 3], [1, 2]], 3 => [[2, 14], [3, 1], [4, 13], [5, 12], [6, 11], [7, 10], [8, 9]], 4 => [[14, 9], [10, 8], [11, 7], [12, 6], [13, 5], [1, 4], [2, 3]], 5 => [[3, 14], [4, 2], [5, 1], [6, 13], [7, 12], [8, 11], [9, 10]], 6 => [[14, 10], [11, 9], [12, 8], [13, 7], [1, 6], [2, 5], [3, 4]], 7 => [[4, 14], [5, 3], [6, 2], [7, 1], [8, 13], [9, 12], [10, 11]], 8 => [[14, 11], [12, 10], [13, 9], [1, 8], [2, 7], [3, 6], [4, 5]], 9 => [[5, 14], [6, 4], [7, 3], [8, 2], [9, 1], [10, 13], [11, 12]], 10 => [[14, 12], [13, 11], [1, 10], [2, 9], [3, 8], [4, 7], [5, 6]], 11 => [[6, 14], [7, 5], [8, 4], [9, 3], [10, 2], [11, 1], [12, 13]], 12 => [[14, 13], [1, 12], [2, 11], [3, 10], [4, 9], [5, 8], [6, 7]], 13 => [[7, 14], [8, 6], [9, 5], [10, 4], [11, 3], [12, 2], [13, 1]]],
            16 => [1 => [[1, 16], [2, 15], [3, 14], [4, 13], [5, 12], [6, 11], [7, 10], [8, 9]], 2 => [[16, 9], [10, 8], [11, 7], [12, 6], [13, 5], [14, 4], [15, 3], [1, 2]], 3 => [[2, 16], [3, 1], [4, 15], [5, 14], [6, 13], [7, 12], [8, 11], [9, 10]], 4 => [[16, 10], [11, 9], [12, 8], [13, 7], [14, 6], [15, 5], [1, 4], [2, 3]], 5 => [[3, 16], [4, 2], [5, 1], [6, 15], [7, 14], [8, 13], [9, 12], [10, 11]], 6 => [[16, 11], [12, 10], [13, 9], [14, 8], [15, 7], [1, 6], [2, 5], [3, 4]], 7 => [[4, 16], [5, 3], [6, 2], [7, 1], [8, 15], [9, 14], [10, 13], [11, 12]], 8 => [[16, 12], [13, 11], [14, 10], [15, 9], [1, 8], [2, 7], [3, 6], [4, 5]], 9 => [[5, 16], [6, 4], [7, 3], [8, 2], [9, 1], [10, 15], [11, 14], [12, 13]], 10 => [[16, 13], [14, 12], [15, 11], [1, 10], [2, 9], [3, 8], [4, 7], [5, 6]], 11 => [[6, 16], [7, 5], [8, 4], [9, 3], [10, 2], [11, 1], [12, 15], [13, 14]], 12 => [[16, 14], [15, 13], [1, 12], [2, 11], [3, 10], [4, 9], [5, 8], [6, 7]], 13 => [[7, 16], [8, 6], [9, 5], [10, 4], [11, 3], [12, 2], [13, 1], [14, 15]], 14 => [[16, 15], [1, 14], [2, 13], [3, 12], [4, 11], [5, 10], [6, 9], [7, 8]], 15 => [[8, 16], [9, 7], [10, 6], [11, 5], [12, 4], [13, 3], [14, 2], [15, 1]]],
        ];
        $effectiveN = $n;
        if ($n % 2 !== 0) {
            $effectiveN = $n + 1;
        }
        foreach ([4, 6, 8, 10, 12, 14, 16] as $key) {
            if ($effectiveN <= $key) {
                return $tables[$key] ?? [];
            }
        }
        return [];
    }

    /**
     * Round Robin pairing via FIDE Berger tables (Handbook C05 Annex 1).
     * Uses pairing number (start_number / Numero di Sorteggio) when all players have it set (1..N).
     * Matches Swiss Manager and chess-results. Round 1 for 10 players: 1-10, 2-9, 3-8, 4-7, 5-6.
     * Double round robin: second cycle repeats the same pairings with colours reversed (FIDE Candidates style).
     *
     * @param array $players Player objects (excluding withdrawn).
     * @param int $roundNo Round number (1-based).
     * @param bool $isDoubleRoundRobin If true, rounds (singleRounds+1)..(2*singleRounds) mirror 1..singleRounds with swapped colours.
     * @return array Array of pairs.
     */
    private static function pair_round_robin(array $players, int $roundNo, bool $isDoubleRoundRobin = false): array
    {
        $count = count($players);
        if ($count < 2) {
            return [];
        }
        $singleRounds = ($count % 2 === 0) ? ($count - 1) : $count;
        $effectiveRound = $roundNo;
        $swapColors = false;
        if ($isDoubleRoundRobin) {
            if ($roundNo < 1 || $roundNo > 2 * $singleRounds) {
                return [];
            }
            if ($roundNo > $singleRounds) {
                $effectiveRound = $roundNo - $singleRounds;
                $swapColors = true;
            }
        } elseif ($roundNo > $singleRounds) {
            return [];
        }
        $hasAllPairingNumbers = true;
        $pairingNums = [];
        foreach ($players as $p) {
            $sn = isset($p->start_number) && (int) $p->start_number > 0 ? (int) $p->start_number : null;
            if ($sn === null || $sn < 1 || $sn > $count) {
                $hasAllPairingNumbers = false;
                break;
            }
            $pairingNums[(int) $p->id] = $sn;
        }
        if ($hasAllPairingNumbers && count(array_unique(array_values($pairingNums))) === $count) {
            usort($players, static function ($a, $b) use ($pairingNums) {
                return ($pairingNums[(int) $a->id] ?? 999) <=> ($pairingNums[(int) $b->id] ?? 999);
            });
        } else {
            usort($players, static function ($a, $b) {
                $r = ((int) $b->rating <=> (int) $a->rating);
                if ($r !== 0) {
                    return $r;
                }
                return strcasecmp((string) $b->name, (string) $a->name);
            });
        }
        $numToId = [];
        foreach ($players as $i => $p) {
            $numToId[$i + 1] = (int) $p->id;
        }
        $effectiveN = $count % 2 !== 0 ? $count + 1 : $count;
        $bergerTable = self::get_berger_table($effectiveN);
        $pairs = null;
        if (!empty($bergerTable)) {
            $roundPairs = $bergerTable[$effectiveRound] ?? null;
            if ($roundPairs !== null) {
                $pairs = [];
                foreach ($roundPairs as $pair) {
                    $wNum = $pair[0];
                    $bNum = $pair[1];
                    if ($bNum > $count) {
                        $pairs[] = ['white' => $numToId[$wNum], 'black' => 0, 'is_bye' => 1];
                    } else {
                        $pairs[] = ['white' => $numToId[$wNum], 'black' => $numToId[$bNum], 'is_bye' => 0];
                    }
                }
            }
        }
        if ($pairs === null) {
            $ids = array_map(static fn ($p) => (int) $p->id, $players);
            $isOdd = $count % 2 !== 0;
            if ($isOdd) {
                $ids[] = 0;
            }
            $n = count($ids);
            $fixed = $ids[0];
            $rotating = $isOdd ? array_slice($ids, 1, -1) : array_slice($ids, 1);
            $roundIndex = ($effectiveRound - 1) % max(1, count($rotating));
            $rotated = array_merge(
                array_slice($rotating, $roundIndex),
                array_slice($rotating, 0, $roundIndex)
            );
            $order = $isOdd ? array_merge([$fixed], $rotated, [0]) : array_merge([$fixed], $rotated);
            $half = (int) ($n / 2);
            $pairs = [];
            for ($i = 0; $i < $half; $i++) {
                $white = $order[$i];
                $black = $order[$half + $i];
                if ($black === 0) {
                    $pairs[] = ['white' => $white, 'black' => 0, 'is_bye' => 1];
                } else {
                    $pairs[] = ['white' => $white, 'black' => $black, 'is_bye' => 0];
                }
            }
        }
        if ($swapColors) {
            foreach ($pairs as &$p) {
                if (empty($p['is_bye']) && (int) ($p['black'] ?? 0) > 0) {
                    $w = (int) $p['white'];
                    $p['white'] = (int) $p['black'];
                    $p['black'] = $w;
                }
            }
            unset($p);
        }
        return $pairs;
    }

    private static function pair_round_one(array $players): array
    {
        usort($players, static function ($a, $b) {
            $r = ((int) $b->rating <=> (int) $a->rating);
            if ($r !== 0) {
                return $r;
            }
            return strcasecmp((string) $b->name, (string) $a->name);
        });

        $pairs = [];
        $count = count($players);
        $half = intdiv($count, 2);

        for ($i = 0; $i < $half; $i++) {
            $top = $players[$i];
            $bottom = $players[$i + $half];
            // FIDE standard: alternate colors by board - odd boards (2,4,6...) bottom gets White
            if ($i % 2 === 0) {
                $white = $top;
                $black = $bottom;
            } else {
                $white = $bottom;
                $black = $top;
            }
            $pairs[] = [
                'white' => (int) $white->id,
                'black' => (int) $black->id,
                'is_bye' => 0,
            ];
        }

        if ($count % 2 !== 0) {
            $byePlayer = $players[$count - 1];
            $pairs[] = [
                'white' => (int) $byePlayer->id,
                'black' => 0,
                'is_bye' => 1,
            ];
        }

        return $pairs;
    }

    /**
     * Round 1 pairing in Swiss Manager / chess-results style: top half (Nr 1..n/2) pairs within itself,
     * bottom half (Nr n/2+1..n) pairs within itself. Produces 1 vs 20, 2 vs 21, ..., 40 vs 59, etc.
     * JaVaFo uses standard Dutch (1 vs 39) which differs. Use this when all players have start_number.
     *
     * @param array $players Player objects (excluding withdrawn).
     * @param array<int, bool> $withdrawn Withdrawn player IDs.
     * @param array<int, bool> $byeRequested Bye-requested player IDs.
     * @param array<int, bool> $absent Absent player IDs.
     * @param int $tournamentId Tournament ID.
     * @return array Array of pairs or empty if not applicable.
     */
    private static function pair_round1_swiss_manager_style(array $players, array $withdrawn, array $byeRequested, array $absent, int $tournamentId): array
    {
        $allPlayers = self::get_players($tournamentId);
        $totalN = count($allPlayers);
        if ($totalN < 4) {
            return [];
        }

        $nrByPid = [];
        foreach ($allPlayers as $p) {
            $nr = isset($p->start_number) && (int) $p->start_number > 0 ? (int) $p->start_number : null;
            if ($nr === null || $nr > 9999) {
                return [];
            }
            $nrByPid[(int) $p->id] = $nr;
        }
        $pidByNr = [];
        foreach ($nrByPid as $pid => $nr) {
            $pidByNr[$nr] = $pid;
        }
        if (count($pidByNr) !== $totalN) {
            return [];
        }

        $half = (int) ($totalN / 2);
        $topNrs = range(1, $half);
        $bottomNrs = range($half + 1, $totalN);

        $excludedFromTop = [];
        foreach (array_merge(array_keys($byeRequested), array_keys($absent)) as $pid) {
            if (isset($nrByPid[$pid])) {
                $excludedFromTop[$nrByPid[$pid]] = true;
            }
        }
        $excludedFromBottom = [];
        foreach (array_merge(array_keys($withdrawn), array_keys($absent)) as $pid) {
            if (isset($nrByPid[$pid])) {
                $excludedFromBottom[$nrByPid[$pid]] = true;
            }
        }

        $topActive = array_values(array_filter($topNrs, static function ($nr) use ($excludedFromTop) {
            return !isset($excludedFromTop[$nr]);
        }));
        $bottomActive = array_values(array_filter($bottomNrs, static function ($nr) use ($excludedFromBottom) {
            return !isset($excludedFromBottom[$nr]);
        }));

        $topCount = count($topActive);
        $bottomCount = count($bottomActive);
        $topHalf = (int) ($topCount / 2);
        $bottomHalf = (int) ($bottomCount / 2);

        $pairs = [];
        for ($i = 0; $i < $topHalf; $i++) {
            $topNr = $topActive[$i];
            $botNr = $topActive[$topHalf + $i];
            $topPid = $pidByNr[$topNr] ?? 0;
            $botPid = $pidByNr[$botNr] ?? 0;
            if ($topPid > 0 && $botPid > 0) {
                if ($i % 2 === 0) {
                    $pairs[] = ['white' => $topPid, 'black' => $botPid, 'is_bye' => 0];
                } else {
                    $pairs[] = ['white' => $botPid, 'black' => $topPid, 'is_bye' => 0];
                }
            }
        }
        if ($topCount % 2 !== 0) {
            $byePid = $pidByNr[$topActive[$topCount - 1]] ?? 0;
            if ($byePid > 0) {
                $pairs[] = ['white' => $byePid, 'black' => 0, 'is_bye' => 1];
            }
        }

        for ($i = 0; $i < $bottomHalf; $i++) {
            $topNr = $bottomActive[$i];
            $botNr = $bottomActive[$bottomHalf + $i];
            $topPid = $pidByNr[$topNr] ?? 0;
            $botPid = $pidByNr[$botNr] ?? 0;
            if ($topPid > 0 && $botPid > 0) {
                if ($i % 2 === 0) {
                    $pairs[] = ['white' => $topPid, 'black' => $botPid, 'is_bye' => 0];
                } else {
                    $pairs[] = ['white' => $botPid, 'black' => $topPid, 'is_bye' => 0];
                }
            }
        }
        if ($bottomCount % 2 !== 0) {
            $byePid = $pidByNr[$bottomActive[$bottomCount - 1]] ?? 0;
            if ($byePid > 0) {
                $pairs[] = ['white' => $byePid, 'black' => 0, 'is_bye' => 1];
            }
        }

        if (!self::validate_pairs($pairs, $tournamentId, $withdrawn, $byeRequested, $absent, 1)) {
            return [];
        }
        return $pairs;
    }

    /**
     * Round 1 accelerated pairing (FIDE C.04.7): build GA/GB by TPN and pair each group internally
     * (Dutch within scoregroups induced by virtual points).
     *
     * Expected visible effect (vs standard Dutch): 1 is paired with ~N/4+1 instead of ~N/2+1.
     *
     * GA size rule (Baku method, C.04.7 §1.2): first half rounded up to the nearest even number,
     * equivalently: 2 * ceil(N/4). Uses participants to be paired (already excluding withdrawn/bye/absent).
     *
     * @param array $players Player objects to be paired in round 1.
     * @param array<int, bool> $withdrawn Withdrawn player IDs.
     * @param array<int, bool> $byeRequested Bye-requested player IDs.
     * @param array<int, bool> $absent Absent player IDs.
     * @param int $tournamentId Tournament ID.
     * @return array Array of pairs or empty if not applicable.
     */
    private static function pair_round1_fide_accelerated(array $players, array $withdrawn, array $byeRequested, array $absent, int $tournamentId): array
    {
        $n = count($players);
        if ($n < 4) {
            return [];
        }

        // Sort by TPN/Nr (start_number when available), fallback to start_rank, then player id.
        $startRankByPid = [];
        foreach (self::get_starting_ranks($tournamentId) as $pid => $rk) {
            $startRankByPid[(int) $pid] = (int) $rk;
        }

        $ordered = $players;
        usort($ordered, static function ($a, $b) use ($startRankByPid) {
            $aNr = isset($a->start_number) && (int) $a->start_number > 0 ? (int) $a->start_number : null;
            $bNr = isset($b->start_number) && (int) $b->start_number > 0 ? (int) $b->start_number : null;
            if ($aNr !== null && $bNr !== null && $aNr !== $bNr) {
                return $aNr <=> $bNr;
            }
            if ($aNr !== null && $bNr === null) {
                return -1;
            }
            if ($aNr === null && $bNr !== null) {
                return 1;
            }
            $ar = $startRankByPid[(int) $a->id] ?? 999999;
            $br = $startRankByPid[(int) $b->id] ?? 999999;
            if ($ar !== $br) {
                return $ar <=> $br;
            }
            return ((int) $a->id) <=> ((int) $b->id);
        });

        // GA size: 2 * ceil(n/4), and must not exceed n.
        $q = (int) ceil($n / 4);
        $gaSize = min($n, 2 * $q);
        if ($gaSize % 2 !== 0) {
            $gaSize = min($n, $gaSize + 1);
        }
        if ($gaSize < 2) {
            return [];
        }

        $ga = array_slice($ordered, 0, $gaSize);
        $gb = array_slice($ordered, $gaSize);

        $pairs = [];

        $pair_group = static function (array $group) use (&$pairs) {
            $cnt = count($group);
            $half = (int) floor($cnt / 2);
            for ($i = 0; $i < $half; $i++) {
                $a = (int) $group[$i]->id;
                $b = (int) $group[$half + $i]->id;
                if ($i % 2 === 0) {
                    $pairs[] = ['white' => $a, 'black' => $b, 'is_bye' => 0];
                } else {
                    $pairs[] = ['white' => $b, 'black' => $a, 'is_bye' => 0];
                }
            }
            if ($cnt % 2 !== 0) {
                $byePid = (int) $group[$cnt - 1]->id;
                $pairs[] = ['white' => $byePid, 'black' => 0, 'is_bye' => 1];
            }
        };

        $pair_group($ga);
        $pair_group($gb);

        if (!self::validate_pairs($pairs, $tournamentId, $withdrawn, $byeRequested, $absent, 1)) {
            return [];
        }
        return $pairs;
    }

    /**
     * @param array<int> $withdrawn Player IDs withdrawn (excluded from pairing).
     * @param array<int> $byeRequested Player IDs with bye requested (excluded from pairing).
     * @param array<int, bool> $absent Player IDs absent (excluded from pairing, opponent gets 1F).
     */
    private static function pair_swiss(array $players, array $standings, int $tournamentId, int $nextRound = 0, array $withdrawn = [], array $byeRequested = [], array $absent = []): array
    {
        if ($nextRound > 0) {
            if ($nextRound === 1) {
                $tournament = self::get_tournament($tournamentId);

                // FIDE Accelerated (C.04.7): virtual points separate GA/GB scoregroups in round 1,
                // causing within-group Dutch pairing (1 vs ~N/4+1 instead of ~N/2+1).
                $pairingAccel = (string) ($tournament->pairing_acceleration ?? 'none');
                if ($pairingAccel === 'fide_accel') {
                    $accelPairs = self::pair_round1_fide_accelerated($players, $withdrawn, $byeRequested, $absent, $tournamentId);
                    if (!empty($accelPairs)) {
                        self::$lastPairingSource = 'fide_accel_round1';
                        return $accelPairs;
                    }
                }

                $round1Mode = ($tournament->round1_pairing_mode ?? 'dutch');
                $useSplit = ($round1Mode === 'split') && apply_filters('chess_podium_round1_swiss_manager_style', true);
                if ($useSplit) {
                    $splitPairs = self::pair_round1_swiss_manager_style($players, $withdrawn, $byeRequested, $absent, $tournamentId);
                    if (!empty($splitPairs)) {
                        self::$lastPairingSource = 'swiss_manager_round1';
                        return $splitPairs;
                    }
                }
            }
            $bbpPairs = self::pair_with_bbp($tournamentId, $nextRound, $withdrawn, $byeRequested, $absent);
            if ($bbpPairs !== null) {
                return $bbpPairs;
            }
            self::$lastPairingSource = 'builtin';
        }

        // FIDE Dutch: pairing order = Score then TPN (start_rank) only - no Buchholz/SB.
        $startRankMap = [];
        $ordered = [];
        $pointsMap = [];
        $playerIdsToPair = [];
        foreach ($players as $p) {
            $playerIdsToPair[(int) $p->id] = true;
        }
        foreach ($standings as $s) {
            $pid = (int) $s['player_id'];
            $startRankMap[$pid] = (int) ($s['start_rank'] ?? 999);
            $pointsMap[$pid] = (float) $s['points'];
            if (isset($playerIdsToPair[$pid])) {
                $ordered[] = $pid;
            }
        }
        // Sort by points DESC, then TPN (start_rank) for pairing. FIDE Dutch uses Nr/sorteggio.
        usort($ordered, static function ($a, $b) use ($pointsMap, $startRankMap) {
            $pa = $pointsMap[$a] ?? 0.0;
            $pb = $pointsMap[$b] ?? 0.0;
            if (abs($pb - $pa) >= 0.001) {
                return $pb <=> $pa;
            }
            // TPN: start_rank ascending (matches Swiss Manager Nr / JaVaFo pairing-id)
            return ($startRankMap[$a] ?? 999) <=> ($startRankMap[$b] ?? 999);
        });

        // Ensure every player is present in ordering.
        foreach ($players as $p) {
            $pid = (int) $p->id;
            if (!in_array($pid, $ordered, true)) {
                $ordered[] = $pid;
                $pointsMap[$pid] = 0.0;
                $startRankMap[$pid] = $startRankMap[$pid] ?? 999;
            }
        }

        $allPairings = self::get_pairings($tournamentId);
        $playedMap = array();
        $hadBye = array();
        $colorBalances = array();

        $lastColors = [];
        foreach ($allPairings as $match) {
            $w = (int) $match->white_player_id;
            $b = (int) $match->black_player_id;
            $isBye = (int) $match->is_bye === 1;

            if (!isset($colorBalances[$w])) {
                $colorBalances[$w] = 0;
            }

            if ($isBye) {
                $hadBye[$w] = true;
                continue;
            }
            $res = (string) ($match->result ?? '');
            if (in_array($res, ['1F', '0F'], true)) {
                $hadBye[(int) ($res === '1F' ? $w : $b)] = true;
            }

            if (!isset($colorBalances[$b])) {
                $colorBalances[$b] = 0;
            }
            $colorBalances[$w]++;
            $colorBalances[$b]--;
            $playedMap[self::pair_key($w, $b)] = true;
            $lastColors[$w] = array_slice(array_merge($lastColors[$w] ?? [], ['w']), -2);
            $lastColors[$b] = array_slice(array_merge($lastColors[$b] ?? [], ['b']), -2);
        }

        $pairs = [];
        $remaining = $ordered;

        // Odd players: FIDE A.9 + C.2 - bye assignment. Try candidates from highest to lowest rank;
        // Swiss Manager/collapsed path may assign bye to higher score group when lower fails.
        $byeCandidate = null;
        if (count($remaining) % 2 !== 0) {
            $eligible = array_values(array_filter($remaining, static function ($pid) use ($hadBye) {
                return !isset($hadBye[(int) $pid]);
            }));
            if (empty($eligible)) {
                return [];
            }
            foreach ($eligible as $candidate) {
                $candidate = (int) $candidate;
                $without = array_values(array_filter($remaining, static fn ($id) => (int) $id !== $candidate));
                $dutchData = self::build_dutch_rank_data($without, $pointsMap);
                $searchedPairs = self::pair_strict_dutch($without, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors, $startRankMap);
                if (is_array($searchedPairs) && count($searchedPairs) * 2 === count($without)) {
                    $byeCandidate = $candidate;
                    $pairs[] = array('white' => $byeCandidate, 'black' => 0, 'is_bye' => 1);
                    return array_merge($pairs, $searchedPairs);
                }
                $searchedPairs = self::pair_swiss_mwm($without, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors);
                if (is_array($searchedPairs) && count($searchedPairs) * 2 === count($without)) {
                    $byeCandidate = $candidate;
                    $pairs[] = array('white' => $byeCandidate, 'black' => 0, 'is_bye' => 1);
                    return array_merge($pairs, $searchedPairs);
                }
                $searchedPairs = self::pair_swiss_greedy_fallback($without, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors);
                if (is_array($searchedPairs) && count($searchedPairs) * 2 === count($without)) {
                    $byeCandidate = $candidate;
                    $pairs[] = array('white' => $byeCandidate, 'black' => 0, 'is_bye' => 1);
                    return array_merge($pairs, $searchedPairs);
                }
            }
            return [];
        }

        // FIDE Dutch System: within each score group, pair top half vs bottom half
        // (rank 1 vs rank 1+size/2, rank 2 vs rank 2+size/2, etc.)
        $dutchData = self::build_dutch_rank_data($remaining, $pointsMap);

        // Strict Dutch first (matches JaVaFo/Vega): sequential pairing within each score group.
        $searchedPairs = self::pair_strict_dutch($remaining, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors, $startRankMap);
        if (count($searchedPairs) * 2 !== count($remaining)) {
            // Fallback: MWM engine per arxiv.org/html/2112.10522v2
            $searchedPairs = self::pair_swiss_mwm($remaining, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors);
        }
        if (count($searchedPairs) * 2 !== count($remaining)) {
            $searchedPairs = self::pair_swiss_greedy_fallback($remaining, $pointsMap, $playedMap, $colorBalances, $dutchData, $lastColors);
        }

        return array_merge($pairs, $searchedPairs);
    }

    /**
     * Strict FIDE Dutch pairing: within each score group, pair rank k with rank k+size/2.
     * Matches JaVaFo/Vega deterministic behavior. Returns [] if any constraint is violated.
     *
     * @param array<int> $remaining Player IDs in standings order.
     * @param array<int,float> $pointsMap Player ID => points.
     * @param array<string,bool> $playedMap pair_key => true if already played.
     * @param array<int,int> $colorBalances Player ID => color diff (white-black).
     * @param array{rankInGroup: array<int,int>, groupSize: array<int,int>} $dutchData
     * @param array<int, array<string>> $lastColors Player ID => last 2 colors.
     * @param array<int,int> $startRankMap Player ID => start rank (rating order).
     * @return array<int, array{white: int, black: int, is_bye: int}>
     */
    /**
     * FIDE Dutch System pairing with full backtracking (JaVaFo/Vega compatible).
     *
     * Algorithm (FIDE Dutch System rules B1-B6 + C1-C5):
     * 1. Split players into score groups ordered by TPN (start rank = rating order).
     * 2. For each group, split into top half S1 and bottom half S2.
     * 3. Try S1[k] vs S2[k] for all k (natural Dutch pairing).
     * 4. On conflict (rematch or absolute color violation):
     *    a. Transpose S2 (shift bottom-half candidate right, cyclically).
     *    b. If all S2 transpositions exhausted, try S1-S2 single exchanges.
     *    c. If all fail, float the last player of this group down to the next group.
     * 5. Floater prepended to next score group (retains priority, pairs first).
     * 6. Color: lower signed CD gets White; CD tie broken by TPN (lower = White).
     *
     * Returns [] if no valid pairing found (caller falls back to MWM engine).
     */
    private static function pair_strict_dutch(
        array $remaining,
        array $pointsMap,
        array $playedMap,
        array $colorBalances,
        array $dutchData,
        array $lastColors,
        array $startRankMap = [],
        array $hadBye = [],
        ?int &$byeCandidate = null
    ): array {
        $n = count($remaining);
        if ($n < 2) {
            return [];
        }

        $expectBye = ($n % 2 !== 0);

        // Build score groups in TPN order (already sorted by pair_swiss).
        $groups = [];
        $i = 0;
        while ($i < $n) {
            $score = (float) ($pointsMap[$remaining[$i]] ?? 0.0);
            $group = [];
            while ($i < $n && abs((float) ($pointsMap[$remaining[$i]] ?? 0) - $score) < 0.001) {
                $group[] = (int) $remaining[$i];
                $i++;
            }
            $groups[] = $group;
        }

        $allPairs = [];
        $floater = null;
        $numGroups = count($groups);

        foreach ($groups as $gIdx => $group) {
            // Prepend floater from previous odd group (FIDE B.2: MDPs have higher score, go to S1).
            if ($floater !== null) {
                array_unshift($group, $floater);
                $floater = null;
            }

            $isLastGroup = ($gIdx === $numGroups - 1);
            $groupPairs = self::dutch_pair_group(
                $group,
                $playedMap,
                $colorBalances,
                $lastColors,
                $startRankMap,
                $floater,
                $isLastGroup ? $hadBye : []
            );

            if ($groupPairs === null) {
                return [];
            }

            foreach ($groupPairs as $p) {
                $allPairs[] = $p;
            }
        }

        // Leftover floater after last group: FIDE A.9 - that player receives the pairing-allocated bye.
        if ($floater !== null && $expectBye) {
            $byeCandidate = $floater;
            return $allPairs;
        }

        if ($floater !== null) {
            return [];
        }

        return $allPairs;
    }

    /**
     * Pair a single score group with FIDE Dutch backtracking.
     * $floater (passed by ref) is set to the player who must float to the next group.
     * When $hadBye is non-empty (last bracket), only try floater candidates who haven't had bye (FIDE C.2).
     *
     * @param array<int> $group Players in TPN order.
     * @param array<int,bool> $hadBye Player ID => true if already had bye or forfeit win (C.2).
     * @return array<int,array{white:int,black:int,is_bye:int}>|null  null = complete failure.
     */
    private static function dutch_pair_group(
        array $group,
        array $playedMap,
        array $colorBalances,
        array $lastColors,
        array $startRankMap,
        ?int &$floater,
        array $hadBye = []
    ): ?array {
        $floater = null;
        $size = count($group);

        if ($size === 0) {
            return [];
        }
        if ($size === 1) {
            // Single player — must float down (will receive bye if last bracket).
            $pid = $group[0];
            if (!empty($hadBye) && isset($hadBye[$pid])) {
                return null;
            }
            $floater = $pid;
            return [];
        }

        // For odd groups, try floating each candidate (last player first per FIDE).
        $candidates = [];
        if ($size % 2 !== 0) {
            for ($fi = $size - 1; $fi >= 0; $fi--) {
                $fc = $group[$fi];
                if (!empty($hadBye) && isset($hadBye[$fc])) {
                    continue;
                }
                $reduced = array_values(array_filter($group, static fn ($x) => $x !== $fc));
                $candidates[] = [$fc, $reduced];
            }
            if (empty($candidates)) {
                return null;
            }
        } else {
            $candidates[] = [null, $group];
        }

        foreach ($candidates as [$fc, $even]) {
            $half = count($even) / 2;
            $s1 = array_slice($even, 0, $half);
            $s2 = array_slice($even, $half);

            $s2perms = self::dutch_s2_permutations($s1, $s2, $startRankMap);

            foreach ($s2perms as $s2perm) {
                $valid = true;
                $pairs = [];
                for ($k = 0; $k < $half; $k++) {
                    $a = (int) $s1[$k];
                    $b = (int) $s2perm[$k];
                    if (!self::dutch_pair_is_allowed($a, $b, $playedMap, $colorBalances, $lastColors)) {
                        $valid = false;
                        break;
                    }
                    $pairs[] = self::dutch_assign_color($a, $b, $colorBalances, $lastColors, $startRankMap);
                }
                if ($valid) {
                    $floater = $fc;
                    return $pairs;
                }
            }
        }

        // Complete failure for this group.
        return null;
    }

    /**
     * Generate S2 permutations in Javafo/Vega priority order:
     * 1. Natural order (no shift).
     * 2. Cyclic right-shifts of S2 (transpositions).
     * 3. Single S1-S2 exchanges (one S1 player swaps with one S2 player).
     */
    private static function dutch_s2_permutations(array $s1, array $s2, array $startRankMap): array
    {
        $perms = [];
        $half = count($s2);

        if ($half === 0) {
            return [[]];
        }

        // 1. Natural order.
        $perms[] = $s2;

        // 2. Cyclic shifts of S2.
        for ($shift = 1; $shift < $half; $shift++) {
            $perms[] = array_merge(array_slice($s2, $shift), array_slice($s2, 0, $shift));
        }

        // 3. Single S1-S2 exchanges: swap s1[i] <-> s2[j].
        for ($i = count($s1) - 1; $i >= 0; $i--) {
            for ($j = 0; $j < $half; $j++) {
                $newS2 = $s2;
                $newS2[$j] = $s1[$i];
                // Re-sort to maintain TPN order within S2 as Javafo does.
                usort($newS2, static fn ($a, $b) => ($startRankMap[$a] ?? $a) <=> ($startRankMap[$b] ?? $b));
                if (!in_array($newS2, $perms, true)) {
                    $perms[] = $newS2;
                }
            }
        }

        return $perms;
    }

    /**
     * Check if pairing (a, b) is allowed under FIDE Dutch rules B2, C1, C5.
     * B2: no rematch.
     * C1: no 3 consecutive same color.
     * C5: no pair if |cdA + cdB| >= 4 (both have extreme same preference).
     */
    private static function dutch_pair_is_allowed(
        int $a,
        int $b,
        array $playedMap,
        array $colorBalances,
        array $lastColors
    ): bool {
        // B2: rematch check.
        if (isset($playedMap[self::pair_key($a, $b)])) {
            return false;
        }

        $lastA = $lastColors[$a] ?? [];
        $lastB = $lastColors[$b] ?? [];
        $lenA = count($lastA);
        $lenB = count($lastB);

        // C1: absolute color prohibition — 2 consecutive same color = must play opposite.
        $mustABlack = $lenA >= 2 && $lastA[$lenA - 2] === 'w' && $lastA[$lenA - 1] === 'w';
        $mustAWhite = $lenA >= 2 && $lastA[$lenA - 2] === 'b' && $lastA[$lenA - 1] === 'b';
        $mustBBlack = $lenB >= 2 && $lastB[$lenB - 2] === 'w' && $lastB[$lenB - 1] === 'w';
        $mustBWhite = $lenB >= 2 && $lastB[$lenB - 2] === 'b' && $lastB[$lenB - 1] === 'b';

        // Both must play the same color — impossible.
        if ($mustAWhite && $mustBWhite) {
            return false;
        }
        if ($mustABlack && $mustBBlack) {
            return false;
        }

        // C5: extreme CD clash.
        $cdA = (int) ($colorBalances[$a] ?? 0);
        $cdB = (int) ($colorBalances[$b] ?? 0);
        if (abs($cdA + $cdB) >= 4) {
            return false;
        }

        return true;
    }

    /**
     * Assign colors for a valid pair (a, b) per FIDE Dutch System C4/C5.
     * Lower signed CD gets White; CD tie broken by TPN (lower start_rank = White).
     */
    private static function dutch_assign_color(
        int $a,
        int $b,
        array $colorBalances,
        array $lastColors,
        array $startRankMap
    ): array {
        $lastA = $lastColors[$a] ?? [];
        $lastB = $lastColors[$b] ?? [];
        $lenA = count($lastA);
        $lenB = count($lastB);

        // C1 absolute constraints take highest priority.
        $mustAWhite = $lenA >= 2 && $lastA[$lenA - 2] === 'b' && $lastA[$lenA - 1] === 'b';
        $mustBWhite = $lenB >= 2 && $lastB[$lenB - 2] === 'b' && $lastB[$lenB - 1] === 'b';
        $mustABlack = $lenA >= 2 && $lastA[$lenA - 2] === 'w' && $lastA[$lenA - 1] === 'w';
        $mustBBlack = $lenB >= 2 && $lastB[$lenB - 2] === 'w' && $lastB[$lenB - 1] === 'w';

        if ($mustAWhite || $mustBBlack) {
            return ['white' => $a, 'black' => $b, 'is_bye' => 0];
        }
        if ($mustBWhite || $mustABlack) {
            return ['white' => $b, 'black' => $a, 'is_bye' => 0];
        }

        // C4: lower signed CD gets White.
        $cdA = (int) ($colorBalances[$a] ?? 0);
        $cdB = (int) ($colorBalances[$b] ?? 0);
        if ($cdA < $cdB) {
            return ['white' => $a, 'black' => $b, 'is_bye' => 0];
        }
        if ($cdB < $cdA) {
            return ['white' => $b, 'black' => $a, 'is_bye' => 0];
        }

        // CD equal: lower TPN (start_rank) gets White (FIDE tiebreaker).
        $srA = $startRankMap[$a] ?? $a;
        $srB = $startRankMap[$b] ?? $b;
        return $srA <= $srB
            ? ['white' => $a, 'black' => $b, 'is_bye' => 0]
            : ['white' => $b, 'black' => $a, 'is_bye' => 0];
    }

    /** Last pairing source used: 'bbp_local', 'bbp_api', or 'builtin'. For admin display. */
    private static $lastPairingSource = null;

    /** Log pairing debug. Enable via wp-config.php: define('CHESS_PODIUM_DEBUG_PAIRING', true); */
    private static function pairing_debug_log(string $msg): void
    {
        if (defined('CHESS_PODIUM_DEBUG_PAIRING') && CHESS_PODIUM_DEBUG_PAIRING) {
            error_log('[Chess Podium Pairing] ' . $msg);
        } elseif (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
            error_log('[Chess Podium Pairing] ' . $msg);
        }
    }

    /**
     * Pair via bbpPairings (FIDE Dutch Swiss). Same engine as Vega. Returns null if disabled or fails.
     * bbpPairings is a single C++ executable, no Java/dependencies. Output identical to JaVaFo.
     *
     * @param int $tournamentId Tournament ID.
     * @param int $roundToPair Round number to pair.
     * @param array<int> $withdrawn Player IDs withdrawn.
     * @param array<int> $byeRequested Player IDs with bye requested.
     * @return array<int, array{white: int, black: int, is_bye: int}>|null Pairings or null.
     */
    /**
     * Pairing order: JaVaFo first (FIDE homologation), then bbpPairings, then built-in.
     * JaVaFo is mandatory for FIDE homologation; bbpPairings and built-in are fallbacks only.
     */
    private static function pair_with_bbp(int $tournamentId, int $roundToPair, array $withdrawn, array $byeRequested, array $absent = []): ?array
    {
        self::$lastPairingSource = null;
        self::pairing_debug_log('pair_with_bbp start: tid=' . $tournamentId . ' round=' . $roundToPair);

        $absentForRound = array_merge(array_keys($withdrawn), array_keys($absent), array_keys($byeRequested));
        $trfData = ChessPodium_TrfExport::generate_trf_for_pairing($tournamentId, $roundToPair, $absentForRound, $byeRequested);
        if ($trfData['trf'] === '') {
            self::pairing_debug_log('TRF data empty, aborting');
            return null;
        }

        $apiUrl = self::get_pairing_api_url();
        self::pairing_debug_log('API URL: ' . ($apiUrl === '' ? '(empty)' : $apiUrl));

        // 1. Always try JaVaFo first (required for FIDE homologation)
        if ($apiUrl !== '' && preg_match('#^https?://#i', $apiUrl)) {
            self::pairing_debug_log('Trying JaVaFo via API...');
            $result = self::pair_with_bbp_api($trfData, $apiUrl, $tournamentId, $roundToPair, $withdrawn, $byeRequested, $absent, 'javafo');
            if ($result !== null) {
                self::$lastPairingSource = 'javafo_api';
                self::pairing_debug_log('JaVaFo API success');
                return $result;
            }
            self::pairing_debug_log('JaVaFo API failed, trying fallbacks');
        } else {
            self::pairing_debug_log('Skipping JaVaFo: API URL empty or invalid');
        }

        // 2. Fallback: bbpPairings local (if exec and path configured)
        if (function_exists('exec')) {
            $bbpPath = trim((string) get_option('chess_podium_bbp_pairings_path', ''));
            if ($bbpPath !== '' && is_readable($bbpPath)) {
                self::pairing_debug_log('Trying bbpPairings local: ' . $bbpPath);
                $result = self::pair_with_bbp_local($trfData, $bbpPath, $tournamentId, $roundToPair, $withdrawn, $byeRequested, $absent);
                if ($result !== null) {
                    self::$lastPairingSource = 'bbp_local';
                    self::pairing_debug_log('bbpPairings local success');
                    return $result;
                }
                self::pairing_debug_log('bbpPairings local failed');
            } else {
                self::pairing_debug_log('bbpPairings local skipped: path=' . ($bbpPath === '' ? '(empty)' : $bbpPath) . ' readable=' . (is_readable($bbpPath) ? 'yes' : 'no'));
            }
        } else {
            self::pairing_debug_log('bbpPairings local skipped: exec() disabled');
        }

        // 3. Fallback: bbpPairings via API
        if ($apiUrl !== '' && preg_match('#^https?://#i', $apiUrl)) {
            self::pairing_debug_log('Trying bbpPairings via API...');
            $result = self::pair_with_bbp_api($trfData, $apiUrl, $tournamentId, $roundToPair, $withdrawn, $byeRequested, $absent, 'bbpPairings');
            if ($result !== null) {
                self::$lastPairingSource = 'bbp_api';
                self::pairing_debug_log('bbpPairings API success');
                return $result;
            }
            self::pairing_debug_log('bbpPairings API failed');
        }

        self::pairing_debug_log('All pairing engines failed, falling back to built-in');
        return null;
    }

    /**
     * Returns the bbpPairings API URL. Uses option if set, otherwise default. Filter: chess_podium_pairing_api_url.
     */
    private static function get_pairing_api_url(): string
    {
        $url = trim((string) get_option('chess_podium_bbp_pairings_api_url', ''));
        if ($url === '') {
            $url = self::DEFAULT_PAIRING_API_URL;
        }
        return (string) apply_filters('chess_podium_pairing_api_url', $url);
    }

    private static function pair_with_bbp_local(array $trfData, string $bbpPath, int $tournamentId, int $roundToPair, array $withdrawn, array $byeRequested, array $absent = []): ?array
    {
        $tmpDir = get_temp_dir();
        $trfFile = $tmpDir . 'cp_trf_' . wp_rand(10000, 99999) . '.trfx';
        $outFile = $tmpDir . 'cp_pair_' . wp_rand(10000, 99999) . '.txt';

        if (file_put_contents($trfFile, $trfData['trf']) === false) {
            return null;
        }

        $bbpEsc = escapeshellarg($bbpPath);
        $trfEsc = escapeshellarg($trfFile);
        $outEsc = escapeshellarg($outFile);
        $cmd = $bbpEsc . ' --dutch ' . $trfEsc . ' -p ' . $outEsc . ' 2>' . (PHP_OS_FAMILY === 'Windows' ? 'nul' : '/dev/null');

        $output = [];
        $ret = 0;
        exec($cmd, $output, $ret);

        $pairs = self::parse_bbp_output($outFile, $trfData['startRankToPlayerId']);

        @unlink($trfFile);
        @unlink($outFile);

        return self::validate_pairs($pairs, $tournamentId, $withdrawn, $byeRequested, $absent, $roundToPair) ? $pairs : null;
    }

    private static function pair_with_bbp_api(array $trfData, string $apiUrl, int $tournamentId, int $roundToPair, array $withdrawn, array $byeRequested, array $absent = [], string $engine = 'javafo'): ?array
    {
        $body = ['trf' => base64_encode($trfData['trf']), 'engine' => $engine];
        self::pairing_debug_log('POST ' . $apiUrl . ' engine=' . $engine . ' trf_len=' . strlen($trfData['trf']));

        $response = wp_remote_post($apiUrl, [
            'timeout' => 30,
            'body' => $body,
            'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
        ]);

        if (is_wp_error($response)) {
            $err = $response->get_error_message();
            self::pairing_debug_log('API wp_remote_post error: ' . $err);
            return null;
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        if ($code !== 200 || $body === '') {
            self::pairing_debug_log('API HTTP ' . $code . ' body=' . substr($body, 0, 500));
            return null;
        }

        $json = json_decode($body, true);
        if (!is_array($json) || empty($json['ok']) || !isset($json['pairs'])) {
            $err = $json['error'] ?? 'Invalid JSON or missing pairs';
            self::pairing_debug_log('API response invalid: ' . (is_string($err) ? $err : 'ok=false or no pairs') . ' body_preview=' . substr($body, 0, 200));
            return null;
        }

        $map = $trfData['startRankToPlayerId'];
        // JaVaFo manual: first=white, second=black. No swap. (Swap was inverting colors.)
        $pairs = [];
        foreach ($json['pairs'] as $pair) {
            $firstTpn = (int) ($pair[0] ?? 0);
            $secondTpn = (int) ($pair[1] ?? 0);
            $firstPid = $map[$firstTpn] ?? 0;
            $secondPid = $secondTpn === 0 ? 0 : ($map[$secondTpn] ?? 0);
            if ($firstPid > 0) {
                $isBye = $secondPid === 0;
                $whitePid = $firstPid;
                $blackPid = $secondPid;
                $pairs[] = [
                    'white' => $whitePid,
                    'black' => $blackPid,
                    'is_bye' => $isBye ? 1 : 0,
                ];
            }
        }

        if (!self::validate_pairs($pairs, $tournamentId, $withdrawn, $byeRequested, $absent, $roundToPair)) {
            $expected = (int) ceil((count(self::get_players($tournamentId)) - count($withdrawn) - count($byeRequested) - count($absent)) / 2);
            self::pairing_debug_log('API validate_pairs failed: got ' . count($pairs) . ' pairs, expected ' . $expected);
            return null;
        }
        return $pairs;
    }

    private static function parse_bbp_output(string $outFile, array $map): array
    {
        $pairs = [];
        if (!is_readable($outFile)) {
            return $pairs;
        }
        $lines = array_map('trim', (array) file($outFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
        $pairCount = (int) ($lines[0] ?? 0);
        if ($pairCount > 0) {
            for ($i = 1; $i <= $pairCount && isset($lines[$i]); $i++) {
                $parts = preg_split('/\s+/', $lines[$i], 2);
                $wId = isset($parts[0]) ? (int) $parts[0] : 0;
                $bId = isset($parts[1]) ? (int) $parts[1] : 0;
                $whitePid = $map[$wId] ?? 0;
                $blackPid = $bId === 0 ? 0 : ($map[$bId] ?? 0);
                if ($whitePid > 0) {
                    $pairs[] = [
                        'white' => $whitePid,
                        'black' => $blackPid,
                        'is_bye' => $blackPid === 0 ? 1 : 0,
                    ];
                }
            }
        }
        return $pairs;
    }

    /**
     * Validate pairing result. FIDE C.2: player who already had pairing-allocated bye must not receive it again.
     *
     * @param int $roundToPair Round being paired (for FIDE C.2 check). 0 = skip bye check.
     */
    private static function validate_pairs(array $pairs, int $tournamentId, array $withdrawn, array $byeRequested, array $absent = [], int $roundToPair = 0): bool
    {
        $playingCount = count(self::get_players($tournamentId)) - count($withdrawn) - count($byeRequested) - count($absent);
        $expectedPairs = (int) ceil($playingCount / 2);
        if (count($pairs) !== $expectedPairs || empty($pairs)) {
            return false;
        }
        if ($roundToPair > 1) {
            $cannotGetBye = self::get_players_who_cannot_receive_bye($tournamentId, $roundToPair - 1);
            foreach ($pairs as $p) {
                if ((int) ($p['is_bye'] ?? 0) === 1) {
                    $byePid = (int) ($p['white'] ?? 0);
                    if (isset($cannotGetBye[$byePid])) {
                        return false;
                    }
                }
            }
        }
        return true;
    }

    /**
     * Get player IDs who cannot receive pairing-allocated bye in the next round. FIDE C.2.
     * Includes: pairing-allocated bye (is_bye=1) and forfeit win (1F/0F).
     *
     * @param int $tournamentId Tournament ID.
     * @param int $roundNo Round to check.
     * @return array<int,bool> Player ID => true (cannot get bye).
     */
    private static function get_players_who_cannot_receive_bye(int $tournamentId, int $roundNo): array
    {
        global $wpdb;
        $table = $wpdb->prefix . 'regina_pairings';
        $out = [];
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT white_player_id, black_player_id, is_bye, result FROM $table WHERE tournament_id = %d AND round_no = %d",
            $tournamentId,
            $roundNo
        ));
        foreach ($rows ?: [] as $r) {
            if ((int) $r->is_bye === 1) {
                $out[(int) $r->white_player_id] = true;
            } elseif (in_array((string) $r->result, ['1F', '0F'], true)) {
                $out[(int) ($r->result === '1F' ? $r->white_player_id : $r->black_player_id)] = true;
            }
        }
        return $out;
    }

    /**
     * Build Dutch pairing data: rank within score group and group size.
     * FIDE Dutch pairs rank k with rank k+size/2 (top half vs bottom half).
     *
     * @return array{rankInGroup: array<int,int>, groupSize: array<int,int>}
     */
    private static function build_dutch_rank_data(array $ordered, array $pointsMap): array
    {
        $rankInGroup = [];
        $groupSize = [];
        $i = 0;
        while ($i < count($ordered)) {
            $score = (float) ($pointsMap[$ordered[$i]] ?? 0.0);
            $group = [];
            while ($i < count($ordered) && abs((float) ($pointsMap[$ordered[$i]] ?? 0) - $score) < 0.001) {
                $group[] = (int) $ordered[$i];
                $i++;
            }
            $size = count($group);
            foreach ($group as $rank => $pid) {
                $rankInGroup[$pid] = $rank + 1;
                $groupSize[$pid] = $size;
            }
        }
        return ['rankInGroup' => $rankInGroup, 'groupSize' => $groupSize];
    }

    /**
     * Swiss pairing via Maximum Weight Matching (MWM) per arxiv.org/html/2112.10522v2.
     * Weight: w = 10000*(-|s(pi)-s(pj)|) + 100*(-|cd(pi)+cd(pj)|) + π(pi,pj)
     * Dutch π = -|sg_size/2 - |r(pi)-r(pj)||^1.01 (sg_size=0 if different score groups)
     * Color: player with lower |cd| plays White; if equal, higher rank in group plays White.
     */
    private static function pair_swiss_mwm(
        array $remaining,
        array $pointsMap,
        array $playedMap,
        array $colorBalances,
        array $dutchData,
        array $lastColors
    ): array {
        $n = count($remaining);
        if ($n < 2) {
            return [];
        }

        $rankInGroup = $dutchData['rankInGroup'] ?? [];
        $groupSize = $dutchData['groupSize'] ?? [];
        $beta = 2;

        // Map: vertex index i -> player id
        $idxToPid = array_values($remaining);
        $pidToIdx = array_flip($idxToPid);

        $edges = [];
        for ($i = 0; $i < $n; $i++) {
            for ($j = $i + 1; $j < $n; $j++) {
                $a = (int) $idxToPid[$i];
                $b = (int) $idxToPid[$j];

                if (isset($playedMap[self::pair_key($a, $b)])) {
                    continue;
                }

                $cdA = (int) ($colorBalances[$a] ?? 0);
                $cdB = (int) ($colorBalances[$b] ?? 0);
                if (abs($cdA + $cdB) >= 2 * $beta) {
                    continue;
                }

                $scoreDiff = abs((float) ($pointsMap[$a] ?? 0) - (float) ($pointsMap[$b] ?? 0));
                $colorSum = abs($cdA + $cdB);
                $pi = self::mwm_dutch_pi($a, $b, $rankInGroup, $groupSize);

                $w = 10000 * (-$scoreDiff) + 100 * (-$colorSum) + $pi;
                $wInt = (int) round($w * 100);
                $edges[] = [$i, $j, $wInt];
            }
        }

        if (empty($edges)) {
            return [];
        }

        $maxWeight = 0;
        foreach ($edges as $e) {
            $maxWeight = max($maxWeight, abs($e[2]));
        }
        $M = (int) ($maxWeight * $n * 2 + 1000000);
        foreach ($edges as $k => $e) {
            $edges[$k][2] = $e[2] + $M;
        }

        try {
            $matching = new ChessPodium_MaxWeightMatching($edges);
            $mate = $matching->main();
        } catch (Throwable $e) {
            return [];
        }

        if (!is_array($mate)) {
            return [];
        }

        $seen = [];
        $pairs = [];
        foreach ($mate as $i => $j) {
            if ($j === -1 || isset($seen[$i]) || isset($seen[$j])) {
                continue;
            }
            $seen[$i] = true;
            $seen[$j] = true;
            $a = (int) $idxToPid[$i];
            $b = (int) $idxToPid[$j];
            $orientation = self::mwm_color_orientation($a, $b, $colorBalances, $dutchData);
            $pairs[] = [
                'white' => (int) $orientation['white'],
                'black' => (int) $orientation['black'],
                'is_bye' => 0,
            ];
        }

        return $pairs;
    }

    /**
     * Dutch π(pi,pj) = -|sg_size/2 - |r(pi)-r(pj)||^1.01
     * sg_size = 0 if different score groups (article 2.2).
     */
    private static function mwm_dutch_pi(int $a, int $b, array $rankInGroup, array $groupSize): float
    {
        $ra = $rankInGroup[$a] ?? 0;
        $rb = $rankInGroup[$b] ?? 0;
        $sz = $groupSize[$a] ?? 0;
        $szB = $groupSize[$b] ?? 0;
        if ($ra === 0 || $rb === 0) {
            return 0.0;
        }
        $actualDist = (float) abs($ra - $rb);
        if ($sz !== $szB || $sz < 2) {
            $idealDist = 0.0;
        } else {
            $idealDist = $sz / 2.0;
        }
        $deviation = abs($idealDist - $actualDist);
        return -pow($deviation, 2.0);
    }

    /**
     * FIDE: "the respective player with the lower color difference will play white."
     * CD = white games - black games (signed); lower CD = has played more Black = right to White.
     * Tiebreaker when CD equal: higher-ranked player (lower rank number) gets White.
     * NOTE: Uses CD with sign, NOT abs() — critical for correct color assignment.
     */
    private static function mwm_color_orientation(int $a, int $b, array $colorBalances, array $dutchData): array
    {
        $cdA = (int) ($colorBalances[$a] ?? 0);
        $cdB = (int) ($colorBalances[$b] ?? 0);
        $rankInGroup = $dutchData['rankInGroup'] ?? [];
        $ra = $rankInGroup[$a] ?? 0;
        $rb = $rankInGroup[$b] ?? 0;

        if ($cdA < $cdB) {
            return ['white' => $a, 'black' => $b];
        }
        if ($cdB < $cdA) {
            return ['white' => $b, 'black' => $a];
        }
        if ($ra !== $rb) {
            return $ra < $rb ? ['white' => $a, 'black' => $b] : ['white' => $b, 'black' => $a];
        }
        return ['white' => $a, 'black' => $b];
    }

    private static function search_pairings_backtracking(
        array $remaining,
        array $pointsMap,
        array $playedMap,
        array $colorBalances,
        array $dutchData = [],
        array $lastColors = []
    ): array {
        $bestPairs = array();
        $bestPenalty = PHP_INT_MAX;
        $maxCandidates = 8;

        $walk = function (array $todo, array $currentPairs, int $currentPenalty) use (
            &$walk,
            &$bestPairs,
            &$bestPenalty,
            $pointsMap,
            $playedMap,
            $colorBalances,
            $dutchData,
            $lastColors,
            $maxCandidates
        ) {
            if ($currentPenalty >= $bestPenalty) {
                return;
            }

            if (empty($todo)) {
                $bestPenalty = $currentPenalty;
                $bestPairs = $currentPairs;
                return;
            }

            $a = (int) $todo[0];
            $others = array_slice($todo, 1);
            $candidates = array();

            foreach ($others as $b) {
                $b = (int) $b;
                $hasRematch = isset($playedMap[self::pair_key($a, $b)]);
                $rematchPenalty = $hasRematch ? 1000 : 0;
                $scoreDiff = abs((float) ($pointsMap[$a] ?? 0.0) - (float) ($pointsMap[$b] ?? 0.0));
                $scorePenalty = (int) round($scoreDiff * 10);
                $dutchPenalty = self::dutch_pairing_penalty($a, $b, $pointsMap, $dutchData);
                $orientation = self::best_orientation($a, $b, $colorBalances, $lastColors, $dutchData);
                $totalPenalty = $rematchPenalty + $scorePenalty + $dutchPenalty + (int) $orientation['penalty'];

                $candidates[] = array(
                    'opp' => $b,
                    'white' => (int) $orientation['white'],
                    'black' => (int) $orientation['black'],
                    'penalty' => $totalPenalty,
                    'dutchPenalty' => $dutchPenalty,
                );
            }

            usort($candidates, static function ($x, $y) {
                $c = (int) $x['penalty'] <=> (int) $y['penalty'];
                return $c !== 0 ? $c : ((int) $x['dutchPenalty'] <=> (int) $y['dutchPenalty']);
            });

            $candidates = array_slice($candidates, 0, $maxCandidates);

            foreach ($candidates as $candidate) {
                $newPenalty = $currentPenalty + (int) $candidate['penalty'];
                if ($newPenalty >= $bestPenalty) {
                    continue;
                }

                $opp = (int) $candidate['opp'];
                $nextTodo = array();
                foreach ($others as $id) {
                    if ((int) $id !== $opp) {
                        $nextTodo[] = (int) $id;
                    }
                }

                $nextPairs = $currentPairs;
                $nextPairs[] = array(
                    'white' => (int) $candidate['white'],
                    'black' => (int) $candidate['black'],
                    'is_bye' => 0,
                );

                $walk($nextTodo, $nextPairs, $newPenalty);
            }
        };

        $walk($remaining, array(), 0);
        return $bestPairs;
    }

    private static function pair_swiss_greedy_fallback(array $remaining, array $pointsMap, array $playedMap, array $colorBalances, array $dutchData = [], array $lastColors = []): array
    {
        $pairs = array();
        $todo = array_values($remaining);

        while (count($todo) > 1) {
            $a = (int) array_shift($todo);
            $bestIdx = null;
            $bestPenalty = PHP_INT_MAX;
            $bestDutchPenalty = PHP_INT_MAX;
            $bestOrientation = array('white' => $a, 'black' => 0, 'penalty' => 0);

            $scoreA = (float) ($pointsMap[$a] ?? 0.0);
            foreach ($todo as $idx => $rawB) {
                $b = (int) $rawB;
                $hasRematch = isset($playedMap[self::pair_key($a, $b)]);
                $scoreDiff = abs($scoreA - (float) ($pointsMap[$b] ?? 0.0));
                $dutchPenalty = self::dutch_pairing_penalty($a, $b, $pointsMap, $dutchData);
                $orientation = self::best_orientation($a, $b, $colorBalances, $lastColors, $dutchData);
                $penalty = ($hasRematch ? 1000 : 0) + (int) round($scoreDiff * 10) + $dutchPenalty + (int) $orientation['penalty'];
                if ($penalty < $bestPenalty || ($penalty === $bestPenalty && $dutchPenalty < $bestDutchPenalty)) {
                    $bestPenalty = $penalty;
                    $bestDutchPenalty = $dutchPenalty;
                    $bestIdx = (int) $idx;
                    $bestOrientation = $orientation;
                }
            }

            if ($bestIdx === null) {
                break;
            }

            $b = (int) $todo[$bestIdx];
            unset($todo[$bestIdx]);
            $todo = array_values($todo);

            $pairs[] = array(
                'white' => (int) $bestOrientation['white'],
                'black' => (int) $bestOrientation['black'],
                'is_bye' => 0,
            );
        }

        return $pairs;
    }

    /**
     * Dutch pairing penalty: prefer pairing rank k with rank k+size/2 (top vs bottom half).
     * Based on arxiv.org/html/2112.10522v2 - π(pi,pj) = -|sg_size/2 - |r(pi)-r(pj)||^1.01
     *
     * @param array{rankInGroup?: array<int,int>, groupSize?: array<int,int>} $dutchData
     */
    private static function dutch_pairing_penalty(int $a, int $b, array $pointsMap, array $dutchData): int
    {
        $rankInGroup = $dutchData['rankInGroup'] ?? [];
        $groupSize = $dutchData['groupSize'] ?? [];
        if (empty($rankInGroup) || empty($groupSize)) {
            return 0;
        }
        $ra = $rankInGroup[$a] ?? 0;
        $rb = $rankInGroup[$b] ?? 0;
        $sz = $groupSize[$a] ?? 0;
        if ($ra === 0 || $rb === 0 || $sz < 2) {
            return 0;
        }
        // Same score group only (different groups already penalized by scoreDiff)
        $szB = $groupSize[$b] ?? 0;
        if ($sz !== $szB) {
            return 0;
        }
        $idealDist = $sz / 2.0;
        $actualDist = (float) abs($ra - $rb);
        $deviation = abs($idealDist - $actualDist);
        return (int) round(12.0 * pow($deviation, 1.01));
    }

    /**
     * FIDE: no more than 2 consecutive same color; color diff ≤ 2.
     * When color balance is equal: bottom-half player (higher rank in group) gets White.
     *
     * @param array<int, array<string>> $lastColors pid => last 2 colors ['w','b']
     * @param array{rankInGroup?: array<int,int>} $dutchData For tiebreaker when balance equal.
     */
    private static function best_orientation(int $a, int $b, array $colorBalances, array $lastColors = [], array $dutchData = []): array
    {
        $balA = (int) ($colorBalances[$a] ?? 0);
        $balB = (int) ($colorBalances[$b] ?? 0);

        $lastA = $lastColors[$a] ?? [];
        $lastB = $lastColors[$b] ?? [];
        $forbidAWhite = count($lastA) === 2 && $lastA[0] === 'w' && $lastA[1] === 'w';
        $forbidABlack = count($lastA) === 2 && $lastA[0] === 'b' && $lastA[1] === 'b';
        $forbidBWhite = count($lastB) === 2 && $lastB[0] === 'w' && $lastB[1] === 'w';
        $forbidBBlack = count($lastB) === 2 && $lastB[0] === 'b' && $lastB[1] === 'b';

        $p1 = 500;
        $p2 = 500;
        if (!$forbidAWhite && !$forbidBBlack && abs($balA + 1) <= 2 && abs($balB - 1) <= 2) {
            $p1 = abs($balA + 1) + abs($balB - 1);
        }
        if (!$forbidBWhite && !$forbidABlack && abs($balB + 1) <= 2 && abs($balA - 1) <= 2) {
            $p2 = abs($balB + 1) + abs($balA - 1);
        }

        if ($p1 < $p2) {
            return array('white' => $a, 'black' => $b, 'penalty' => $p1);
        }
        if ($p2 < $p1) {
            return array('white' => $b, 'black' => $a, 'penalty' => $p2);
        }

        // Equal penalty: FIDE tiebreaker - higher-ranked player (lower rank number) gets White
        $rankInGroup = $dutchData['rankInGroup'] ?? [];
        $ra = $rankInGroup[$a] ?? 0;
        $rb = $rankInGroup[$b] ?? 0;
        if ($ra > 0 && $rb > 0 && $ra !== $rb) {
            return $ra < $rb
                ? array('white' => $a, 'black' => $b, 'penalty' => $p1)
                : array('white' => $b, 'black' => $a, 'penalty' => $p2);
        }

        if ($p1 <= $p2) {
            return array('white' => $a, 'black' => $b, 'penalty' => $p1);
        }
        return array('white' => $b, 'black' => $a, 'penalty' => $p2);
    }

    /**
     * Reorder pairings so players with fixed board assignments get their assigned board.
     * Inserts the fixed-board pairing at the target position and shifts others (no swap).
     *
     * @param array<int, array{white: int, black: int, is_bye: int, bye_points?: float}> $pairings
     * @return array<int, array{white: int, black: int, is_bye: int, bye_points?: float}>
     */
    private static function apply_fixed_board_assignments(array $pairings, int $tournamentId, int $round): array
    {
        // chess-results board numbering (Sc.) counts only real games (non-BYE).
        // In Chess Podium, $pairings also contains BYE/withdrawn placeholders (is_bye=1),
        // so applying fixed boards on the full array can shift indices.
        $games = [];
        $rest = [];
        foreach ($pairings as $pair) {
            $black = isset($pair['black']) ? (int) $pair['black'] : 0;
            if (!empty($pair['is_bye']) || $black === 0) {
                $rest[] = $pair;
                continue;
            }
            $games[] = $pair;
        }

        $assignments = self::get_fixed_board_assignments($tournamentId, $round);
        if (!empty($assignments)) {
            // Build exact board slots to avoid index shifts when multiple fixed boards exist.
            // Strategy:
            // - Find the pairing (game) that contains each fixed-board player.
            // - Place that pairing into the exact target slot (board_number-1) if free.
            // - Fill remaining slots with the other games, preserving their relative order.
            asort($assignments, SORT_NUMERIC);
            $nGames = count($games);
            if ($nGames > 0) {
                $fixedPairingIndexByGameIdx = [];
                $fixedPairingByTargetIdx = [];

                foreach ($assignments as $playerId => $boardNumber) {
                    $targetIdx = (int) $boardNumber - 1; // board 1 => index 0
                    if ($targetIdx < 0 || $targetIdx >= $nGames) {
                        continue;
                    }
                    if (isset($fixedPairingByTargetIdx[$targetIdx])) {
                        // Two fixed assignments targeting same board: keep first.
                        continue;
                    }

                    $foundGameIdx = null;
                    foreach ($games as $idx => $pair) {
                        $w = (int) $pair['white'];
                        $b = (int) $pair['black'];
                        if ($w === (int) $playerId || $b === (int) $playerId) {
                            $foundGameIdx = (int) $idx;
                            break;
                        }
                    }
                    if ($foundGameIdx === null) {
                        continue;
                    }
                    // If this game was already fixed by another player (e.g. both players fixed),
                    // keep the earlier (lower board) placement.
                    if (isset($fixedPairingIndexByGameIdx[$foundGameIdx])) {
                        continue;
                    }

                    $fixedPairingIndexByGameIdx[$foundGameIdx] = $targetIdx;
                    $fixedPairingByTargetIdx[$targetIdx] = $games[$foundGameIdx];
                }

                $newGames = array_fill(0, $nGames, null);
                foreach ($fixedPairingByTargetIdx as $targetIdx => $pair) {
                    $newGames[(int) $targetIdx] = $pair;
                }

                $nextFree = 0;
                foreach ($games as $idx => $pair) {
                    if (isset($fixedPairingIndexByGameIdx[(int) $idx])) {
                        continue;
                    }
                    while ($nextFree < $nGames && $newGames[$nextFree] !== null) {
                        $nextFree++;
                    }
                    if ($nextFree >= $nGames) {
                        break;
                    }
                    $newGames[$nextFree] = $pair;
                    $nextFree++;
                }

                // Defensive: if any holes remain (should not), compact-fill from original order.
                for ($i = 0; $i < $nGames; $i++) {
                    if ($newGames[$i] === null) {
                        $newGames[$i] = $games[$i] ?? $games[0];
                    }
                }

                $games = $newGames;
            }
        }

        return array_merge($games, $rest);
    }

    /**
     * @return array<int, array{player_id: int, player_name: string, board_number: int, from_round: int}>
     */
    private static function get_all_fixed_board_assignments(int $tournamentId): array
    {
        global $wpdb;
        $fbTable = self::db_table('regina_fixed_boards');
        $pTable = self::db_table('regina_players');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT fb.player_id, fb.board_number, fb.from_round, p.name AS player_name
             FROM $fbTable fb
             LEFT JOIN $pTable p ON fb.player_id = p.id AND fb.tournament_id = p.tournament_id
             WHERE fb.tournament_id = %d
             ORDER BY fb.board_number ASC",
            $tournamentId
        ));
        $out = [];
        foreach ($rows as $r) {
            $out[] = [
                'player_id' => (int) $r->player_id,
                'player_name' => (string) ($r->player_name ?? ''),
                'board_number' => (int) $r->board_number,
                'from_round' => (int) $r->from_round,
            ];
        }
        return $out;
    }

    /**
     * @return array<int, int> player_id => board_number for players with fixed board from this round
     */
    private static function get_fixed_board_assignments(int $tournamentId, int $round): array
    {
        global $wpdb;
        $table = self::db_table('regina_fixed_boards');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT player_id, board_number FROM $table WHERE tournament_id = %d AND from_round <= %d",
            $tournamentId,
            $round
        ));
        $out = [];
        foreach ($rows as $r) {
            $out[(int) $r->player_id] = (int) $r->board_number;
        }
        return $out;
    }

    private static function save_fixed_board(int $tournamentId, int $playerId, int $boardNumber, int $fromRound): void
    {
        global $wpdb;
        $table = self::db_table('regina_fixed_boards');
        $wpdb->replace(
            $table,
            [
                'tournament_id' => $tournamentId,
                'player_id' => $playerId,
                'board_number' => $boardNumber,
                'from_round' => $fromRound,
            ],
            ['%d', '%d', '%d', '%d']
        );
    }

    private static function remove_fixed_board(int $tournamentId, int $playerId): void
    {
        global $wpdb;
        $wpdb->delete(
            self::db_table('regina_fixed_boards'),
            ['tournament_id' => $tournamentId, 'player_id' => $playerId],
            ['%d', '%d']
        );
    }

    private static function pair_key(int $a, int $b): string
    {
        $min = min($a, $b);
        $max = max($a, $b);
        return $min . '-' . $max;
    }

    private static function color_balance(int $tournamentId, int $playerId): int
    {
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $white = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $pairings WHERE tournament_id=%d AND white_player_id=%d", $tournamentId, $playerId));
        $black = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM $pairings WHERE tournament_id=%d AND black_player_id=%d", $tournamentId, $playerId));
        return $white - $black;
    }

    private static function already_played(int $tournamentId, int $a, int $b): bool
    {
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $count = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM $pairings
                 WHERE tournament_id=%d
                 AND ((white_player_id=%d AND black_player_id=%d) OR (white_player_id=%d AND black_player_id=%d))",
                $tournamentId,
                $a,
                $b,
                $b,
                $a
            )
        );
        return $count > 0;
    }

    private static function player_used_in_pairings(int $tournamentId, int $playerId): bool
    {
        global $wpdb;
        $pairings = self::db_table('regina_pairings');
        $count = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM $pairings
                 WHERE tournament_id = %d
                 AND (white_player_id = %d OR black_player_id = %d)",
                $tournamentId,
                $playerId,
                $playerId
            )
        );
        return $count > 0;
    }

    public static function sanitize_fide_id(string $raw): string
    {
        return preg_replace('/[^0-9]/', '', $raw) ?? '';
    }

    /**
     * Public wrapper used by export engine and integrations.
     * Returns the cached FIDE profile photo URL (or null when unavailable).
     */
    public static function get_fide_photo_url_cached(string $fideId): ?string
    {
        return self::fetch_fide_photo_url($fideId);
    }

    public static function sanitize_uscf_id(string $raw): string
    {
        return preg_replace('/[^0-9]/', '', trim($raw)) ?? '';
    }

    public static function sanitize_ecf_id(string $raw): string
    {
        return preg_replace('/[^0-9A-Za-z]/', '', trim($raw)) ?? '';
    }

    private static function fetch_fide_player_data(string $fideId): ?array
    {
        $url = 'https://ratings.fide.com/profile/' . rawurlencode($fideId);
        $response = wp_remote_get($url, array(
            'timeout' => 15,
            'user-agent' => 'ChessPodium/0.1 (+WordPress)',
        ));

        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            return null;
        }

        $body = (string) wp_remote_retrieve_body($response);
        if ($body === '') {
            return null;
        }

        $name = '';
        if (preg_match('/<title>\s*([^<]+?)\s+FIDE\s+Profile\s*<\/title>/i', $body, $m)) {
            $name = trim(wp_strip_all_tags($m[1]));
        }

        $plain = preg_replace('/\s+/', ' ', wp_strip_all_tags($body));
        $rating = 0;
        if (is_string($plain) && preg_match('/\b([0-9]{3,4})\s*STANDARD\b/i', $plain, $m2)) {
            $rating = (int) $m2[1];
        }

        if ($name === '') {
            return null;
        }

        $country = null;
        // Current FIDE profile HTML: federation flag under .profile-info-country (e.g. /images/flags/us.svg).
        if (preg_match(
            '#<div[^>]*class="[^"]*profile-info-country[^"]*"[^>]*>[\s\S]*?/images/flags/([a-z]{2,3})\.svg#i',
            $body,
            $m
        )) {
            $flag = strtoupper($m[1]);
            if (strlen($flag) === 2) {
                $country = $flag;
            } elseif (strlen($flag) === 3 && class_exists('ChessPodium_CountryHelper')) {
                $iso = ChessPodium_CountryHelper::fide_to_iso($flag);
                if ($iso !== null) {
                    $country = $iso;
                }
            }
        }
        if ($country === null && preg_match('/topfed\.phtml\?country=([A-Z]{3})\b/i', $body, $m)) {
            $country = ChessPodium_CountryHelper::fide_to_iso($m[1]);
        } elseif ($country === null && preg_match('/\bfederation[:\s]+[A-Za-z\s]+\(([A-Z]{3})\)/i', $body, $m)) {
            $country = ChessPodium_CountryHelper::fide_to_iso($m[1]);
        } elseif ($country === null && preg_match('/\b(ITA|GER|USA|RUS|FRA|ENG|ESP|CHN|IND|NED|POL|UKR|HUN|AZE|ARM|ROU|CZE|GRE|CRO|SRB|BUL|SWE|NOR|DEN|FIN|ISR|BRA|ARG|MEX|CAN|AUS|EGY|IRI|UZB|KAZ|GEO|LTU|LAT|EST|SLO|SUI|AUT|BLR|MDA|PHI|VIE|THA|INA|SGP|MAL)\b/i', $body, $m)) {
            $country = ChessPodium_CountryHelper::fide_to_iso($m[1]);
        }

        $title = null;
        if (preg_match(
            '#<div[^>]*class="[^"]*profile-info-title[^"]*"[^>]*>\s*<p>\s*([^<]+?)\s*</p>#i',
            $body,
            $m
        )) {
            $title = trim(wp_strip_all_tags($m[1]));
            if ($title === '') {
                $title = null;
            }
        }
        if ($title === null && preg_match('/\b(GM|IM|FM|CM|WGM|WIM|WFM|WCM)\b/i', $body, $m)) {
            $title = strtoupper($m[1]);
        }

        return array(
            'name' => $name,
            'rating' => $rating,
            'fide_id' => $fideId,
            'country' => $country,
            'title' => $title,
        );
    }

    private static function sync_player_from_fide(int $tournamentId, int $playerId): bool
    {
        global $wpdb;
        $playersTable = self::db_table('regina_players');
        $player = $wpdb->get_row($wpdb->prepare(
            "SELECT id, fide_id FROM $playersTable WHERE tournament_id = %d AND id = %d LIMIT 1",
            $tournamentId,
            $playerId
        ));
        if (!$player) {
            return false;
        }

        $fideId = self::sanitize_fide_id((string) ($player->fide_id ?? ''));
        if ($fideId === '') {
            return false;
        }

        $fideData = self::fetch_fide_player_data($fideId);
        if ($fideData === null) {
            return false;
        }

        $name = (string) ($fideData['name'] ?? '');
        if ($name === '') {
            return false;
        }

        $rating = isset($fideData['rating']) ? (int) $fideData['rating'] : 0;
        $country = isset($fideData['country']) ? self::sanitize_country_code((string) $fideData['country']) : null;
        $title = isset($fideData['title']) ? self::sanitize_fide_title((string) $fideData['title']) : null;

        $data = [
            'name' => $name,
            'rating' => $rating,
        ];
        $formats = ['%s', '%d'];
        if ($country !== null && $country !== '') {
            $data['country'] = $country;
            $formats[] = '%s';
        }
        $data['title'] = $title !== null ? $title : null;
        $formats[] = '%s';

        $updated = $wpdb->update(
            $playersTable,
            $data,
            ['tournament_id' => $tournamentId, 'id' => $playerId],
            $formats,
            ['%d', '%d']
        );
        return $updated !== false;
    }

    private static function sync_all_players_from_fide(int $tournamentId): int
    {
        global $wpdb;
        $playersTable = self::db_table('regina_players');
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT id FROM $playersTable WHERE tournament_id = %d AND fide_id IS NOT NULL AND fide_id <> ''",
            $tournamentId
        ));
        $updated = 0;
        foreach ((array) $rows as $r) {
            $pid = (int) ($r->id ?? 0);
            if ($pid <= 0) {
                continue;
            }
            if (self::sync_player_from_fide($tournamentId, $pid)) {
                $updated++;
            }
        }
        return $updated;
    }

    private static function render_admin_notice(string $code, int $importedCount = 0): void
    {
        $map = array(
            'create_success' => array('success', __('Tournament created successfully.', 'chess-podium')),
            'create_error_name' => array('error', __('Enter a valid tournament name.', 'chess-podium')),
            'excel_import_success' => array('success', __('Tournament created from Excel file.', 'chess-podium')),
            'excel_import_error' => array('error', __('Excel import failed. Use chess-results format: player list (Nr., Nome, FideID, paese, EloI) or pairing/results (White, Black, Risultato).', 'chess-podium')),
            'excel_import_no_file' => array('error', __('Select or upload an Excel file.', 'chess-podium')),
            'add_player_success' => array('success', __('Player added successfully.', 'chess-podium')),
            'add_player_error_tournament' => array('error', __('Select a valid tournament.', 'chess-podium')),
            'add_player_error_name' => array('error', __('Enter a valid player name.', 'chess-podium')),
            'add_player_error_country' => array('error', __('Select a nationality for the player.', 'chess-podium')),
            'player_limit_reached' => array('error', __('Player limit reached. Upgrade to Chess Podium Pro for unlimited players.', 'chess-podium')),
            'update_player_success' => array('success', __('Player updated successfully.', 'chess-podium')),
            'update_player_error' => array('error', __('Unable to update: invalid data.', 'chess-podium')),
            'update_player_error_name' => array('error', __('Player name cannot be empty.', 'chess-podium')),
            'delete_player_success' => array('success', __('Player deleted successfully.', 'chess-podium')),
            'bulk_update_success' => array('success', __('Players updated successfully.', 'chess-podium')),
            'randomize_success' => array('success', __('Pairing numbers randomized successfully.', 'chess-podium')),
            'randomize_error' => array('error', __('Automatic draw is only available for Round Robin tournaments with at least 2 players.', 'chess-podium')),
            'bulk_update_no_changes' => array('info', __('No valid players to update.', 'chess-podium')),
            'delete_player_error' => array('error', __('Unable to delete: invalid data.', 'chess-podium')),
            'delete_player_in_use' => array('warning', __('Unable to delete: player is already paired in one or more rounds.', 'chess-podium')),
            'generate_success' => array('success', __('Round generated successfully.', 'chess-podium')),
            'generate_failed' => array('error', __('Unable to generate round. Ensure you have at least 2 players and rounds available.', 'chess-podium')),
            'generate_error' => array('error', __('Select a valid tournament.', 'chess-podium')),
            'lichess_import_success' => array('success', __('Next round imported from Lichess PGN.', 'chess-podium')),
            'lichess_import_error' => array('error', __('Could not import the next round from Lichess.', 'chess-podium')),
            'rollback_success' => array('success', __('Round rolled back successfully.', 'chess-podium')),
            'rollback_failed' => array('error', __('Unable to rollback: no round to rollback.', 'chess-podium')),
            'swap_colors_success' => array('success', __('Round colors swapped successfully.', 'chess-podium')),
            'swap_colors_failed' => array('error', __('Unable to swap colors: no round.', 'chess-podium')),
            'swap_colors_error' => array('error', __('Unable to swap colors: invalid tournament.', 'chess-podium')),
            'rollback_error' => array('error', __('Select a valid tournament.', 'chess-podium')),
            'fixed_boards_updated' => array('success', __('Fixed board assignment saved.', 'chess-podium')),
            'fixed_boards_removed' => array('success', __('Fixed board assignment removed.', 'chess-podium')),
            'fixed_boards_error' => array('error', __('Select a valid tournament.', 'chess-podium')),
            'save_results_success' => array('success', __('Results saved successfully.', 'chess-podium')),
            'simulate_live_ok' => array('success', __('Live boards test data added. View the tournament page to see the interface.', 'chess-podium')),
            'save_results_error' => array('error', __('Unable to save results.', 'chess-podium')),
            'save_results_no_data' => array('warning', __('No results to save.', 'chess-podium')),
            'save_pairings_success' => array('success', __('Pairings updated successfully.', 'chess-podium')),
            'save_pairings_error' => array('error', __('Unable to save pairings. Check that each player appears only once per round.', 'chess-podium')),
            'save_pairings_no_data' => array('warning', __('No pairings to save.', 'chess-podium')),
            'fide_imported' => array('success', __('Player imported from FIDE successfully.', 'chess-podium')),
            'fide_exists' => array('warning', __('This FIDE ID is already in the tournament.', 'chess-podium')),
            'fide_not_found' => array('error', __('FIDE player not found or unreachable.', 'chess-podium')),
            'fide_invalid' => array('error', __('Invalid FIDE ID.', 'chess-podium')),
            'fide_synced' => array('success', __('Player synced from FIDE successfully.', 'chess-podium')),
            'fide_sync_error' => array('error', __('Unable to sync player from FIDE.', 'chess-podium')),
            'fide_bulk_synced' => array('success', __('Players synced from FIDE successfully.', 'chess-podium')),
            'fide_bulk_noop' => array('info', __('No players with FIDE ID to sync.', 'chess-podium')),
            'bulk_import_success' => array('success', __('Players imported successfully.', 'chess-podium')),
            'bulk_import_error' => array('error', __('CSV import error. Check format (name;rating or name,rating).', 'chess-podium')),
            'bulk_import_no_file' => array('error', __('Select a CSV file to upload.', 'chess-podium')),
            'import_cr_success' => array('success', __('Results imported from chess-results.com Excel successfully.', 'chess-podium')),
            'import_cr_error' => array('error', __('Import failed. Use the Excel export from chess-results (pairing/result page) and try again.', 'chess-podium')),
            'import_cr_no_file' => array('error', __('Select an Excel (.xlsx) file to upload.', 'chess-podium')),
            'import_cr_no_round' => array('error', __('Select the round to import into.', 'chess-podium')),
            'import_cr_no_pairings' => array('error', __('No pairings found for this round.', 'chess-podium')),
            'import_cr_no_results_column' => array('error', __('Excel file has no Result/Risultato column. Use the chess-results Excel export.', 'chess-podium')),
            'import_cr_empty_file' => array('error', __('Excel file is empty or invalid.', 'chess-podium')),
            'publish_success' => array('success', __('Tournament published successfully.', 'chess-podium')),
            'publish_error' => array('error', __('Publishing error.', 'chess-podium')),
            'photo_added' => array('success', __('Photo added to gallery.', 'chess-podium')),
            'photo_deleted' => array('success', __('Photo removed.', 'chess-podium')),
            'photo_error' => array('error', __('Error: select a tournament.', 'chess-podium')),
            'photo_upload_error' => array('error', __('Photo upload error.', 'chess-podium')),
            'pgn_added' => array('success', __('PGN game added.', 'chess-podium')),
            'pgn_deleted' => array('success', __('PGN game removed.', 'chess-podium')),
            'pgn_round_updated' => array('success', __('Round updated.', 'chess-podium')),
            'pgn_rounds_updated' => array('success', __('Rounds updated.', 'chess-podium')),
            'pgn_all_deleted' => array('success', __('All PGN games removed.', 'chess-podium')),
            'pgn_error' => array('error', __('Error: enter valid PGN content or upload a file.', 'chess-podium')),
            'delete_tournament_success' => array('success', __('Tournament permanently deleted.', 'chess-podium')),
            'delete_tournament_error' => array('error', __('Unable to delete tournament.', 'chess-podium')),
            'update_name_success' => array('success', __('Tournament name updated.', 'chess-podium')),
            'update_name_empty' => array('error', __('Tournament name cannot be empty.', 'chess-podium')),
            'update_name_error' => array('error', __('Unable to update tournament name.', 'chess-podium')),
            'update_rating_settings_success' => array('success', __('Rating system and country updated.', 'chess-podium')),
            'update_fair_play_success' => array('success', __('Fair play settings saved.', 'chess-podium')),
            'update_settings_error' => array('error', __('Unable to update settings.', 'chess-podium')),
            'update_tiebreakers_success' => array('success', __('Tiebreakers updated. Standings will use the new order.', 'chess-podium')),
            'update_tiebreakers_error' => array('error', __('Unable to update tiebreakers.', 'chess-podium')),
            'update_rounds_success' => array('success', __('Number of rounds updated.', 'chess-podium')),
            'update_rounds_error' => array('error', __('Unable to update rounds. The new value cannot be less than the current round.', 'chess-podium')),
            'update_type_success' => array('success', __('Tournament type updated.', 'chess-podium')),
            'update_type_error' => array('error', __('Unable to update tournament type.', 'chess-podium')),
            'update_pairing_engine_success' => array('success', __('Pairing engine updated.', 'chess-podium')),
            'update_round1_pairing_success' => array('success', __('Round 1 pairing mode saved.', 'chess-podium')),
            'update_round2_tiebreak_success' => array('success', __('Round 2+ tiebreak saved.', 'chess-podium')),
            'update_pairing_acceleration_success' => array('success', __('Pairing acceleration saved.', 'chess-podium')),
            'update_team_settings_success' => array('success', __('Team settings saved.', 'chess-podium')),
            'budget_added' => array('success', __('Budget entry added.', 'chess-podium')),
            'budget_deleted' => array('success', __('Budget entry deleted.', 'chess-podium')),
            'budget_error' => array('error', __('Unable to add budget entry.', 'chess-podium')),
            'hotel_added' => array('success', __('Partner hotel added.', 'chess-podium')),
            'hotel_deleted' => array('success', __('Partner hotel deleted.', 'chess-podium')),
            'hotel_error' => array('error', __('Unable to save partner hotel.', 'chess-podium')),
            'hotel_stays_saved' => array('success', __('Hotel stays saved.', 'chess-podium')),
            'hotel_stays_error' => array('error', __('Unable to save hotel stays.', 'chess-podium')),
            'registration_settings_saved' => array('success', __('Registration settings saved.', 'chess-podium')),
            'status_save_success' => array('success', __('Player statuses saved.', 'chess-podium')),
            'status_save_error' => array('error', __('Unable to save player statuses.', 'chess-podium')),
            'brochure_saved' => array('success', __('Tournament brochure saved.', 'chess-podium')),
            'event_schedule_saved' => array('success', __('Event dates and calendar visibility saved.', 'chess-podium')),
            'brochure_error' => array('error', __('Unable to save brochure.', 'chess-podium')),
            'pgn_source_added' => array('success', __('PGN live source added. Fetch runs on page load and via cron.', 'chess-podium')),
            'pgn_source_deleted' => array('success', __('PGN source removed.', 'chess-podium')),
            'pgn_fetched' => array('success', __('PGN fetched. Live boards updated.', 'chess-podium')),
            'pgn_fetch_done' => array('success', __('PGN fetch completed.', 'chess-podium')),
            'pgn_source_error' => array('error', __('Enter a valid URL for PGN source.', 'chess-podium')),
            'tournament_gp_saved' => array('success', __('Grand Prix association saved. Points and social ELO will be included when the tournament has results.', 'chess-podium')),
            'cp_mail_queued' => array('success', __('Emails queued for background delivery.', 'chess-podium')),
            'cp_mail_error' => array('error', __('Could not queue tournament emails.', 'chess-podium')),
            'cp_mail_test_sent' => array('success', __('Test email sent.', 'chess-podium')),
            'cp_mail_test_error' => array('error', __('Could not send the test email.', 'chess-podium')),
            'cp_extra_success' => array('success', __('Recipient list updated.', 'chess-podium')),
            'cp_extra_error' => array('error', __('Could not update the recipient list.', 'chess-podium')),
            'cp_team_ok' => array('success', __('Teams updated.', 'chess-podium')),
            'cp_team_error' => array('error', __('Could not update teams.', 'chess-podium')),
        );

        if (!isset($map[$code])) {
            return;
        }

        [$type, $text] = $map[$code];
        if ($code === 'bulk_import_success' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of players */ _n('%d player imported successfully.', '%d players imported successfully.', $importedCount, 'chess-podium'), $importedCount);
        }
        if ($code === 'excel_import_success' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of players */ __('Tournament created from Excel with %d players.', 'chess-podium'), $importedCount);
        }
        if ($code === 'bulk_update_success' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of players */ _n('%d player updated successfully.', '%d players updated successfully.', $importedCount, 'chess-podium'), $importedCount);
        }
        if ($code === 'pgn_added' && $importedCount > 1) {
            $text = sprintf(/* translators: %d: number of games */ __('%d PGN games imported.', 'chess-podium'), $importedCount);
        }
        if ($code === 'pgn_all_deleted' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of games */ _n('%d PGN game removed.', '%d PGN games removed.', $importedCount, 'chess-podium'), $importedCount);
        }
        if ($code === 'pgn_rounds_updated' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of games */ _n('%d round updated.', '%d rounds updated.', $importedCount, 'chess-podium'), $importedCount);
        }
        if ($code === 'photo_added' && $importedCount > 1) {
            $text = sprintf(/* translators: %d: number of photos */ __('%d photos added to gallery.', 'chess-podium'), $importedCount);
        }
        if ($code === 'import_cr_success' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of results */ _n('%d result imported from chess-results.com.', '%d results imported from chess-results.com.', $importedCount, 'chess-podium'), $importedCount);
        }
        if ($code === 'lichess_import_success' && $importedCount > 0) {
            $text = sprintf(/* translators: %d: number of games in the PGN */ __('Next round imported from Lichess PGN (%d games).', 'chess-podium'), $importedCount);
        }
        if ($code === 'lichess_import_error') {
            $detailRaw = isset($_GET['cp_lichess_detail']) ? rawurldecode((string) wp_unslash($_GET['cp_lichess_detail'])) : '';
            $detailRaw = wp_strip_all_tags($detailRaw);
            if ($detailRaw !== '') {
                $text .= ' ' . $detailRaw;
            }
        }
        if ($code === 'cp_mail_error' || $code === 'cp_mail_test_error') {
            $err = get_transient('chess_podium_cp_mail_err_' . get_current_user_id());
            if (is_string($err) && $err !== '') {
                delete_transient('chess_podium_cp_mail_err_' . get_current_user_id());
                $text .= ' ' . esc_html($err);
            }
        }
        if ($code === 'cp_extra_success' || $code === 'cp_extra_error') {
            $extraMsg = get_transient('chess_podium_cp_extra_msg_' . get_current_user_id());
            if (is_string($extraMsg) && $extraMsg !== '') {
                delete_transient('chess_podium_cp_extra_msg_' . get_current_user_id());
                $text .= ' ' . esc_html($extraMsg);
            }
        }
        if ($code === 'cp_team_ok' || $code === 'cp_team_error') {
            $teamMsg = get_transient('chess_podium_cp_team_msg_' . get_current_user_id());
            if (is_string($teamMsg) && $teamMsg !== '') {
                delete_transient('chess_podium_cp_team_msg_' . get_current_user_id());
                $text .= ' ' . esc_html($teamMsg);
            }
        }
        if ($code === 'cp_mail_queued' && $importedCount > 0) {
            $text = sprintf(
                /* translators: %d: number of recipients */
                __('Queued %d emails for background delivery.', 'chess-podium'),
                $importedCount
            );
        }
        if ($code === 'generate_success') {
            $source = isset($_GET['cp_pairing_source']) ? sanitize_key(wp_unslash($_GET['cp_pairing_source'])) : false;
            if ($source === false) {
                $tid = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
                $source = $tid > 0 ? get_transient('chess_podium_pairing_source_' . $tid) : false;
            }
            $engineLabel = '';
            if ($source === 'bbp_api') {
                $engineLabel = __('bbpPairings via API', 'chess-podium');
            } elseif ($source === 'bbp_local') {
                $engineLabel = __('bbpPairings local', 'chess-podium');
            } elseif ($source === 'javafo_api') {
                $engineLabel = __('JaVaFo via API', 'chess-podium');
            } elseif ($source === 'fide_accel_round1') {
                $engineLabel = __('Accelerated (FIDE C.04.7) — Round 1', 'chess-podium');
            } elseif ($source === 'swiss_manager_round1') {
                $engineLabel = __('Split (Swiss-Manager style) — Round 1', 'chess-podium');
            } elseif ($source === 'builtin') {
                $engineLabel = __('built-in engine', 'chess-podium');
            } elseif ($source === 'lichess_pgn') {
                $engineLabel = __('Lichess PGN import', 'chess-podium');
            }
            if ($engineLabel !== '') {
                $text = sprintf(/* translators: %s: pairing engine name */ __('Round generated successfully with %s.', 'chess-podium'), $engineLabel);
            } else {
                $text .= ' ' . __('(engine unknown—check Pairing settings)', 'chess-podium');
            }
            $tid = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
            if ($tid > 0 && $source !== false && get_transient('chess_podium_pairing_source_' . $tid) !== false) {
                delete_transient('chess_podium_pairing_source_' . $tid);
            }
        }
        $allowHtml = false;
        if ($code === 'publish_success' && isset($_GET['cp_publish_url'])) {
            $url = esc_url_raw(wp_unslash($_GET['cp_publish_url']));
            if ($url !== '') {
                $text = sprintf(
                    /* translators: %s: link to published page */
                    __('Tournament published. <a href="%s" target="_blank">Open published page</a>', 'chess-podium'),
                    esc_url($url)
                );
                $allowHtml = true;
            }
        }
        echo '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p>' . ($allowHtml ? wp_kses_post($text) : esc_html($text)) . '</p></div>';
    }

    public static function compute_standings(int $tournamentId): array
    {
        $key = self::cache_key($tournamentId, 'standings');
        $cached = self::cache_get($key);
        if (is_array($cached)) {
            return $cached;
        }
        $tournament = self::get_tournament($tournamentId);
        $byePoints = ($tournament && isset($tournament->bye_points)) ? (float) $tournament->bye_points : 1.0;
        $pointsSystem = ($tournament && isset($tournament->points_system)) ? strtolower((string) $tournament->points_system) : 'standard';
        if ($pointsSystem === '310') {
            $byePoints = $byePoints * 3.0;
        }
        $players = self::get_players($tournamentId);
        $pairings = self::get_pairings($tournamentId);

        $scores = [];
        $opponents = [];
        $resultsVsOpponent = [];
        $byePointsByPlayer = [];
        $withdrawnZeroByeCount = [];

        $scale = ($pointsSystem === '310') ? 3.0 : 1.0;
        $winCount = [];
        $bwgCount = [];
        foreach ($players as $p) {
            $pid = (int) $p->id;
            $scores[$pid] = 0.0;
            $opponents[$pid] = [];
            $resultsVsOpponent[$pid] = [];
            $byePointsByPlayer[$pid] = [];
            $withdrawnZeroByeCount[$pid] = 0;
            $winCount[$pid] = 0;
            $bwgCount[$pid] = 0;
        }

        foreach ($pairings as $match) {
            $w = (int) $match->white_player_id;
            $b = (int) $match->black_player_id;
            $res = self::normalize_result((string) ($match->result ?? ''));
            $isBye = (int) $match->is_bye === 1;

            if ($isBye) {
                $pts = isset($match->bye_points) && $match->bye_points !== null ? (float) $match->bye_points : $byePoints;
                if ($pointsSystem === '310' && isset($match->bye_points) && $match->bye_points !== null) {
                    $pts *= 3.0;
                }
                $scores[$w] += $pts;
                $byePointsByPlayer[$w][] = $pts;
                // Unplayed rounds after withdrawal: treat zero-point byes as draws for opponent tie-break purposes.
                if ($res === 'NOT_PAIRED') {
                    $withdrawnZeroByeCount[$w] = ($withdrawnZeroByeCount[$w] ?? 0) + 1;
                }
                if ($pts >= $scale - 0.01) {
                    $winCount[$w] = ($winCount[$w] ?? 0) + 1;
                }
                continue;
            }

            $opponents[$w][] = $b;
            $opponents[$b][] = $w;

            $wPts = self::get_points_for_result($res, true, $pointsSystem);
            $bPts = self::get_points_for_result($res, false, $pointsSystem);
            $scores[$w] += $wPts;
            $scores[$b] += $bPts;
            $resultsVsOpponent[$w][$b] = $wPts;
            $resultsVsOpponent[$b][$w] = $bPts;

            if ($wPts >= $scale - 0.01) {
                $winCount[$w] = ($winCount[$w] ?? 0) + 1;
            }
            if ($bPts >= $scale - 0.01) {
                $winCount[$b] = ($winCount[$b] ?? 0) + 1;
                $bwgCount[$b] = ($bwgCount[$b] ?? 0) + 1;
            }
        }

        // For Swiss tie-breaks (FIDE 2023 TB rules): withdrawn/unplayed rounds (category 16.2.5) count as draws
        // when their score is used as an opponent's score (i.e. in Buchholz/SB sums).
        $drawScale = $pointsSystem === '310' ? 1.0 : 0.5;
        $scoresForOpponents = [];
        foreach ($players as $p) {
            $pid = (int) $p->id;
            $scoresForOpponents[$pid] = ($scores[$pid] ?? 0.0) + (($withdrawnZeroByeCount[$pid] ?? 0) * $drawScale);
        }

        $playerRatings = [];
        $aroMinRating = ($tournament && isset($tournament->aro_min_rating)) ? max(0, min(3000, (int) $tournament->aro_min_rating)) : 1400;
        foreach ($players as $p) {
            $r = (int) $p->rating;
            $playerRatings[(int) $p->id] = $r > 0 ? $r : $aroMinRating;
        }

        $aroCutStrongest = ($tournament && isset($tournament->aro_cut_strongest)) ? max(0, min(2, (int) $tournament->aro_cut_strongest)) : 0;
        $aroCutWeakest = ($tournament && isset($tournament->aro_cut_weakest)) ? max(0, min(2, (int) $tournament->aro_cut_weakest)) : 0;
        $buchholzCutStrongest = ($tournament && isset($tournament->buchholz_cut_strongest)) ? max(0, min(2, (int) $tournament->buchholz_cut_strongest)) : 0;
        $buchholzCutWeakest = ($tournament && isset($tournament->buchholz_cut_weakest)) ? max(0, min(2, (int) $tournament->buchholz_cut_weakest)) : 0;
        $sbCutStrongest = ($tournament && isset($tournament->sb_cut_strongest)) ? max(0, min(2, (int) $tournament->sb_cut_strongest)) : 0;
        $sbCutWeakest = ($tournament && isset($tournament->sb_cut_weakest)) ? max(0, min(2, (int) $tournament->sb_cut_weakest)) : 0;

        $startingRanks = self::get_starting_ranks($tournamentId);
        $playersById = [];
        foreach ($players as $p) {
            $playersById[(int) $p->id] = $p;
        }
        $rows = [];
        foreach ($players as $p) {
            $pid = (int) $p->id;
            $oppScores = [];
            $oppSbContributions = [];
            $oppRatingsForAro = [];
            foreach ($opponents[$pid] as $opp) {
                $oppScore = $scoresForOpponents[$opp] ?? ($scores[$opp] ?? 0.0);
                $oppScores[] = $oppScore;
                $gameScore = $resultsVsOpponent[$pid][$opp] ?? 0.0;
                $oppSbContributions[] = ['score' => $oppScore, 'contrib' => $oppScore * $gameScore];
                $oppRatingsForAro[] = $playerRatings[$opp] ?? $aroMinRating;
            }
            // Unplayed rounds for this participant: dummy opponents with score = participant final score.
            $dummyOppScore = (float) ($scores[$pid] ?? 0.0);
            $byePtsList = $byePointsByPlayer[$pid] ?? [];
            $oppScoresWithDummies = $oppScores;
            foreach ($byePtsList as $bp) {
                $oppScoresWithDummies[] = $dummyOppScore;
                $oppSbContributions[] = ['score' => $dummyOppScore, 'contrib' => $dummyOppScore * (float) $bp];
            }

            $buchholz = self::apply_cut_sum($oppScoresWithDummies, $buchholzCutStrongest, $buchholzCutWeakest);
            $sbContribs = array_column($oppSbContributions, 'contrib');
            $sbScores = array_column($oppSbContributions, 'score');
            $sb = self::apply_cut_sum_weighted($sbContribs, $sbScores, $sbCutStrongest, $sbCutWeakest);

            $buchholz1 = 0.0;
            if (count($oppScoresWithDummies) > 1) {
                $sorted = $oppScoresWithDummies;
                sort($sorted);
                array_shift($sorted);
                $buchholz1 = array_sum($sorted);
            }
            $aro = 0.0;
            if (count($oppRatingsForAro) > 0) {
                rsort($oppRatingsForAro);
                for ($i = 0; $i < $aroCutStrongest && count($oppRatingsForAro) > 0; $i++) {
                    array_shift($oppRatingsForAro);
                }
                for ($i = 0; $i < $aroCutWeakest && count($oppRatingsForAro) > 0; $i++) {
                    array_pop($oppRatingsForAro);
                }
                $aro = count($oppRatingsForAro) > 0 ? array_sum($oppRatingsForAro) / count($oppRatingsForAro) : 0.0;
            }

            $ks = 0.0;
            $roundsTotal = ($tournament && isset($tournament->rounds_total)) ? max(1, (int) $tournament->rounds_total) : 1;
            $koyaThreshold = 0.5 * $roundsTotal * $scale;
            foreach ($opponents[$pid] as $opp) {
                $oppScore = $scores[$opp] ?? 0.0;
                if ($oppScore >= $koyaThreshold - 0.01) {
                    $ks += $resultsVsOpponent[$pid][$opp] ?? 0.0;
                }
            }

            $normGames = class_exists('ChessPodium_FideNorm') ? ChessPodium_FideNorm::build_norm_games($pid, $pairings, $playersById, $resultsVsOpponent, $pointsSystem) : [];
            $normScale = $pointsSystem === '310' ? 3.0 : 1.0;
            $norms = class_exists('ChessPodium_FideNorm') ? ChessPodium_FideNorm::check_norms($normGames, (string) ($p->country ?? ''), $normScale) : ['gm_norm' => false, 'im_norm' => false];

            $rep = count($opponents[$pid] ?? []);

            $rows[] = [
                'player_id' => $pid,
                'name' => $p->name,
                'title' => isset($p->title) && $p->title !== '' ? (string) $p->title : null,
                'country' => isset($p->country) ? (string) $p->country : '',
                'fide_id' => isset($p->fide_id) ? (string) $p->fide_id : '',
                'uscf_id' => isset($p->uscf_id) ? (string) $p->uscf_id : '',
                'ecf_id' => isset($p->ecf_id) ? (string) $p->ecf_id : '',
                'rating' => (int) $p->rating,
                'points' => $scores[$pid] ?? 0.0,
                'buchholz' => $buchholz,
                'buchholz1' => $buchholz1,
                'sb' => $sb,
                'aro' => $aro,
                'win' => $winCount[$pid] ?? 0,
                'bwg' => $bwgCount[$pid] ?? 0,
                'ks' => $ks,
                'rep' => $rep,
                'start_rank' => $startingRanks[$pid] ?? 9999,
                'gm_norm' => !empty($norms['gm_norm']),
                'im_norm' => !empty($norms['im_norm']),
            ];
        }

        $tiebreakers = ($tournament && isset($tournament->tiebreakers)) ? explode(',', (string) $tournament->tiebreakers) : ['buchholz', 'sb', 'rating'];
        $tiebreakers = array_map('trim', array_map('strtolower', $tiebreakers));
        $ratingSystem = ($tournament && isset($tournament->rating_system)) ? strtolower((string) $tournament->rating_system) : 'fide';
        $directIdx = array_search('direct', $tiebreakers, true);
        $floatKeys = ['points', 'buchholz', 'buchholz1', 'sb', 'aro', 'ks'];

        $tbValue = static function (array $row, string $tb) use ($ratingSystem, $floatKeys) {
            if ($tb === 'rating') {
                $r = (int) ($row['rating'] ?? 0);
                if ($ratingSystem === 'uscf') {
                    return !empty($row['uscf_id']) ? $r : 0;
                }
                if ($ratingSystem === 'ecf') {
                    return !empty($row['ecf_id']) ? $r : 0;
                }
                return $r;
            }

            $val = $row[$tb] ?? 0;
            if (in_array($tb, $floatKeys, true)) {
                return round((float) $val, 2);
            }
            return $val;
        };

        $compareByTiebreakers = static function (array $a, array $b, array $tbs) use ($tbValue) {
            foreach (array_merge(['points'], $tbs) as $tb) {
                $aV = $tbValue($a, $tb);
                $bV = $tbValue($b, $tb);
                if ($bV !== $aV) {
                    return $bV <=> $aV; // DESC for all supported keys (bigger wins)
                }
            }
            return ($a['start_rank'] ?? PHP_INT_MAX) <=> ($b['start_rank'] ?? PHP_INT_MAX);
        };

        // If DE is not configured, keep the old single-pass ordering.
        if ($directIdx === false) {
            $tbsNoDirect = $tiebreakers;
            usort($rows, static function ($a, $b) use ($compareByTiebreakers, $tbsNoDirect) {
                return $compareByTiebreakers($a, $b, $tbsNoDirect);
            });
            self::cache_set($key, $rows, 120);
            return $rows;
        }

        // Multi-pass DE:
        // 1) sort by points + all tiebreakers except 'direct'
        // 2) within each group where (points + pre-direct tiebreakers) are equal, compute mini-league DE and reorder
        $tbsNoDirect = array_values(array_filter($tiebreakers, static fn($t) => $t !== 'direct'));
        usort($rows, static function ($a, $b) use ($compareByTiebreakers, $tbsNoDirect) {
            return $compareByTiebreakers($a, $b, $tbsNoDirect);
        });

        $preDirectTbs = array_slice($tiebreakers, 0, $directIdx);
        $postDirectTbs = array_slice($tiebreakers, $directIdx + 1);

        $rowsFinal = [];
        $n = count($rows);
        $i = 0;
        while ($i < $n) {
            $base = $rows[$i];
            $j = $i + 1;
            while ($j < $n) {
                $other = $rows[$j];
                // Grouping key: points + all tiebreakers before 'direct'
                if ($tbValue($other, 'points') !== $tbValue($base, 'points')) {
                    break;
                }
                $same = true;
                foreach ($preDirectTbs as $tb) {
                    if ($tbValue($other, $tb) !== $tbValue($base, $tb)) {
                        $same = false;
                        break;
                    }
                }
                if (!$same) {
                    break;
                }
                $j++;
            }

            $group = array_slice($rows, $i, $j - $i);
            if (count($group) <= 1) {
                $rowsFinal = array_merge($rowsFinal, $group);
                $i = $j;
                continue;
            }

            // Compute Direct Encounter (DE) as sum of head-to-head results within the tied group.
            $groupIds = array_map(static fn($r) => (int) ($r['player_id'] ?? 0), $group);

            $directScores = [];
            foreach ($group as $r) {
                $pid = (int) ($r['player_id'] ?? 0);
                $de = 0.0;
                foreach ($groupIds as $oppPid) {
                    if ($oppPid === $pid) {
                        continue;
                    }
                    // resultsVsOpponent values are already scaled to match pointsSystem.
                    $de += $resultsVsOpponent[$pid][$oppPid] ?? 0.0;
                }
                $directScores[$pid] = round((float) $de, 2);
            }

            foreach ($group as &$r) {
                $pid = (int) ($r['player_id'] ?? 0);
                $r['direct_score'] = $directScores[$pid] ?? 0.0;
            }
            unset($r);

            // Reorder group: DE, then post-direct tiebreakers (if any), then start_rank.
            usort($group, static function (array $a, array $b) use ($postDirectTbs, $tbValue) {
                $aDe = round((float) ($a['direct_score'] ?? 0.0), 2);
                $bDe = round((float) ($b['direct_score'] ?? 0.0), 2);
                if ($bDe !== $aDe) {
                    return $bDe <=> $aDe; // higher DE wins
                }

                foreach ($postDirectTbs as $tb) {
                    $aV = $tbValue($a, $tb);
                    $bV = $tbValue($b, $tb);
                    if ($bV !== $aV) {
                        return $bV <=> $aV;
                    }
                }

                return ($a['start_rank'] ?? PHP_INT_MAX) <=> ($b['start_rank'] ?? PHP_INT_MAX);
            });

            $rowsFinal = array_merge($rowsFinal, $group);
            $i = $j;
        }

        self::cache_set($key, $rowsFinal, 120);
        return $rowsFinal;
    }

    public static function handle_export_requests(): void
    {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }

        if (empty($_GET['page']) || sanitize_text_field(wp_unslash($_GET['page'])) !== self::MENU_SLUG) {
            return;
        }
        if (empty($_GET['regina_export'])) {
            return;
        }

        $exportType = sanitize_key(wp_unslash($_GET['regina_export']));
        $tournamentId = isset($_GET['tournament_id']) ? (int) $_GET['tournament_id'] : 0;
        $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';

        if ($tournamentId <= 0 || !in_array($exportType, array('turns', 'standings', 'trf', 'uscf', 'ecf', 'brochure_pdf', 'brochure_html', 'certificates_winners', 'certificates_participation', 'badges_pdf', 'badges_html', 'placecards_pdf', 'placecards_html', 'boardcards_pdf', 'boardcards_html', 'hotel_report_csv', 'hotel_report_pdf', 'hotel_report_html'), true)) {
            wp_die('Richiesta export non valida.');
        }
        if (!wp_verify_nonce($nonce, 'regina_export_' . $tournamentId . '_' . $exportType)) {
            wp_die('Nonce non valido.');
        }

        $tournament = self::get_tournament($tournamentId);
        if (!$tournament) {
            wp_die('Torneo non trovato.');
        }

        if ($exportType === 'turns') {
            self::output_turns_csv($tournament);
            return;
        }

        if ($exportType === 'trf') {
            self::output_trf($tournament);
            return;
        }

        if ($exportType === 'uscf') {
            self::output_uscf($tournament);
            return;
        }

        if ($exportType === 'ecf') {
            self::output_ecf($tournament);
            return;
        }

        if ($exportType === 'brochure_pdf' || $exportType === 'brochure_html') {
            self::output_brochure($tournament, $exportType === 'brochure_pdf');
            return;
        }

        if ($exportType === 'hotel_report_csv') {
            self::output_hotel_report_csv($tournament);
            return;
        }

        if ($exportType === 'hotel_report_pdf' || $exportType === 'hotel_report_html') {
            self::output_hotel_report($tournament, $exportType === 'hotel_report_pdf');
            return;
        }

        if ($exportType === 'badges_pdf' || $exportType === 'badges_html') {
            self::output_badges($tournament, $exportType === 'badges_pdf');
            return;
        }

        if ($exportType === 'placecards_pdf' || $exportType === 'placecards_html') {
            self::output_placecards($tournament, $exportType === 'placecards_pdf');
            return;
        }

        if ($exportType === 'boardcards_pdf' || $exportType === 'boardcards_html') {
            self::output_boardcards($tournament, $exportType === 'boardcards_pdf');
            return;
        }

        if ($exportType === 'certificates_winners' || $exportType === 'certificates_participation') {
            self::output_certificates($tournament, $exportType === 'certificates_winners');
            return;
        }

        self::output_standings_csv($tournament);
    }

    private static function build_export_url(int $tournamentId, string $exportType): string
    {
        $url = add_query_arg(
            array(
                'page' => self::MENU_SLUG,
                'tournament_id' => $tournamentId,
                'regina_export' => $exportType,
            ),
            admin_url('admin.php')
        );

        return wp_nonce_url($url, 'regina_export_' . $tournamentId . '_' . $exportType);
    }

    private static function output_turns_csv(object $tournament): void
    {
        $rows = self::get_pairings_detailed((int) $tournament->id);
        $filename = 'chess-podium-turns-' . sanitize_title((string) $tournament->name) . '.csv';

        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        $out = fopen('php://output', 'w');
        if ($out === false) {
            exit;
        }

        fputcsv($out, array('round', 'board', 'white', 'black', 'result', 'is_bye'));
        $boardByRound = array();
        foreach ($rows as $row) {
            $roundNo = (int) $row->round_no;
            if (!isset($boardByRound[$roundNo])) {
                $boardByRound[$roundNo] = 0;
            }
            $boardByRound[$roundNo]++;
            fputcsv($out, array(
                $roundNo,
                $boardByRound[$roundNo],
                (string) $row->white_name,
                (int) $row->is_bye === 1 ? '' : (string) $row->black_name,
                (string) ($row->result ?? ''),
                (int) $row->is_bye,
            ));
        }

        fclose($out);
        exit;
    }

    private static function output_brochure(object $tournament, bool $asPdf): void
    {
        if ($asPdf) {
            $customUrl = ChessPodium_PdfBrochure::get_meta((int) $tournament->id, 'custom_brochure_pdf_url');
            if (!empty($customUrl)) {
                $uploadDir = wp_upload_dir();
                $contentUrl = $uploadDir['baseurl'] . '/';
                $contentPath = $uploadDir['basedir'] . '/';
                if (strpos($customUrl, $contentUrl) === 0) {
                    $relative = substr($customUrl, strlen($contentUrl));
                    $localPath = $contentPath . $relative;
                    if (file_exists($localPath) && is_readable($localPath)) {
                        $pdf = file_get_contents($localPath);
                        if ($pdf !== false && strlen($pdf) > 0) {
                            $filename = 'bando-' . sanitize_title((string) $tournament->name) . '.pdf';
                            header('Content-Type: application/pdf');
                            header('Content-Disposition: attachment; filename="' . $filename . '"');
                            header('Pragma: no-cache');
                            header('Expires: 0');
                            header('Content-Length: ' . strlen($pdf));
                            echo $pdf;
                            exit;
                        }
                    }
                }
                wp_safe_redirect($customUrl);
                exit;
            }
            $pdf = ChessPodium_PdfBrochure::generate_pdf($tournament);
            if ($pdf && strlen($pdf) > 0) {
                $filename = 'bando-' . sanitize_title((string) $tournament->name) . '.pdf';
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="' . $filename . '"');
                header('Pragma: no-cache');
                header('Expires: 0');
                header('Content-Length: ' . strlen($pdf));
                echo $pdf;
                exit;
            }
        }
        $html = ChessPodium_PdfBrochure::generate_html($tournament);
        if ($asPdf) {
            $notice = '<div style="background:#fff3cd;border:1px solid #ffc107;padding:12px;margin-bottom:20px;border-radius:4px;"><strong>' . esc_html__('PDF not available', 'chess-podium') . '</strong> ' . esc_html__('Run "composer install" in the plugin directory to enable PDF download. HTML file provided instead.', 'chess-podium') . '</div>';
            $html = preg_replace('/(<body[^>]*>)/', '$1' . $notice, $html, 1);
        }
        $filename = 'bando-' . sanitize_title((string) $tournament->name) . '.html';
        header('Content-Type: text/html; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        echo $html;
        exit;
    }

    private static function build_hotel_report_html(object $tournament): string
    {
        $tid = (int) ($tournament->id ?? 0);
        $partnerHotels = self::get_partner_hotels($tid);
        $players = self::get_players($tid);
        $staysByPlayer = self::get_player_hotel_stays_indexed($tid);
        $rateById = [];
        $hotelById = [];
        foreach ($partnerHotels as $h) {
            $hid = (int) ($h->id ?? 0);
            if ($hid > 0) {
                $hotelById[$hid] = $h;
                $rateById[$hid] = (float) ($h->rate_per_presence_per_day ?? 0);
            }
        }

        $incomeByHotel = [];
        $totalIncome = 0.0;
        foreach ($players as $p) {
            $pid = (int) ($p->id ?? 0);
            $stay = $staysByPlayer[$pid] ?? null;
            if (!$stay) {
                continue;
            }
            $hid = (int) ($stay->hotel_id ?? 0);
            if ($hid <= 0) {
                continue;
            }
            $share = (float) ($stay->share_amount ?? 0);
            $incomeByHotel[$hid] = ($incomeByHotel[$hid] ?? 0.0) + $share;
            $totalIncome += $share;
        }

        $logoUrl = '';
        if (class_exists('ChessPodium_PdfBrochure')) {
            $logoUrl = (string) (ChessPodium_PdfBrochure::get_meta($tid, 'brochure_logo') ?: '');
        }
        if ($logoUrl === '') {
            $logoUrl = self::get_default_logo_url();
        }

        ob_start();
        ?>
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <title><?php echo esc_html((string) $tournament->name); ?> - <?php esc_html_e('Partner hotels report', 'chess-podium'); ?></title>
            <style>
                @page { margin: 1.5cm; }
                body { font-family: "DejaVu Sans", Helvetica, Arial, sans-serif; font-size: 10pt; line-height: 1.4; color: #1f2937; }
                .hdr { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 2px solid #e5e7eb; margin-bottom: 14px; }
                .logo { max-height: 46px; max-width: 140px; }
                h1 { font-size: 16pt; margin: 0; }
                h2 { font-size: 12pt; margin: 16px 0 8px; }
                .meta { color: #6b7280; font-size: 9pt; margin-top: 2px; }
                table { width: 100%; border-collapse: collapse; }
                th, td { border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; }
                th { background: #111827; color: #fff; font-weight: 700; }
                tr:nth-child(even) td { background: #f9fafb; }
                .right { text-align: right; }
                .small { font-size: 9pt; color: #4b5563; }
            </style>
        </head>
        <body>
            <div class="hdr">
                <?php if ($logoUrl !== ''): ?><img class="logo" src="<?php echo esc_url($logoUrl); ?>" alt=""><?php endif; ?>
                <div>
                    <h1><?php echo esc_html((string) $tournament->name); ?> — <?php esc_html_e('Partner hotels report', 'chess-podium'); ?></h1>
                    <div class="meta"><?php echo esc_html(date_i18n('Y-m-d H:i')); ?></div>
                </div>
            </div>

            <h2><?php esc_html_e('Income by hotel', 'chess-podium'); ?></h2>
            <?php if (empty($partnerHotels)): ?>
                <p class="small"><?php esc_html_e('No partner hotels.', 'chess-podium'); ?></p>
            <?php else: ?>
                <table>
                    <thead>
                        <tr>
                            <th><?php esc_html_e('Hotel', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Rate (€/presence/day)', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Tournament share (€)', 'chess-podium'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php foreach ($partnerHotels as $h): ?>
                            <?php $hid = (int) ($h->id ?? 0); ?>
                            <tr>
                                <td><?php echo esc_html($h->name ?? ''); ?><?php if (!empty($h->stars)): ?> (<?php echo (int) $h->stars; ?>★)<?php endif; ?><?php if (!empty($h->city)): ?> — <?php echo esc_html($h->city); ?><?php endif; ?></td>
                                <td class="right"><?php echo esc_html(number_format((float) ($h->rate_per_presence_per_day ?? 0), 2)); ?></td>
                                <td class="right"><?php echo esc_html(number_format((float) ($incomeByHotel[$hid] ?? 0.0), 2)); ?></td>
                            </tr>
                        <?php endforeach; ?>
                    </tbody>
                    <tfoot>
                        <tr>
                            <th colspan="2" class="right"><?php esc_html_e('Total', 'chess-podium'); ?></th>
                            <th class="right"><?php echo esc_html(number_format($totalIncome, 2)); ?></th>
                        </tr>
                    </tfoot>
                </table>
            <?php endif; ?>

            <h2><?php esc_html_e('Player stays', 'chess-podium'); ?></h2>
            <?php if (empty($players)): ?>
                <p class="small"><?php esc_html_e('No players.', 'chess-podium'); ?></p>
            <?php else: ?>
                <table>
                    <thead>
                        <tr>
                            <th><?php esc_html_e('Player', 'chess-podium'); ?></th>
                            <th><?php esc_html_e('Hotel', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Family', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Days', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Presences', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Rate', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Hotel total (€)', 'chess-podium'); ?></th>
                            <th class="right"><?php esc_html_e('Tournament share (€)', 'chess-podium'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php foreach ($players as $p): ?>
                            <?php
                            $pid = (int) ($p->id ?? 0);
                            $stay = $staysByPlayer[$pid] ?? null;
                            $hid = $stay ? (int) ($stay->hotel_id ?? 0) : 0;
                            if ($hid <= 0) {
                                continue;
                            }
                            $fam = $stay ? (int) ($stay->family_members ?? 0) : 0;
                            $days = $stay ? (int) ($stay->days ?? 0) : 0;
                            $pres = 1 + max(0, $fam);
                            $rate = $hid > 0 ? (float) ($rateById[$hid] ?? 0.0) : 0.0;
                            $hotelName = $hid > 0 && isset($hotelById[$hid]) ? (string) ($hotelById[$hid]->name ?? '') : '';
                            ?>
                            <tr>
                                <td><?php echo esc_html($p->name ?? ''); ?></td>
                                <td><?php echo esc_html($hotelName); ?></td>
                                <td class="right"><?php echo esc_html((string) $fam); ?></td>
                                <td class="right"><?php echo esc_html((string) $days); ?></td>
                                <td class="right"><?php echo esc_html((string) $pres); ?></td>
                                <td class="right"><?php echo esc_html(number_format($rate, 2)); ?></td>
                                <td class="right"><?php echo esc_html(number_format((float) ($stay->hotel_total ?? 0), 2)); ?></td>
                                <td class="right"><?php echo esc_html(number_format((float) ($stay->share_amount ?? 0), 2)); ?></td>
                            </tr>
                        <?php endforeach; ?>
                    </tbody>
                </table>
            <?php endif; ?>
        </body>
        </html>
        <?php
        return (string) ob_get_clean();
    }

    private static function output_hotel_report(object $tournament, bool $asPdf): void
    {
        $filenameBase = 'chess-podium-hotels-' . sanitize_title((string) $tournament->name);
        if ($asPdf) {
            $html = self::build_hotel_report_html($tournament);
            $autoload = CHESS_PODIUM_PLUGIN_DIR . '/lib/dompdf/vendor/autoload.php';
            if (!file_exists($autoload)) {
                $autoload = CHESS_PODIUM_PLUGIN_DIR . '/vendor/autoload.php';
            }
            if (!file_exists($autoload)) {
                // Fallback to HTML download if PDF engine is missing.
                self::output_hotel_report($tournament, false);
                return;
            }
            require_once $autoload;
            if (!class_exists('Dompdf\\Dompdf')) {
                self::output_hotel_report($tournament, false);
                return;
            }
            $dompdf = new \Dompdf\Dompdf(['isRemoteEnabled' => true]);
            $dompdf->loadHtml($html, 'UTF-8');
            $dompdf->setPaper('A4', 'portrait');
            $dompdf->render();
            $pdf = $dompdf->output();
            $filename = $filenameBase . '.pdf';
            header('Content-Type: application/pdf');
            header('Content-Disposition: attachment; filename="' . $filename . '"');
            header('Pragma: no-cache');
            header('Expires: 0');
            header('Content-Length: ' . strlen($pdf));
            echo $pdf;
            exit;
        }

        $html = self::build_hotel_report_html($tournament);
        $filename = $filenameBase . '.html';
        header('Content-Type: text/html; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        echo $html;
        exit;
    }

    private static function output_hotel_report_csv(object $tournament): void
    {
        $tid = (int) ($tournament->id ?? 0);
        $partnerHotels = self::get_partner_hotels($tid);
        $players = self::get_players($tid);
        $staysByPlayer = self::get_player_hotel_stays_indexed($tid);

        $hotelNameById = [];
        $rateById = [];
        foreach ($partnerHotels as $h) {
            $hid = (int) ($h->id ?? 0);
            if ($hid > 0) {
                $hotelNameById[$hid] = (string) ($h->name ?? '');
                $rateById[$hid] = (float) ($h->rate_per_presence_per_day ?? 0);
            }
        }

        $filename = 'chess-podium-hotels-' . sanitize_title((string) $tournament->name) . '.csv';
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        $out = fopen('php://output', 'w');
        if ($out === false) {
            exit;
        }

        // Summary section
        fputcsv($out, ['SECTION', 'hotel_name', 'rate_per_presence_per_day', 'tournament_share_total']);
        $incomeByHotel = [];
        foreach ($players as $p) {
            $pid = (int) ($p->id ?? 0);
            $stay = $staysByPlayer[$pid] ?? null;
            if (!$stay) {
                continue;
            }
            $hid = (int) ($stay->hotel_id ?? 0);
            if ($hid <= 0) {
                continue;
            }
            $incomeByHotel[$hid] = ($incomeByHotel[$hid] ?? 0.0) + (float) ($stay->share_amount ?? 0);
        }
        foreach ($partnerHotels as $h) {
            $hid = (int) ($h->id ?? 0);
            fputcsv($out, [
                'HOTEL_SUMMARY',
                (string) ($h->name ?? ''),
                number_format((float) ($h->rate_per_presence_per_day ?? 0), 2, '.', ''),
                number_format((float) ($incomeByHotel[$hid] ?? 0.0), 2, '.', ''),
            ]);
        }

        fputcsv($out, []); // blank line between sections

        // Player stays section
        fputcsv($out, ['SECTION', 'player_name', 'hotel_name', 'family_members', 'days', 'presences', 'rate', 'hotel_total', 'tournament_share']);
        foreach ($players as $p) {
            $pid = (int) ($p->id ?? 0);
            $stay = $staysByPlayer[$pid] ?? null;
            $hid = $stay ? (int) ($stay->hotel_id ?? 0) : 0;
            if ($hid <= 0) {
                continue;
            }
            $fam = $stay ? (int) ($stay->family_members ?? 0) : 0;
            $days = $stay ? (int) ($stay->days ?? 0) : 0;
            $pres = 1 + max(0, $fam);
            $rate = $hid > 0 ? (float) ($rateById[$hid] ?? 0.0) : 0.0;
            fputcsv($out, [
                'PLAYER_STAY',
                (string) ($p->name ?? ''),
                $hid > 0 ? (string) ($hotelNameById[$hid] ?? '') : '',
                $fam,
                $days,
                $hid > 0 ? $pres : 0,
                number_format($rate, 2, '.', ''),
                number_format((float) ($stay->hotel_total ?? 0.0), 2, '.', ''),
                number_format((float) ($stay->share_amount ?? 0.0), 2, '.', ''),
            ]);
        }

        fclose($out);
        exit;
    }

    private static function output_badges(object $tournament, bool $asPdf): void
    {
        $players = self::get_players((int) $tournament->id);
        if ($asPdf) {
            $pdf = ChessPodium_PdfBadges::generate_pdf($tournament, $players, 'badges');
            if ($pdf && strlen($pdf) > 0) {
                $filename = 'badges-' . sanitize_title((string) $tournament->name) . '.pdf';
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="' . $filename . '"');
                header('Pragma: no-cache');
                header('Expires: 0');
                header('Content-Length: ' . strlen($pdf));
                echo $pdf;
                exit;
            }
        }
        $html = ChessPodium_PdfBadges::generate_badges_html($tournament, $players);
        if ($asPdf) {
            $notice = '<div style="background:#fff3cd;border:1px solid #ffc107;padding:12px;margin-bottom:20px;border-radius:4px;"><strong>' . esc_html__('PDF not available', 'chess-podium') . '</strong> ' . esc_html__('Run "composer install" in the plugin directory to enable PDF download. HTML file provided instead.', 'chess-podium') . '</div>';
            $html = preg_replace('/(<body[^>]*>)/', '$1' . $notice, $html, 1);
        }
        $filename = 'badges-' . sanitize_title((string) $tournament->name) . '.html';
        header('Content-Type: text/html; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        echo $html;
        exit;
    }

    private static function output_placecards(object $tournament, bool $asPdf): void
    {
        $players = self::get_players((int) $tournament->id);
        if ($asPdf) {
            $pdf = ChessPodium_PdfBadges::generate_pdf($tournament, $players, 'placecards');
            if ($pdf && strlen($pdf) > 0) {
                $filename = 'placecards-' . sanitize_title((string) $tournament->name) . '.pdf';
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="' . $filename . '"');
                header('Pragma: no-cache');
                header('Expires: 0');
                header('Content-Length: ' . strlen($pdf));
                echo $pdf;
                exit;
            }
        }
        $html = ChessPodium_PdfBadges::generate_placecards_html($tournament, $players);
        if ($asPdf) {
            $notice = '<div style="background:#fff3cd;border:1px solid #ffc107;padding:12px;margin-bottom:20px;border-radius:4px;"><strong>' . esc_html__('PDF not available', 'chess-podium') . '</strong> ' . esc_html__('Run "composer install" in the plugin directory to enable PDF download. HTML file provided instead.', 'chess-podium') . '</div>';
            $html = preg_replace('/(<body[^>]*>)/', '$1' . $notice, $html, 1);
        }
        $filename = 'placecards-' . sanitize_title((string) $tournament->name) . '.html';
        header('Content-Type: text/html; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        echo $html;
        exit;
    }

    private static function output_boardcards(object $tournament, bool $asPdf): void
    {
        // Board cards do not depend on players list; current round board count is derived from pairings.
        $players = [];
        if ($asPdf) {
            $pdf = ChessPodium_PdfBadges::generate_pdf($tournament, $players, 'boardcards');
            if ($pdf && strlen($pdf) > 0) {
                $filename = 'boardcards-' . sanitize_title((string) $tournament->name) . '.pdf';
                header('Content-Type: application/pdf');
                header('Content-Disposition: attachment; filename="' . $filename . '"');
                header('Pragma: no-cache');
                header('Expires: 0');
                header('Content-Length: ' . strlen($pdf));
                echo $pdf;
                exit;
            }
        }
        $html = ChessPodium_PdfBadges::generate_boardcards_html($tournament);
        if ($asPdf) {
            $notice = '<div style="background:#fff3cd;border:1px solid #ffc107;padding:12px;margin-bottom:20px;border-radius:4px;"><strong>' . esc_html__('PDF not available', 'chess-podium') . '</strong> ' . esc_html__('Run "composer install" in the plugin directory to enable PDF download. HTML file provided instead.', 'chess-podium') . '</div>';
            $html = preg_replace('/(<body[^>]*>)/', '$1' . $notice, $html, 1);
        }
        $filename = 'boardcards-' . sanitize_title((string) $tournament->name) . '.html';
        header('Content-Type: text/html; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        echo $html;
        exit;
    }

    private static function output_certificates(object $tournament, bool $winnersOnly): void
    {
        $type = $winnersOnly ? 'winners' : 'participation';
        $pdf = ChessPodium_PdfCertificate::generate_pdf((int) $tournament->id, $type);
        if (!$pdf || strlen($pdf) === 0) {
            wp_die(esc_html__('Unable to generate certificates. Ensure there are players with standings and Dompdf is available.', 'chess-podium'));
        }
        $suffix = $winnersOnly ? 'winners' : 'participation';
        $filename = 'certificates-' . $suffix . '-' . sanitize_title((string) $tournament->name) . '.pdf';
        header('Content-Type: application/pdf');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');
        header('Content-Length: ' . strlen($pdf));
        echo $pdf;
        exit;
    }

    private static function output_trf(object $tournament): void
    {
        $content = ChessPodium_TrfExport::generate_trf((int) $tournament->id);
        $filename = 'chess-podium-' . sanitize_title((string) $tournament->name) . '.trf';

        header('Content-Type: text/plain; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        echo $content;
        exit;
    }

    private static function output_uscf(object $tournament): void
    {
        $content = ChessPodium_USCF_Export::generate_th((int) $tournament->id);
        $filename = 'chess-podium-' . sanitize_title((string) $tournament->name) . '.th';

        header('Content-Type: text/plain; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        echo $content;
        exit;
    }

    private static function output_ecf(object $tournament): void
    {
        $content = ChessPodium_ECF_Export::generate_json((int) $tournament->id);
        $filename = 'chess-podium-' . sanitize_title((string) $tournament->name) . '-ecf.json';

        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        echo $content;
        exit;
    }

    private static function output_standings_csv(object $tournament): void
    {
        $standings = self::compute_standings((int) $tournament->id);
        $filename = 'chess-podium-standings-' . sanitize_title((string) $tournament->name) . '.csv';

        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        $out = fopen('php://output', 'w');
        if ($out === false) {
            exit;
        }

        fputcsv($out, array('position', 'player', 'country', 'fide_id', 'rating', 'points', 'buchholz', 'buchholz1', 'sonneborn_berger', 'aro', 'win', 'bwg', 'ks', 'gm_norm', 'im_norm'));
        foreach ($standings as $pos => $row) {
            fputcsv($out, array(
                (int) $pos + 1,
                (string) $row['name'],
                (string) ($row['country'] ?? ''),
                (string) ($row['fide_id'] ?? ''),
                (int) $row['rating'],
                round((float) $row['points'], 2),
                round((float) $row['buchholz'], 2),
                round((float) ($row['buchholz1'] ?? 0), 2),
                round((float) $row['sb'], 2),
                round((float) ($row['aro'] ?? 0), 2),
                (int) ($row['win'] ?? 0),
                (int) ($row['bwg'] ?? 0),
                round((float) ($row['ks'] ?? 0), 2),
                !empty($row['gm_norm']) ? '1' : '0',
                !empty($row['im_norm']) ? '1' : '0',
            ));
        }

        fclose($out);
        exit;
    }
}

register_activation_hook(__FILE__, ['ChessPodiumPlugin', 'activate']);
register_deactivation_hook(__FILE__, ['ChessPodiumPlugin', 'deactivate']);
ChessPodiumPlugin::init();