<?php declare(strict_types=1);
/**
 * This file is part of the SplendidBear Websites' projects.
 *
 * Copyright (c) 2019 @ www.splendidbear.org
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace App\Util;

use App\Entity\Gamer;
use App\Entity\GridRow;
use App\Entity\PlayedGame;
use App\Entity\Step;
use App\Entity\User;
use App\Interfaces\TopicManagerInterface;
use App\Repository\UserRepository;
use DateTime;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use JsonException;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

/**
 * Class TopicManager
 *
 * @package  App\Util
 * @author   Lang <https://www.splendidbear.org>
 * @category Class
 * @license  https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
 * @link     www.splendidbear.org
 * @since    2019. 10. 27.
 */
readonly class TopicManager implements TopicManagerInterface
{
    public function __construct(
        private EntityManagerInterface $em,
        private HubInterface           $hub,
        private LoggerInterface        $logger,
        private CacheManager           $cacheManager,
        private UserRepository         $userRepository,
        private RequestStack           $requestStack,
        private Security               $security,
    ) {
    }

    public function subscribe(string $gameAssoc, string $userName): void
    {
        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);

        if (null === $playedGame) {
            return;
        }

        $users = $this->getUserCollection($playedGame);
        $count = $this->getPlayerCount($users);
        $isKnown = in_array($userName, array_filter(array_values($users)), true);

        /** Reject a third player who is not a reconnecting player */
        if ($count >= 2 && !$isKnown) {
            return;
        }

        /** Save the player to the database on a fresh join */
        if (!$isKnown && $count < 2) {
            $users = $this->saveUserToDb($gameAssoc, $userName, $count + 1);
            $count = $this->getPlayerCount($users);
        }

        $topic = 'mineseeker/channel/' . $gameAssoc;

        try {
            $this->hub->publish(new Update(
                $topic,
                json_encode([
                    'userTopicId' => $userName,
                    'channel'     => $topic,
                    'user'        => $userName,
                    'userCnt'     => $count,
                    'users'       => $users,
                ], JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            throw new RuntimeException($e->getMessage());
        }

        // ── Lobby updates ──────────────────────────────────────────────────
        if ($count === 1) {
            // One player waiting — mark as active and announce to the lobby
            $playedGame->updated = new DateTime();

            $this->em->persist($playedGame);
            $this->em->flush();

            $displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
            $this->publishToLobby([
                'action'    => 'join',
                'gameAssoc' => $gameAssoc,
                'name'      => $displayName,
                'since'     => $playedGame->created?->format(DateTimeInterface::ATOM) ?? '',
            ]);
        } elseif ($count === 2) {
            // Both players joined — remove from lobby
            $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
        }
    }

    public function unSubscribe(string $gameAssoc, string $userName): void
    {
        // If the game was still waiting for a second player, stamp it as abandoned
        // so it no longer appears in the waiting-games query, and remove from lobby.
        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);
        if (null !== $playedGame) {
            $users = $this->getUserCollection($playedGame);
            if ($this->getPlayerCount($users) === 1) {
                $playedGame->updated = new DateTime('2000-01-01 00:00:00');
                $this->em->persist($playedGame);
                $this->em->flush();
                $this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
            }
        }

        $topic = 'mineseeker/channel/' . $gameAssoc;

        $this->hub->publish(new Update(
            $topic,
            json_encode(['msg' => $userName . ' has left ' . $topic])
        ));
    }

    /**
     * Resolve the revealed cells for a step, persist it, and broadcast via Mercure.
     * Returns the step result data (same shape as what is broadcast in `data`).
     */
    public function publish(string $gameAssoc, string $userName, array $event): array
    {
        if (null !== $event['resign']) {
            $this->saveResignToDb($gameAssoc, $event['resign']);

            $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);
            $users = $this->getUserCollection($playedGame);
            $count = $this->getPlayerCount($users);
            $topic = 'mineseeker/channel/' . $gameAssoc;

            $data = ['resign' => $event['resign'], 'coords' => null];

            try {
                $this->hub->publish(new Update(
                    $topic,
                    json_encode([
                        'userTopicId' => $userName,
                        'channel'     => $topic,
                        'user'        => $userName,
                        'userCnt'     => $count,
                        'data'        => $data,
                    ], JSON_THROW_ON_ERROR)
                ));
            } catch (JsonException $e) {
                throw new RuntimeException($e->getMessage());
            }

            return $data;
        }

        $coords = $event['coords'];
        $player = $event['player'];          // 'red' | 'blue'
        $isBomb = (bool)$event['bomb'];

        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);
        $grid = $this->loadGrid($gameAssoc);

        /** Cells already revealed by previous steps (as "row,col" => true map) */
        $alreadyRevealed = $this->buildRevealedMap($playedGame);

        /** Determine which cells to reveal for this step */
        if ($isBomb) {
            $revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
        } elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) {
            /** Direct click on a mine — reveal it immediately (flood-fill skips mines) */
            $revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']];
        } else {
            $revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
        }

        $minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value']));
        $safeCellsFound = count(array_filter($revealedCells, static fn($c) => 'm' !== $c['value']));

        $redPoints = ($playedGame->redPoints ?? 0) + ('red' === $player ? $minesFound : 0);
        $bluePoints = ($playedGame->bluePoints ?? 0) + ('blue' === $player ? $minesFound : 0);
        $gameOver = $redPoints > 25 || $bluePoints > 25;

        /** Calculate bonus points and stats */
        $bonusData = $this->calculateBonuses(
            $playedGame,
            $player,
            $coords,
            $grid,
            $alreadyRevealed,
            $minesFound,
            $safeCellsFound,
            $redPoints,
            $bluePoints
        );

        /** Reveal remaining mines when the game ends */
        $leftMines = [];
        if ($gameOver) {
            $finalRevealed = $alreadyRevealed;
            foreach ($revealedCells as $c) {
                $finalRevealed[$c['row'] . ',' . $c['col']] = true;
            }
            $leftMines = $this->getLeftMines($grid, $finalRevealed);
        }

        $this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints, $bonusData);

        $users = $this->getUserCollection($playedGame);
        $count = $this->getPlayerCount($users);
        $topic = 'mineseeker/channel/' . $gameAssoc;

        $data = [
            'coords'          => $coords,
            'player'          => $player,
            'bomb'            => $isBomb,
            'revealedCells'   => $revealedCells,
            'minesFound'      => $minesFound,
            'redPoints'       => $redPoints,
            'bluePoints'      => $bluePoints,
            'resign'          => null,
            'gameOver'        => $gameOver,
            'leftMines'       => $leftMines,
            'redBonusPoints'  => $bonusData['redBonusPoints'],
            'blueBonusPoints' => $bonusData['blueBonusPoints'],
            'redBonusStats'   => $bonusData['redBonusStats'],
            'blueBonusStats'  => $bonusData['blueBonusStats'],
        ];

        try {
            $this->hub->publish(new Update(
                $topic,
                json_encode([
                    'userTopicId' => $userName,
                    'channel'     => $topic,
                    'user'        => $userName,
                    'userCnt'     => $count,
                    'data'        => $data,
                ], JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            throw new RuntimeException($e->getMessage());
        }

        return $data;
    }

    /** Load the grid rows from the database as a 2-D array. */
    private function loadGrid(string $gameAssoc): array
    {
        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);
        $gridEntity = $playedGame?->grid;

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

        $grid = [];
        /** @var GridRow $row */
        foreach ($gridEntity->gridRow as $row) {
            $grid[] = $row->gridCol;
        }

        return $grid;
    }

    private function calculateBonuses(
        PlayedGame $playedGame,
        string     $player,
        array      $coords,
        array      $grid,
        array      $alreadyRevealed,
        int        $minesFound,
        int        $safeCellsFound,
        int        $redPoints,
        int        $bluePoints
    ): array {
        /** Initialize or load existing bonus stats */
        $redBonusStats = $playedGame->redBonusStats ?? [
            'blindHits'     => 0,
            'chainBest'     => 0,
            'chainCurrent'  => 0,
            'lastMineHits'  => 0,
            'edgeMines'     => 0,
            'biggestReveal' => 0,
        ];
        $blueBonusStats = $playedGame->blueBonusStats ?? [
            'blindHits'     => 0,
            'chainBest'     => 0,
            'chainCurrent'  => 0,
            'lastMineHits'  => 0,
            'edgeMines'     => 0,
            'biggestReveal' => 0,
        ];

        $redBonusPoints = $playedGame->redBonusPoints ?? 0;
        $blueBonusPoints = $playedGame->blueBonusPoints ?? 0;

        $isRed = 'red' === $player;
        $currentStats = $isRed ? $redBonusStats : $blueBonusStats;
        $bonusPoints = 0;

        /** Track biggest reveal (safe cells count) if any safe cells were revealed */
        if ($safeCellsFound > 0) {
            if ($safeCellsFound > $currentStats['biggestReveal']) {
                $currentStats['biggestReveal'] = $safeCellsFound;
            }
        }

        /** Only calculate bonuses if mines were found */
        if ($minesFound > 0) {
            /** Check Blind Hit: the clicked mine cell has no revealed numbered neighbors */
            if ($this->isBlindHit($coords, $grid, $alreadyRevealed)) {
                $currentStats['blindHits']++;
                $bonusPoints += 2;
            }

            /** Check Edge Mine: the clicked cell is on the boundary */
            if ($this->isEdgeMine($coords)) {
                $currentStats['edgeMines']++;
                $bonusPoints += 1;
            }

            /** Check Endgame Mine: when few mines remain on the board */
            $totalMinesOnBoard = 51;
            $minesRevealed = $redPoints + $bluePoints;
            $minesRemaining = $totalMinesOnBoard - $minesRevealed;

            if ($minesRemaining <= 10) {
                $currentStats['lastMineHits']++;
                $bonusPoints += 3;
            }

            /** Chain combo: increment consecutive mine-click counter */
            $currentStats['chainCurrent']++;
            if ($currentStats['chainCurrent'] > $currentStats['chainBest']) {
                $currentStats['chainBest'] = $currentStats['chainCurrent'];
            }
        } else {
            /** No mines found - reset chain for this player */
            $currentStats['chainCurrent'] = 0;
        }

        /**
         * Add points for safe cells revealed (each safe cell revealed = +0.5 bonus point)
         * Only award points if at least 2 safe cells were revealed
         */
        if ($safeCellsFound >= 2) {
            $bonusPoints += ($safeCellsFound * 0.5);
        }

        /** Update the appropriate player's stats and points */
        if ($isRed) {
            $redBonusStats = $currentStats;
            $redBonusPoints += $bonusPoints;
        } else {
            $blueBonusStats = $currentStats;
            $blueBonusPoints += $bonusPoints;
        }

        /** Persist updated stats to the database */
        $playedGame->redBonusStats = $redBonusStats;
        $playedGame->blueBonusStats = $blueBonusStats;
        $playedGame->redBonusPoints = $redBonusPoints;
        $playedGame->blueBonusPoints = $blueBonusPoints;
        $this->em->persist($playedGame);

        return [
            'redBonusPoints'  => $redBonusPoints,
            'blueBonusPoints' => $blueBonusPoints,
            'redBonusStats'   => $redBonusStats,
            'blueBonusStats'  => $blueBonusStats,
        ];
    }

    /**
     * Check if a mine was clicked with no revealed numbered neighbors (blind hit).
     * Returns true if none of the 8 surrounding cells show a number.
     */
    private function isBlindHit(array $coords, array $grid, array $alreadyRevealed): bool
    {
        $row = $coords[0];
        $col = $coords[1];
        $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];

        foreach ($dirs as [$dr, $dc]) {
            $nr = $row + $dr;
            $nc = $col + $dc;
            $key = $nr . ',' . $nc;

            /** Check if neighbor is revealed and is a numbered cell (not a mine, not hidden) */
            if (isset($alreadyRevealed[$key])) {
                $val = $grid[$nr][$nc] ?? null;

                /** If it's a number (0-8), not a mine, it's revealed and visible */
                if (is_numeric($val) && $val >= 0) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Check if a mine is on the edge/corner of the board.
     */
    private function isEdgeMine(array $coords): bool
    {
        $row = $coords[0];
        $col = $coords[1];

        return 0 === $row || $row === 15 || 0 === $col || $col === 15;
    }

    /**
     * BFS flood-fill starting at (row, col).
     * Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
     * Mines are never added to the result.
     *
     * @param array<string, true> $visited Map of "row,col" already revealed; updated in-place.
     */
    private function floodFill(array $grid, int $row, int $col, array &$visited): array
    {
        $cells = [];
        $queue = [[$row, $col]];
        $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];

        while (!empty($queue)) {
            [$r, $c] = array_shift($queue);
            $key = $r . ',' . $c;

            if (isset($visited[$key])) {
                continue;
            }
            $visited[$key] = true;

            if (!isset($grid[$r][$c])) {
                continue;
            }

            $value = $grid[$r][$c];

            // Mines are never cascade-revealed
            if ('m' === $value) {
                continue;
            }

            $cells[] = ['row' => $r, 'col' => $c, 'value' => $value];

            // Only expand neighbours for zero-cells
            if (0 === $value) {
                foreach ($dirs as [$dr, $dc]) {
                    $nr = $r + $dr;
                    $nc = $c + $dc;
                    $nKey = $nr . ',' . $nc;
                    if (!isset($visited[$nKey]) && isset($grid[$nr][$nc])) {
                        $queue[] = [$nr, $nc];
                    }
                }
            }
        }

        return $cells;
    }

    /**
     * Compute the 25-cell bomb radius (mirrors the JS getBombRadius logic).
     */
    private function getBombRadius(int $row, int $col): array
    {
        $max = 13; // grid is 16×16 (0–15); clamped centre must be in 2–13
        if (!($row > 1 && $row < 14 && $col > 1 && $col < 14)) {
            $row = max(2, min($row, $max));
            $col = max(2, min($col, $max));
        }

        return [
            [$row, $col], [$row - 2, $col - 2], [$row - 2, $col], [$row - 2, $col + 2],
            [$row, $col - 2], [$row, $col + 2], [$row + 2, $col - 2], [$row + 2, $col],
            [$row + 2, $col + 2], [$row - 2, $col + 1], [$row - 2, $col - 1],
            [$row - 1, $col - 2], [$row - 1, $col - 1], [$row - 1, $col], [$row - 1, $col + 1], [$row - 1, $col + 2],
            [$row, $col - 1], [$row, $col + 1],
            [$row + 1, $col - 2], [$row + 1, $col - 1], [$row + 1, $col], [$row + 1, $col + 1], [$row + 1, $col + 2],
            [$row + 2, $col - 1], [$row + 2, $col + 1],
        ];
    }

    /**
     * Reveal cells hit by a bomb.  Direct mine hits are revealed (flagged);
     * non-mine cells trigger a normal flood-fill (so zero-cells still cascade).
     */
    private function getBombRevealedCells(array $grid, int $row, int $col, array $alreadyRevealed): array
    {
        $bombCells = $this->getBombRadius($row, $col);
        $visited = $alreadyRevealed;
        $cells = [];

        foreach ($bombCells as [$r, $c]) {
            $key = $r . ',' . $c;
            if (isset($visited[$key]) || !isset($grid[$r][$c])) {
                continue;
            }

            if ('m' === $grid[$r][$c]) {
                $visited[$key] = true;
                $cells[] = ['row' => $r, 'col' => $c, 'value' => 'm'];
            } else {
                // flood-fill handles the zero-cascade and deduplication via $visited
                $newCells = $this->floodFill($grid, $r, $c, $visited);
                $cells = array_merge($cells, $newCells);
            }
        }

        return $cells;
    }

    /**
     * Build a "row,col" => true map from every previously saved step.
     */
    private function buildRevealedMap(PlayedGame $playedGame): array
    {
        $map = [];
        foreach ($playedGame->steps as $step) {
            foreach ($step->revealedCells ?? [] as $cell) {
                $map[$cell['row'] . ',' . $cell['col']] = true;
            }
        }

        return $map;
    }

    /**
     * Return coordinates of mines that have NOT yet been revealed.
     *
     * @param array<string, true> $alreadyRevealed
     */
    private function getLeftMines(array $grid, array $alreadyRevealed): array
    {
        $mines = [];

        foreach ($grid as $r => $row) {
            foreach ($row as $c => $value) {
                if ('m' === $value && !isset($alreadyRevealed["$r,$c"])) {
                    $mines[] = ['row' => $r, 'col' => $c];
                }
            }
        }

        return $mines;
    }

    private function getPlayerCount(array $users): int
    {
        $red = '' !== $users['red'] || '' !== $users['redAnon'] ? 1 : 0;
        $blue = '' !== $users['blue'] || '' !== $users['blueAnon'] ? 1 : 0;

        return $red + $blue;
    }

    private function saveResignToDb(string $gameAssoc, string $color): void
    {
        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);
        $playedGame->resign = $color;
        $this->em->persist($playedGame);
        $this->em->flush();
    }

    private function saveStepToDb(
        string $gameAssoc,
        array  $event,
        string $player,
        array  $revealedCells,
        int    $redPoints,
        int    $bluePoints,
        array  $bonusData = []
    ): void {
        try {
            $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);

            $step = new Step();
            $step->row = $event['coords'][0];
            $step->col = $event['coords'][1];
            $step->wBomb = (bool)$event['bomb'];
            $step->player = $player;
            $step->revealedCells = $revealedCells;
            $step->playedGame = $playedGame;
            $step->created = new DateTime();
            $this->em->persist($step);

            $playedGame->redPoints = $redPoints;
            $playedGame->bluePoints = $bluePoints;
            if ((bool)$event['bomb']) {
                if ('red' === $player) {
                    $playedGame->redExplodedBomb = true;
                } elseif ('blue' === $player) {
                    $playedGame->blueExplodedBomb = true;
                }
            }
            $playedGame->updated = new DateTime();

            /** Bonus data is already persisted in calculateBonuses, but we ensure it's up to date */
            if (!empty($bonusData)) {
                $playedGame->redBonusPoints = $bonusData['redBonusPoints'];
                $playedGame->blueBonusPoints = $bonusData['blueBonusPoints'];
                $playedGame->redBonusStats = $bonusData['redBonusStats'];
                $playedGame->blueBonusStats = $bonusData['blueBonusStats'];
            }

            $this->em->persist($playedGame);
            $this->em->flush();
        } catch (Exception $e) {
            $this->logger->error($e->getMessage());
        }
    }

    private function saveUserToDb(string $gameAssoc, string $userName, int $count): array
    {
        $playedGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($gameAssoc);

        null !== $this->security->getUser()
            ? $this->saveRegisteredUser($userName, $count, $playedGame)
            : $this->saveAnonUser($userName, $count, $playedGame);

        $this->em->persist($playedGame);
        $this->em->flush();

        return $this->getUserCollection($playedGame);
    }

    private function saveRegisteredUser(string $userName, int $count, PlayedGame $playedGame): void
    {
        /** @var User $user */
        $user = $this->userRepository->findOneByUsername($userName);

        try {
            if ($count === 1) {
                $random = random_int(0, 1);
                !$random ? $playedGame->red = $user : $playedGame->blue = $user;
            } else {
                null === $playedGame->red && null === $playedGame->redAnon
                    ? $playedGame->red = $user
                    : $playedGame->blue = $user;
            }

            $this->em->persist($playedGame);
        } catch (Exception $e) {
            $this->logger->error($e->getMessage());
        }
    }

    private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void
    {
        try {
            $anon = new Gamer();
            $anon->userName = $userName;
            $anon->ip = $this->requestStack->getCurrentRequest()->getClientIp();
            $anon->country = $this->extractCountry();
            $anon->userAgent = $this->requestStack->getCurrentRequest()->headers->get('User-Agent');
            $anon->connTimestamp = new DateTime();

            $this->em->persist($anon);

            if ($count === 1) {
                $random = random_int(0, 1);
                !$random ? $playedGame->redAnon = $anon : $playedGame->blueAnon = $anon;
            } else {
                null === $playedGame->red && null === $playedGame->redAnon
                    ? $playedGame->redAnon = $anon
                    : $playedGame->blueAnon = $anon;
            }

            $this->em->persist($playedGame);
        } catch (Exception $e) {
            $this->logger->error($e->getMessage());
        }
    }

    private function getUserCollection(PlayedGame $playedGame): array
    {
        $redUser = $playedGame->red;
        $blueUser = $playedGame->blue;

        return [
            'red'        => null !== $redUser ? $redUser->getUsername() : '',
            'blue'       => null !== $blueUser ? $blueUser->getUsername() : '',
            'redAnon'    => null !== $playedGame->redAnon ? $playedGame->redAnon->userName : '',
            'blueAnon'   => null !== $playedGame->blueAnon ? $playedGame->blueAnon->userName : '',
            'redAvatar'  => null !== $redUser && null !== $redUser->avatarPath
                ? $this->cacheManager->generateUrl($redUser->avatarPath, 'avatar_thumb')
                : null,
            'blueAvatar' => null !== $blueUser && null !== $blueUser->avatarPath
                ? $this->cacheManager->generateUrl($blueUser->avatarPath, 'avatar_thumb')
                : null,
        ];
    }

    public function publishChallenge(string $targetGameAssoc, string $challengerGameAssoc): void
    {
        $challengerGame = $this->em->getRepository(PlayedGame::class)->findOneByGameAssoc($challengerGameAssoc);
        $challengerName = 'Unknown';

        if (null !== $challengerGame) {
            $users = $this->getUserCollection($challengerGame);
            $challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
        }

        try {
            $this->hub->publish(new Update(
                'mineseeker/channel/' . $targetGameAssoc,
                json_encode([
                    'type'                => 'challenge',
                    'challengerName'      => $challengerName,
                    'challengerGameAssoc' => $challengerGameAssoc,
                ], JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            $this->logger->error('Challenge publish error: ' . $e->getMessage());
        }
    }

    public function publishChallengeResponse(string $challengerGameAssoc, bool $accepted, string $targetGameAssoc): void
    {
        try {
            $this->hub->publish(new Update(
                'mineseeker/channel/' . $challengerGameAssoc,
                json_encode([
                    'type'            => 'challenge-response',
                    'accepted'        => $accepted,
                    'targetGameAssoc' => $targetGameAssoc,
                ], JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            $this->logger->error('Challenge response publish error: ' . $e->getMessage());
        }
    }

    public function publishHeartbeat(string $gameAssoc, string $color): void
    {
        try {
            $this->hub->publish(new Update(
                'mineseeker/channel/' . $gameAssoc,
                json_encode([
                    'type'  => 'heartbeat',
                    'color' => $color,
                    'ts'    => (int)(microtime(true) * 1000),
                ], JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            $this->logger->error('Heartbeat publish error: ' . $e->getMessage());
        }
    }

    private function publishToLobby(array $data): void
    {
        try {
            $this->hub->publish(new Update(
                'mineseeker/lobby',
                json_encode($data, JSON_THROW_ON_ERROR)
            ));
        } catch (JsonException $e) {
            $this->logger->error('Lobby publish error: ' . $e->getMessage());
        }
    }

    private function extractCountry(): ?string
    {
        /** Common headers used by CDNs and proxies to pass country information */
        $countryHeaders = [
            'CF-IPCountry',               // Cloudflare
            'CloudFront-Viewer-Country',  // AWS CloudFront
            'X-Country-Code',             // Custom header
            'X-Geoip-Country',            // Generic GeoIP header
        ];

        foreach ($countryHeaders as $header) {
            $country = $this->requestStack->getCurrentRequest()->headers->get($header);

            if (empty($country)) {
                continue;
            }

            return substr($country, 0, 100);
        }

        return null;
    }
}
