<?php
/**
 * GDPR-oriented service emails to registered players (batch + wp_mail).
 *
 * @package Chess_Podium
 */

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

final class ChessPodium_Tournament_Communications
{
    private const OPTION_QUEUE_PREFIX = 'chess_podium_mail_queue_';
    private const OPTION_EXTRA_RECIPIENTS_PREFIX = 'chess_podium_mail_extra_recipients_';
    private const EXTRA_RECIPIENTS_MAX = 2000;
    private const EXTRA_IMPORT_MAX_BYTES = 524288;
    private const BATCH_SIZE = 25;
    private const BATCH_DELAY = 60;
    private const LOG_TABLE = 'chess_podium_tournament_email_log';
    private const CRON_HOOK = 'chess_podium_mail_batch';

    public static function init(): void
    {
        add_action(self::CRON_HOOK, [self::class, 'run_batch'], 10, 1);
    }

    public static function create_log_table(): void
    {
        global $wpdb;
        $table = $wpdb->prefix . self::LOG_TABLE;
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table) { // phpcs:ignore
            return;
        }
        $charset = $wpdb->get_charset_collate();
        $wpdb->query("CREATE TABLE {$table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            tournament_id BIGINT UNSIGNED NOT NULL,
            sent_at DATETIME NOT NULL,
            subject VARCHAR(500) NOT NULL,
            recipients_count INT UNSIGNED NOT NULL DEFAULT 0,
            kind VARCHAR(32) NOT NULL DEFAULT '',
            PRIMARY KEY (id),
            KEY tournament_sent (tournament_id, sent_at)
        ) {$charset}"); // phpcs:ignore
    }

    public static function queue_option_key(int $tournamentId): string
    {
        return self::OPTION_QUEUE_PREFIX . $tournamentId;
    }

    public static function extra_recipients_option_key(int $tournamentId): string
    {
        return self::OPTION_EXTRA_RECIPIENTS_PREFIX . $tournamentId;
    }

    /**
     * @return list<string>
     */
    public static function get_extra_recipients(int $tournamentId): array
    {
        if ($tournamentId <= 0) {
            return [];
        }
        $raw = get_option(self::extra_recipients_option_key($tournamentId), []);
        if (!is_array($raw)) {
            return [];
        }
        $out = [];
        foreach ($raw as $e) {
            $e = sanitize_email((string) $e);
            if ($e !== '' && is_email($e)) {
                $out[strtolower($e)] = $e;
            }
        }
        ksort($out, SORT_STRING);

        return array_values($out);
    }

    /**
     * @param list<string> $emails
     */
    public static function set_extra_recipients(int $tournamentId, array $emails): void
    {
        if ($tournamentId <= 0) {
            return;
        }
        $norm = self::normalize_email_list($emails);
        $norm = array_slice($norm, 0, self::EXTRA_RECIPIENTS_MAX);
        if ($norm === []) {
            delete_option(self::extra_recipients_option_key($tournamentId));

            return;
        }
        update_option(self::extra_recipients_option_key($tournamentId), $norm, false);
    }

    /**
     * @param list<string> $emails
     * @return list<string>
     */
    public static function normalize_email_list(array $emails): array
    {
        $out = [];
        foreach ($emails as $e) {
            $e = sanitize_email(trim((string) $e));
            if ($e !== '' && is_email($e)) {
                $out[strtolower($e)] = $e;
            }
        }
        ksort($out, SORT_STRING);

        return array_values($out);
    }

    /**
     * @return list<string>
     */
    public static function extract_emails_from_blob(string $blob): array
    {
        $blob = str_replace(["\r\n", "\r"], "\n", $blob);
        $seen = [];
        foreach (explode("\n", $blob) as $line) {
            $line = trim($line);
            if ($line === '') {
                continue;
            }
            if (strpos($line, ',') !== false || strpos($line, ';') !== false) {
                foreach (str_getcsv($line) as $cell) {
                    foreach (preg_split('/\s*;\s*/', trim((string) $cell)) as $part) {
                        self::collect_email_token($seen, trim($part));
                    }
                }
            } else {
                self::collect_email_token($seen, $line);
            }
        }
        if (preg_match_all('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/', $blob, $m)) {
            foreach ($m[0] as $e) {
                self::collect_email_token($seen, $e);
            }
        }

        return array_values($seen);
    }

    /**
     * @param array<string,string> $seen lowercase => canonical email
     */
    private static function collect_email_token(array &$seen, string $raw): void
    {
        $e = sanitize_email($raw);
        if ($e !== '' && is_email($e)) {
            $seen[strtolower($e)] = $e;
        }
    }

    /**
     * @return array{emails:list<string>}|array{error:string}
     */
    public static function parse_emails_from_uploaded_file(array $file): array
    {
        if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
            return ['error' => __('No file uploaded.', 'chess-podium')];
        }
        if (!empty($file['error']) && (int) $file['error'] !== UPLOAD_ERR_OK) {
            return ['error' => __('File upload failed.', 'chess-podium')];
        }
        $size = isset($file['size']) ? (int) $file['size'] : 0;
        if ($size <= 0 || $size > self::EXTRA_IMPORT_MAX_BYTES) {
            return [
                'error' => sprintf(
                    /* translators: %s: max file size, e.g. "512 KB" */
                    __('Invalid file or file too large. Use .txt or .csv (max %s).', 'chess-podium'),
                    size_format(self::EXTRA_IMPORT_MAX_BYTES)
                ),
            ];
        }
        $name = isset($file['name']) ? strtolower((string) $file['name']) : '';
        $ext = pathinfo($name, PATHINFO_EXTENSION);
        if (!in_array($ext, ['txt', 'csv'], true)) {
            return ['error' => __('Invalid file type. Use .txt or .csv.', 'chess-podium')];
        }
        $contents = file_get_contents($file['tmp_name']); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local uploaded tmp path
        if ($contents === false || $contents === '') {
            return ['error' => __('Could not read the file.', 'chess-podium')];
        }
        if (substr($contents, 0, 3) === "\xEF\xBB\xBF") {
            $contents = substr($contents, 3);
        }
        if (strlen($contents) > self::EXTRA_IMPORT_MAX_BYTES) {
            return [
                'error' => sprintf(
                    /* translators: %s: max file size */
                    __('Invalid file or file too large. Use .txt or .csv (max %s).', 'chess-podium'),
                    size_format(self::EXTRA_IMPORT_MAX_BYTES)
                ),
            ];
        }

        return ['emails' => self::extract_emails_from_blob($contents)];
    }

    /**
     * @param list<string> $current
     * @param list<string> $toAdd
     * @return array{added:int,skipped:int}
     */
    public static function merge_into_extra_recipients(int $tournamentId, array $current, array $toAdd): array
    {
        $currentNorm = self::normalize_email_list($current);
        $incoming = self::normalize_email_list($toAdd);
        $existingKeys = [];
        foreach ($currentNorm as $e) {
            $existingKeys[strtolower($e)] = true;
        }
        $added = 0;
        foreach ($incoming as $e) {
            $lk = strtolower($e);
            if (isset($existingKeys[$lk])) {
                continue;
            }
            if (count($currentNorm) >= self::EXTRA_RECIPIENTS_MAX) {
                break;
            }
            $existingKeys[$lk] = true;
            $currentNorm[] = $e;
            ++$added;
        }
        self::set_extra_recipients($tournamentId, $currentNorm);

        return ['added' => $added, 'skipped' => count($incoming) - $added];
    }

    /**
     * @param array<string,mixed> $post Global $_POST fragment — use privacy flag from request.
     * @return array{success:bool,message:string,count?:int}
     */
    public static function process_extra_recipients_post(string $action, int $tournamentId, array $post, ?array $files = null): array
    {
        if (!ChessPodium_License::is_pro()) {
            return ['success' => false, 'message' => __('Tournament emails are a Pro feature.', 'chess-podium')];
        }
        if ($tournamentId <= 0) {
            return ['success' => false, 'message' => __('Invalid tournament.', 'chess-podium')];
        }
        $files = $files ?? $_FILES;
        $privacy = !empty($post['cp_comm_privacy_confirm']);

        switch ($action) {
            case 'cp_comm_extra_add':
                if (!$privacy) {
                    return ['success' => false, 'message' => __('Confirm the consent statement before adding or importing addresses.', 'chess-podium')];
                }
                $one = isset($post['cp_extra_email_single']) ? sanitize_email(wp_unslash((string) $post['cp_extra_email_single'])) : '';
                if ($one === '' || !is_email($one)) {
                    return ['success' => false, 'message' => __('Enter a valid email address.', 'chess-podium')];
                }
                $cur = self::get_extra_recipients($tournamentId);
                $r = self::merge_into_extra_recipients($tournamentId, $cur, [$one]);
                if ($r['added'] < 1) {
                    return ['success' => false, 'message' => __('That address is already on the list.', 'chess-podium')];
                }

                return ['success' => true, 'message' => __('Address added to the additional recipients list.', 'chess-podium'), 'count' => 1];

            case 'cp_comm_extra_bulk':
                if (!$privacy) {
                    return ['success' => false, 'message' => __('Confirm the consent statement before adding or importing addresses.', 'chess-podium')];
                }
                $blob = isset($post['cp_extra_email_bulk']) ? wp_unslash((string) $post['cp_extra_email_bulk']) : '';
                $parsed = self::extract_emails_from_blob($blob);
                if ($parsed === []) {
                    return ['success' => false, 'message' => __('No valid email addresses found in the text.', 'chess-podium')];
                }
                $cur = self::get_extra_recipients($tournamentId);
                $r = self::merge_into_extra_recipients($tournamentId, $cur, $parsed);
                if ($r['added'] < 1) {
                    return ['success' => false, 'message' => __('No new addresses to add (all invalid or already listed).', 'chess-podium')];
                }

                return [
                    'success' => true,
                    'message' => sprintf(
                        /* translators: 1: newly added count, 2: skipped (invalid or duplicate) count */
                        __('Added %1$d address(es). %2$d skipped (invalid or duplicate).', 'chess-podium'),
                        $r['added'],
                        $r['skipped']
                    ),
                    'count' => $r['added'],
                ];

            case 'cp_comm_extra_import':
                if (!$privacy) {
                    return ['success' => false, 'message' => __('Confirm the consent statement before adding or importing addresses.', 'chess-podium')];
                }
                $file = isset($files['cp_extra_email_file']) && is_array($files['cp_extra_email_file']) ? $files['cp_extra_email_file'] : null;
                if (!$file) {
                    return ['success' => false, 'message' => __('No file uploaded.', 'chess-podium')];
                }
                $parsedFile = self::parse_emails_from_uploaded_file($file);
                if (isset($parsedFile['error'])) {
                    return ['success' => false, 'message' => $parsedFile['error']];
                }
                $parsed = $parsedFile['emails'];
                if ($parsed === []) {
                    return ['success' => false, 'message' => __('No valid email addresses found in the file.', 'chess-podium')];
                }
                $cur = self::get_extra_recipients($tournamentId);
                $r = self::merge_into_extra_recipients($tournamentId, $cur, $parsed);
                if ($r['added'] < 1) {
                    return ['success' => false, 'message' => __('No new addresses to add (all invalid or already listed).', 'chess-podium')];
                }

                return [
                    'success' => true,
                    'message' => sprintf(
                        /* translators: 1: newly added count, 2: skipped count */
                        __('Imported %1$d address(es) from file. %2$d skipped (invalid or duplicate).', 'chess-podium'),
                        $r['added'],
                        $r['skipped']
                    ),
                    'count' => $r['added'],
                ];

            case 'cp_comm_extra_remove_all':
                self::set_extra_recipients($tournamentId, []);

                return ['success' => true, 'message' => __('All additional addresses removed.', 'chess-podium'), 'count' => 0];

            case 'cp_comm_extra_remove':
                $remove = isset($post['cp_extra_remove']) && is_array($post['cp_extra_remove']) ? $post['cp_extra_remove'] : [];
                $removeLower = [];
                foreach ($remove as $addr) {
                    $e = sanitize_email(wp_unslash((string) $addr));
                    if ($e !== '' && is_email($e)) {
                        $removeLower[strtolower($e)] = true;
                    }
                }
                if ($removeLower === []) {
                    return ['success' => false, 'message' => __('Select at least one address to remove.', 'chess-podium')];
                }
                $cur = self::get_extra_recipients($tournamentId);
                $new = [];
                foreach ($cur as $e) {
                    if (!isset($removeLower[strtolower($e)])) {
                        $new[] = $e;
                    }
                }
                self::set_extra_recipients($tournamentId, $new);
                $removed = count($cur) - count($new);
                if ($removed < 1) {
                    return ['success' => false, 'message' => __('No matching addresses to remove.', 'chess-podium')];
                }

                return [
                    'success' => true,
                    'message' => sprintf(
                        /* translators: %d: number of addresses removed */
                        _n('Removed %d address.', 'Removed %d addresses.', $removed, 'chess-podium'),
                        $removed
                    ),
                    'count' => $removed,
                ];

            default:
                return ['success' => false, 'message' => __('Unknown action.', 'chess-podium')];
        }
    }

    /**
     * @param list<array{id:int,email:string,name:string}> $recipients
     * @return list<array{id:int,email:string,name:string}>
     */
    private static function merge_extra_into_recipient_queue(int $tournamentId, array $recipients): array
    {
        $seen = [];
        foreach ($recipients as $r) {
            $seen[strtolower((string) ($r['email'] ?? ''))] = true;
        }
        $subscriber = __('Subscriber', 'chess-podium');
        foreach (self::get_extra_recipients($tournamentId) as $email) {
            $lk = strtolower($email);
            if (isset($seen[$lk])) {
                continue;
            }
            $seen[$lk] = true;
            $recipients[] = [
                'id' => 0,
                'email' => $email,
                'name' => $subscriber,
            ];
        }

        return $recipients;
    }

    public static function get_active_queue(int $tournamentId): ?array
    {
        $q = get_option(self::queue_option_key($tournamentId), null);
        return is_array($q) && !empty($q['recipients']) ? $q : null;
    }

    public static function is_queue_running(int $tournamentId): bool
    {
        $q = self::get_active_queue($tournamentId);
        if (!$q) {
            return false;
        }
        $offset = (int) ($q['offset'] ?? 0);
        $total = count($q['recipients']);
        return $offset < $total;
    }

    /**
     * @return array{success:bool,message:string,queued?:int}
     */
    public static function start_queue(int $tournamentId, string $subjectTpl, string $bodyTpl, string $kind, int $roundForPlaceholders = 0): array
    {
        if (!ChessPodium_License::is_pro()) {
            return ['success' => false, 'message' => __('Tournament emails are a Pro feature.', 'chess-podium')];
        }
        if ($tournamentId <= 0) {
            return ['success' => false, 'message' => __('Invalid tournament.', 'chess-podium')];
        }
        if (self::is_queue_running($tournamentId)) {
            return ['success' => false, 'message' => __('A bulk send is already in progress for this tournament.', 'chess-podium')];
        }
        $subjectTpl = sanitize_text_field($subjectTpl);
        if ($subjectTpl === '') {
            return ['success' => false, 'message' => __('Enter an email subject.', 'chess-podium')];
        }
        $bodyTpl = wp_unslash($bodyTpl);
        if (trim($bodyTpl) === '') {
            return ['success' => false, 'message' => __('Enter a message.', 'chess-podium')];
        }

        $tournament = ChessPodiumPlugin::get_tournament($tournamentId);
        if (!$tournament) {
            return ['success' => false, 'message' => __('Tournament not found.', 'chess-podium')];
        }

        $players = ChessPodiumPlugin::get_players($tournamentId);
        $recipients = [];
        foreach ($players as $p) {
            $email = isset($p->email) ? sanitize_email((string) $p->email) : '';
            $allow = isset($p->allow_notifications) ? (int) $p->allow_notifications : 0;
            if ($email === '' || !is_email($email) || $allow !== 1) {
                continue;
            }
            $recipients[] = [
                'id' => (int) $p->id,
                'email' => $email,
                'name' => (string) $p->name,
            ];
        }
        $recipients = self::merge_extra_into_recipient_queue($tournamentId, $recipients);
        if ($recipients === []) {
            return ['success' => false, 'message' => __('No recipients: add opted-in players or addresses under Additional recipients.', 'chess-podium')];
        }

        $ctx = self::resolve_send_context($tournamentId, $tournament, $kind, $roundForPlaceholders);
        if (isset($ctx['error'])) {
            return ['success' => false, 'message' => $ctx['error']];
        }

        $queue = [
            'subject_tpl' => $subjectTpl,
            'body_tpl' => $bodyTpl,
            'recipients' => $recipients,
            'offset' => 0,
            'tournament_id' => $tournamentId,
            'tournament_name' => $ctx['tournament_name'],
            'round_no' => $ctx['round'],
            'pairing_url' => $ctx['pairing_url'],
            'kind' => sanitize_key($kind) ?: 'general',
            'board_map' => $ctx['board_map'],
            'total_sent' => 0,
        ];

        update_option(self::queue_option_key($tournamentId), $queue, false);

        if (!wp_next_scheduled(self::CRON_HOOK, [$tournamentId])) {
            wp_schedule_single_event(time() + 3, self::CRON_HOOK, [$tournamentId]);
        }
        if (function_exists('spawn_cron')) {
            spawn_cron();
        }

        return [
            'success' => true,
            'message' => sprintf(
                /* translators: %d: number of recipients */
                __('Queued %d emails. Sending in the background (small batches).', 'chess-podium'),
                count($recipients)
            ),
            'queued' => count($recipients),
        ];
    }

    /**
     * Shared URL/round/board context for queue and test emails.
     *
     * @return array{error?:string,round:int,pairing_url:string,tournament_name:string,board_map:array<int,int>}
     */
    private static function resolve_send_context(int $tournamentId, object $tournament, string $kind, int $roundForPlaceholders = 0): array
    {
        $kind = sanitize_key($kind);
        if (!in_array($kind, ['general', 'pairings'], true)) {
            $kind = 'general';
        }
        if ($kind === 'pairings') {
            $round = $roundForPlaceholders > 0 ? $roundForPlaceholders : (int) $tournament->current_round;
            if ($round < 1) {
                return ['error' => __('There is no published round to link yet.', 'chess-podium')];
            }
        } else {
            $round = $roundForPlaceholders > 0 ? $roundForPlaceholders : (int) $tournament->current_round;
            if ($round < 1) {
                $round = 0;
            }
        }
        $pairingUrl = $kind === 'pairings' && $round > 0
            ? ChessPodiumPlugin::get_public_tournament_round_url($tournamentId, $round)
            : ChessPodiumPlugin::get_public_tournament_view_url($tournamentId);
        $boardMap = $round > 0 ? self::build_player_board_map($tournamentId, $round) : [];

        return [
            'round' => $round,
            'pairing_url' => $pairingUrl,
            'tournament_name' => (string) $tournament->name,
            'board_map' => $boardMap,
        ];
    }

    /**
     * Send one preview message with sample placeholder values (does not touch the bulk queue or send log).
     *
     * @return array{success:bool,message:string}
     */
    public static function send_test_email(int $tournamentId, string $toEmail, string $subjectTpl, string $bodyTpl, string $kind): array
    {
        if (!ChessPodium_License::is_pro()) {
            return ['success' => false, 'message' => __('Tournament emails are a Pro feature.', 'chess-podium')];
        }
        if ($tournamentId <= 0) {
            return ['success' => false, 'message' => __('Invalid tournament.', 'chess-podium')];
        }
        $toEmail = sanitize_email($toEmail);
        if ($toEmail === '' || !is_email($toEmail)) {
            return ['success' => false, 'message' => __('Enter a valid email address for the test message.', 'chess-podium')];
        }
        $subjectTpl = sanitize_text_field($subjectTpl);
        if ($subjectTpl === '') {
            return ['success' => false, 'message' => __('Enter an email subject.', 'chess-podium')];
        }
        $bodyTpl = wp_unslash($bodyTpl);
        if (trim($bodyTpl) === '') {
            return ['success' => false, 'message' => __('Enter a message.', 'chess-podium')];
        }
        $tournament = ChessPodiumPlugin::get_tournament($tournamentId);
        if (!$tournament) {
            return ['success' => false, 'message' => __('Tournament not found.', 'chess-podium')];
        }
        $kind = sanitize_key($kind);
        if (!in_array($kind, ['general', 'pairings'], true)) {
            $kind = 'general';
        }
        $roundFor = $kind === 'pairings' ? (int) $tournament->current_round : 0;
        $ctx = self::resolve_send_context($tournamentId, $tournament, $kind, $roundFor);
        if (isset($ctx['error'])) {
            return ['success' => false, 'message' => $ctx['error']];
        }
        $sampleName = __('Sample player (preview)', 'chess-podium');
        $boardMap = $ctx['board_map'];
        $sampleTable = $boardMap !== [] ? (string) reset($boardMap) : '12';
        $placeholders = self::build_placeholders(
            $sampleName,
            $ctx['tournament_name'],
            $ctx['pairing_url'],
            $ctx['round'],
            $sampleTable,
            home_url('/')
        );
        $subject = self::replace_tokens($subjectTpl, $placeholders);
        $subject = '[TEST] ' . $subject;
        $bodyPlain = self::replace_tokens($bodyTpl, $placeholders);
        $bodyHtml = self::format_body_html($bodyPlain);
        $html = self::wrap_email_html($bodyHtml, $ctx['tournament_name'], $ctx['pairing_url']);
        $headers = ['Content-Type: text/html; charset=UTF-8'];
        $sent = wp_mail($toEmail, $subject, $html, $headers);
        if (!$sent) {
            return ['success' => false, 'message' => __('WordPress could not send the test email. Check your SMTP or mail settings.', 'chess-podium')];
        }

        return ['success' => true, 'message' => __('Test email sent.', 'chess-podium')];
    }

    /**
     * @return array<int,int> player_id => board number (1-based)
     */
    private static function build_player_board_map(int $tournamentId, int $round): array
    {
        $pairings = ChessPodiumPlugin::get_pairings_for_round($tournamentId, $round);
        $map = [];
        foreach ($pairings as $i => $p) {
            $board = $i + 1;
            $wid = (int) $p->white_player_id;
            $bid = (int) $p->black_player_id;
            if ($wid > 0) {
                $map[$wid] = $board;
            }
            if ($bid > 0) {
                $map[$bid] = $board;
            }
        }
        return $map;
    }

    public static function run_batch(int $tournamentId): void
    {
        if ($tournamentId <= 0) {
            return;
        }
        $key = self::queue_option_key($tournamentId);
        $queue = get_option($key, null);
        if (!is_array($queue) || empty($queue['recipients'])) {
            return;
        }

        $recipients = $queue['recipients'];
        $offset = (int) ($queue['offset'] ?? 0);
        $slice = array_slice($recipients, $offset, self::BATCH_SIZE);
        $tournamentName = (string) ($queue['tournament_name'] ?? '');
        $roundNo = (int) ($queue['round_no'] ?? 0);
        $pairingUrl = (string) ($queue['pairing_url'] ?? '');
        $boardMap = is_array($queue['board_map'] ?? null) ? $queue['board_map'] : [];
        $subjectTpl = (string) ($queue['subject_tpl'] ?? '');
        $bodyTpl = (string) ($queue['body_tpl'] ?? '');
        $kind = (string) ($queue['kind'] ?? 'general');

        $siteUrl = home_url('/');
        $headers = ['Content-Type: text/html; charset=UTF-8'];

        foreach ($slice as $row) {
            $playerName = (string) ($row['name'] ?? '');
            $email = (string) ($row['email'] ?? '');
            if (!is_email($email)) {
                continue;
            }
            $pid = (int) ($row['id'] ?? 0);
            $tableNo = $pid > 0 && isset($boardMap[$pid]) ? (string) (int) $boardMap[$pid] : '';
            $placeholders = self::build_placeholders($playerName, $tournamentName, $pairingUrl, $roundNo, $tableNo, $siteUrl);
            $subject = self::replace_tokens($subjectTpl, $placeholders);
            $bodyPlain = self::replace_tokens($bodyTpl, $placeholders);
            $bodyHtml = self::format_body_html($bodyPlain);
            $html = self::wrap_email_html($bodyHtml, $tournamentName, $pairingUrl);
            wp_mail($email, $subject, $html, $headers);
            $queue['total_sent'] = (int) ($queue['total_sent'] ?? 0) + 1;
        }

        $offset += count($slice);
        $queue['offset'] = $offset;

        if ($offset >= count($recipients)) {
            $total = (int) ($queue['total_sent'] ?? 0);
            self::append_log($tournamentId, $subjectTpl, $total, $kind);
            delete_option($key);
            return;
        }

        update_option($key, $queue, false);
        if (!wp_next_scheduled(self::CRON_HOOK, [$tournamentId])) {
            wp_schedule_single_event(time() + self::BATCH_DELAY, self::CRON_HOOK, [$tournamentId]);
        }
    }

    /**
     * @return array<string,string>
     */
    private static function build_placeholders(string $playerName, string $tournamentName, string $pairingUrl, int $roundNo, string $tableNumber, string $siteUrl): array
    {
        $roundLabel = $roundNo > 0
            ? sprintf(
                /* translators: %d: round number */
                __('Round %d', 'chess-podium'),
                $roundNo
            )
            : '';
        $tableOut = $tableNumber !== '' ? $tableNumber : '—';
        return [
            '{player_name}' => $playerName,
            '{tournament_name}' => $tournamentName,
            '{pairing_url}' => $pairingUrl,
            '{table_number}' => $tableOut,
            '{round_number}' => $roundNo > 0 ? (string) $roundNo : '',
            '{site_url}' => $siteUrl,
        ];
    }

    private static function replace_tokens(string $text, array $placeholders): string
    {
        return str_replace(array_keys($placeholders), array_values($placeholders), $text);
    }

    private static function format_body_html(string $plain): string
    {
        $escaped = esc_html($plain);
        return nl2br($escaped, false);
    }

    public static function wrap_email_html(string $bodyHtml, string $tournamentName, string $ctaUrl): string
    {
        $headerTitle = esc_html($tournamentName);
        $ctaLabel = esc_html__('View pairings / standings', 'chess-podium');
        $safeCta = esc_url($ctaUrl);
        $footerTech = esc_html__('This is an automated technical message from Chess Podium. Please do not reply to this email.', 'chess-podium');
        $footerSite = esc_html__('Tournament page', 'chess-podium');
        $site = esc_url(home_url('/'));

        ob_start();
        ?>
<!DOCTYPE html>
<html lang="<?php echo esc_attr(substr(get_locale(), 0, 2)); ?>">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f1f5f9;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:10px;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,.08);">
<tr><td style="padding:20px 24px;background:#0f172a;color:#f8fafc;">
<strong style="font-size:18px;line-height:1.35;"><?php echo $headerTitle; ?></strong>
</td></tr>
<tr><td style="padding:24px;color:#334155;font-size:15px;line-height:1.6;">
<?php echo $bodyHtml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped nl2br ?>
</td></tr>
<tr><td style="padding:0 24px 24px;" align="center">
<a href="<?php echo $safeCta; ?>" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:8px;font-size:15px;"><?php echo $ctaLabel; ?></a>
</td></tr>
<tr><td style="padding:16px 24px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:12px;line-height:1.5;color:#64748b;">
<p style="margin:0 0 8px;"><?php echo $footerTech; ?></p>
<p style="margin:0;"><a href="<?php echo $site; ?>" style="color:#2563eb;text-decoration:none;"><?php echo $footerSite; ?></a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
        <?php
        return (string) ob_get_clean();
    }

    private static function append_log(int $tournamentId, string $subject, int $count, string $kind): void
    {
        global $wpdb;
        $table = $wpdb->prefix . self::LOG_TABLE;
        $wpdb->insert(
            $table,
            [
                'tournament_id' => $tournamentId,
                'sent_at' => current_time('mysql'),
                'subject' => mb_substr($subject, 0, 500),
                'recipients_count' => max(0, $count),
                'kind' => mb_substr(sanitize_key($kind), 0, 32),
            ],
            ['%d', '%s', '%s', '%d', '%s']
        );
    }

    /**
     * @return list<object{sent_at:string,subject:string,recipients_count:int,kind:string}>
     */
    public static function get_recent_log(int $tournamentId, int $limit = 15): array
    {
        global $wpdb;
        $table = $wpdb->prefix . self::LOG_TABLE;
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) { // phpcs:ignore
            return [];
        }
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT sent_at, subject, recipients_count, kind FROM {$table} WHERE tournament_id = %d ORDER BY sent_at DESC LIMIT %d",
                $tournamentId,
                max(1, min(50, $limit))
            )
        );
        return is_array($rows) ? $rows : [];
    }

    public static function render_admin_tab(object $tournament, bool $isActiveTab): void
    {
        $tid = (int) $tournament->id;
        ?>
        <div class="cp-tab-panel <?php echo $isActiveTab ? 'cp-tab-active' : ''; ?>" data-panel="communications">
        <h3><?php echo esc_html__('Communications', 'chess-podium'); ?></h3>
        <?php if (!ChessPodium_License::is_pro()): ?>
            <p class="description"><?php echo esc_html__('Service emails to players who opted in are available in Chess Podium Pro.', 'chess-podium'); ?></p>
        <?php else: ?>
        <?php
        $running = self::is_queue_running($tid);
        $prefill = isset($_GET['cp_mail_prefill']) ? sanitize_key(wp_unslash($_GET['cp_mail_prefill'])) : '';
        $roundNo = max(1, (int) $tournament->current_round);
        $defaultSubject = '';
        $defaultBody = '';
        if ($prefill === 'pairings' && $roundNo > 0) {
            $defaultSubject = sprintf(
                /* translators: 1: tournament name placeholder token, 2: round number */
                __('[%1$s] Round %2$d published', 'chess-podium'),
                '{tournament_name}',
                $roundNo
            );
            $defaultBody = sprintf(
                /* translators: 1: player name placeholder, 2: round number placeholder, 3: table placeholder */
                __("Hello {player_name},\n\nRound {round_number} has been published. You are on board {table_number}.\n\nPairings: {pairing_url}\n\nGood luck!", 'chess-podium')
            );
        }
        ?>
        <?php if ($running): ?>
            <div class="notice notice-info inline"><p><?php echo esc_html__('A bulk email send is in progress. Remaining messages are sent in the background.', 'chess-podium'); ?></p></div>
        <?php endif; ?>
        <p class="description"><?php echo esc_html__('Bulk messages go to players who registered with a valid email and accepted technical communications, plus any addresses you list under Additional recipients (Pro). You are responsible for lawful consent for every address.', 'chess-podium'); ?></p>

        <?php
        $extraList = self::get_extra_recipients($tid);
        $extraMax = self::EXTRA_RECIPIENTS_MAX;
        ?>
        <h4 style="margin-top:1.25em;"><?php echo esc_html__('Additional recipients', 'chess-podium'); ?></h4>
        <p class="description"><?php echo esc_html__('Optional list for this tournament only (organisers, press, etc.). Each address must have expressly opted in to receive these emails under the privacy laws that apply to you (e.g. GDPR, CAN-SPAM). Chess Podium does not verify consent.', 'chess-podium'); ?></p>
        <p class="description"><?php echo esc_html(sprintf(
            /* translators: %d: maximum number of extra email addresses */
            (string) __('Maximum %d addresses. Invalid entries are ignored when pasting or importing.', 'chess-podium'),
            $extraMax
        )); ?></p>

        <?php if ($extraList !== []): ?>
        <form method="post" action="" style="max-width:720px;margin-bottom:1em;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="communications">
            <table class="widefat striped" style="max-width:720px;">
                <thead><tr>
                    <th style="width:36px;"><span class="screen-reader-text"><?php echo esc_html__('Select', 'chess-podium'); ?></span></th>
                    <th><?php echo esc_html__('Email', 'chess-podium'); ?></th>
                </tr></thead>
                <tbody>
                <?php foreach ($extraList as $em): ?>
                    <tr>
                        <td><input type="checkbox" name="cp_extra_remove[]" value="<?php echo esc_attr($em); ?>"></td>
                        <td><?php echo esc_html($em); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
            <p>
                <button type="submit" class="button" name="regina_action" value="cp_comm_extra_remove"><?php echo esc_html__('Remove selected', 'chess-podium'); ?></button>
                <button type="submit" class="button" name="regina_action" value="cp_comm_extra_remove_all"><?php echo esc_html__('Remove all additional addresses', 'chess-podium'); ?></button>
            </p>
        </form>
        <?php else: ?>
        <p class="description"><?php echo esc_html__('No additional addresses yet. Add one below or paste/import a list.', 'chess-podium'); ?></p>
        <?php endif; ?>

        <p class="description" style="max-width:720px;padding:10px 12px;border-left:4px solid #dba617;background:#fcf9e8;">
            <?php echo esc_html__('You may only add addresses for people who have opted in. You certify that your list complies with applicable laws (such as GDPR, ePrivacy, CAN-SPAM, or other local rules). Chess Podium stores addresses only for sending these tournament messages and does not verify consent.', 'chess-podium'); ?>
        </p>

        <form id="cp-comm-extra-add" method="post" action="" style="max-width:720px;margin-bottom:1em;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="communications">
            <p>
                <label for="cp_extra_email_single"><strong><?php echo esc_html__('Add one email address', 'chess-podium'); ?></strong></label><br>
                <input type="email" class="regular-text" id="cp_extra_email_single" name="cp_extra_email_single" autocomplete="email"
                    placeholder="<?php echo esc_attr__('name@example.com', 'chess-podium'); ?>">
                <button type="submit" class="button" name="regina_action" value="cp_comm_extra_add"><?php echo esc_html__('Add address', 'chess-podium'); ?></button>
            </p>
            <p>
                <label>
                    <input type="checkbox" name="cp_comm_privacy_confirm" value="1" required>
                    <?php echo esc_html__('I confirm lawful opt-in and compliance for this address, as described above.', 'chess-podium'); ?>
                </label>
            </p>
        </form>

        <form method="post" action="" style="max-width:720px;margin-bottom:1em;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="communications">
            <p>
                <label for="cp_extra_email_bulk"><strong><?php echo esc_html__('Bulk paste (one per line or comma-separated)', 'chess-podium'); ?></strong></label><br>
                <textarea id="cp_extra_email_bulk" name="cp_extra_email_bulk" rows="6" class="large-text" placeholder="<?php echo esc_attr("one@example.com\ntwo@example.com"); ?>"></textarea>
            </p>
            <p>
                <label>
                    <input type="checkbox" name="cp_comm_privacy_confirm" value="1" required>
                    <?php echo esc_html__('I confirm lawful opt-in and compliance for every address in this list, as described above.', 'chess-podium'); ?>
                </label>
            </p>
            <p><button type="submit" class="button" name="regina_action" value="cp_comm_extra_bulk"><?php echo esc_html__('Merge into list', 'chess-podium'); ?></button></p>
        </form>

        <form method="post" action="" enctype="multipart/form-data" style="max-width:720px;margin-bottom:1.5em;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="communications">
            <p>
                <strong><?php echo esc_html__('Import from .txt or .csv file', 'chess-podium'); ?></strong><br>
                <span class="description"><?php echo esc_html__('One address per line; CSV rows may use the first column. Max 512 KB.', 'chess-podium'); ?></span><br>
                <input type="file" name="cp_extra_email_file" accept=".txt,.csv,text/plain,text/csv">
            </p>
            <p>
                <label>
                    <input type="checkbox" name="cp_comm_privacy_confirm" value="1" required>
                    <?php echo esc_html__('I confirm lawful opt-in and compliance for every address in this file, as described above.', 'chess-podium'); ?>
                </label>
            </p>
            <p><button type="submit" class="button" name="regina_action" value="cp_comm_extra_import"><?php echo esc_html__('Import file', 'chess-podium'); ?></button></p>
        </form>

        <p class="description"><strong><?php echo esc_html__('Placeholders:', 'chess-podium'); ?></strong>
            <code>{player_name}</code>, <code>{tournament_name}</code>, <code>{pairing_url}</code>, <code>{table_number}</code>, <code>{round_number}</code>, <code>{site_url}</code>
            — <?php echo esc_html__('Additional recipients see the label "Subscriber" for the player name unless they are also registered players with notifications enabled.', 'chess-podium'); ?>
        </p>
        <form method="post" style="max-width:720px;">
            <?php wp_nonce_field('regina_torneo_action', 'regina_nonce'); ?>
            <input type="hidden" name="tournament_id" value="<?php echo esc_attr((string) $tid); ?>">
            <input type="hidden" name="cp_tab" value="communications">
            <p>
                <label for="cp_mail_subject"><strong><?php echo esc_html__('Subject', 'chess-podium'); ?></strong></label><br>
                <input type="text" class="large-text" id="cp_mail_subject" name="cp_mail_subject" required
                    value="<?php echo esc_attr($defaultSubject); ?>"
                    placeholder="<?php echo esc_attr__('e.g. [{tournament_name}] Update', 'chess-podium'); ?>">
            </p>
            <p>
                <label for="cp_mail_body"><strong><?php echo esc_html__('Message', 'chess-podium'); ?></strong></label><br>
                <textarea id="cp_mail_body" name="cp_mail_body" rows="10" class="large-text" required placeholder="<?php echo esc_attr__('Write your message. You can use the placeholders listed above.', 'chess-podium'); ?>"><?php echo esc_textarea($defaultBody); ?></textarea>
            </p>
            <p>
                <label for="cp_mail_test_to"><strong><?php echo esc_html__('Test recipient email', 'chess-podium'); ?></strong></label><br>
                <input type="email" class="large-text" id="cp_mail_test_to" name="cp_mail_test_to" autocomplete="email"
                    placeholder="<?php echo esc_attr__('your@email.example', 'chess-podium'); ?>">
                <span class="description"><?php echo esc_html__('Send one preview with sample placeholder data. The subject is prefixed with [TEST]. Does not queue bulk mail or appear in the log below.', 'chess-podium'); ?></span>
            </p>
            <p>
                <button type="submit" class="button" name="regina_action" value="cp_send_tournament_notification_test_general" <?php disabled($running); ?>>
                    <?php echo esc_html__('Send test (general)', 'chess-podium'); ?>
                </button>
                <button type="submit" class="button" name="regina_action" value="cp_send_tournament_notification_test_pairings" <?php disabled($running || $roundNo < 1); ?>
                    title="<?php echo esc_attr__('Uses the current round and pairing page URL.', 'chess-podium'); ?>">
                    <?php echo esc_html__('Send test (pairings)', 'chess-podium'); ?>
                </button>
            </p>
            <hr style="margin:1em 0;border:none;border-top:1px solid #c3c4c7;">
            <p>
                <button type="submit" class="button button-primary" name="regina_action" value="cp_queue_tournament_notifications_general" <?php disabled($running); ?>>
                    <?php echo esc_html__('Send general announcement', 'chess-podium'); ?>
                </button>
                <button type="submit" class="button" name="regina_action" value="cp_queue_tournament_notifications_pairings" <?php disabled($running || $roundNo < 1); ?>
                    title="<?php echo esc_attr__('Uses the current round and pairing page URL.', 'chess-podium'); ?>">
                    <?php echo esc_html__('Send current round pairings link', 'chess-podium'); ?>
                </button>
            </p>
        </form>
        <h4><?php echo esc_html__('Recent sends', 'chess-podium'); ?></h4>
        <?php
        $log = self::get_recent_log($tid, 15);
        if ($log === []) {
            echo '<p class="description">' . esc_html__('No sends logged yet for this tournament.', 'chess-podium') . '</p>';
        } else {
            echo '<table class="widefat striped" style="max-width:720px;"><thead><tr>';
            echo '<th>' . esc_html__('Date', 'chess-podium') . '</th><th>' . esc_html__('Subject', 'chess-podium') . '</th><th>' . esc_html__('Recipients', 'chess-podium') . '</th></tr></thead><tbody>';
            foreach ($log as $row) {
                echo '<tr>';
                echo '<td>' . esc_html($row->sent_at ?? '') . '</td>';
                echo '<td>' . esc_html($row->subject ?? '') . '</td>';
                echo '<td>' . esc_html((string) (int) ($row->recipients_count ?? 0)) . '</td>';
                echo '</tr>';
            }
            echo '</tbody></table>';
        }
        ?>
        <?php endif; ?>
        </div>
        <?php
    }
}
