<?php

declare(strict_types=1);

/*
 * This file is a part of the DiscordPHP project.
 *
 * Copyright (c) 2015-2022 David Cole <david.cole1340@gmail.com>
 * Copyright (c) 2020-present Valithor Obsidion <valithor@discordphp.org>
 *
 * This file is subject to the MIT license that is bundled
 * with this source code in the LICENSE.md file.
 */

namespace Discord;

use Discord\Exceptions\IntentException;
use Discord\Factory\Factory;
use Discord\Helpers\BigInt;
use Discord\Helpers\CacheConfig;
use Discord\Helpers\Collection;
use Discord\Helpers\ExCollectionInterface;
use Discord\Helpers\RegisteredCommand;
use Discord\Http\Drivers\React;
use Discord\Http\Endpoint;
use Discord\Http\Http;
use Discord\Parts\Channel\Channel;
use Discord\Parts\Gateway\GetGatewayBot;
use Discord\Parts\Gateway\SessionStartLimit;
use Discord\Parts\Guild\Guild;
use Discord\Parts\OAuth\Application;
use Discord\Parts\Part;
use Discord\Parts\User\Activity;
use Discord\Parts\User\Client;
use Discord\Parts\User\Member;
use Discord\Parts\User\User;
use Discord\Parts\WebSockets\VoiceServerUpdate;
use Discord\Parts\WebSockets\VoiceStateUpdate;
use Discord\Repository\AbstractRepository;
use Discord\Repository\EmojiRepository;
use Discord\Repository\GuildRepository;
use Discord\Repository\LobbyRepository;
use Discord\Repository\PrivateChannelRepository;
use Discord\Repository\SoundRepository;
use Discord\Repository\UserRepository;
use Discord\Voice\Manager;
use Discord\Voice\Region;
use Discord\Voice\VoiceClient;
use Discord\WebSockets\Event;
use Discord\WebSockets\Events\Data\GuildMembersChunkData;
use Discord\WebSockets\Events\GuildCreate;
use Discord\WebSockets\Payload;
use Discord\WebSockets\Handlers;
use Discord\WebSockets\Intents;
use Discord\WebSockets\Op;
use Evenement\EventEmitterTrait;
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger as Monolog;
use Psr\Log\LoggerInterface;
use Ratchet\Client\Connector;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\Message;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Socket\Connector as SocketConnector;
use Symfony\Component\OptionsResolver\OptionsResolver;

use function React\Async\coroutine;
use function React\Promise\all;
use function React\Promise\reject;
use function React\Promise\resolve;

/**
 * The Discord client class.
 *
 * @version 10.0.0
 *
 * @property string      $id            The unique identifier of the client.
 * @property string      $username      The username of the client.
 * @property string      $password      The password of the client (if they have provided it).
 * @property string      $email         The email of the client.
 * @property bool        $verified      Whether the client has verified their email.
 * @property string      $avatar        The avatar URL of the client.
 * @property string      $avatar_hash   The avatar hash of the client.
 * @property string      $discriminator The unique discriminator of the client.
 * @property bool        $bot           Whether the client is a bot.
 * @property User        $user          The user instance of the client.
 * @property Application $application   The OAuth2 application of the bot.
 *
 * @property EmojiRepository          $emojis
 * @property GuildRepository          $guilds
 * @property LobbyRepository          $lobbies
 * @property PrivateChannelRepository $private_channels
 * @property SoundRepository          $sounds
 * @property UserRepository           $users
 */
class Discord
{
    use EventEmitterTrait;

    /**
     * The gateway version the client uses.
     *
     * @var int Gateway version.
     */
    public const GATEWAY_VERSION = 10;

    /**
     * The client version.
     *
     * @var string Version.
     */
    public const VERSION = 'v10.46.0';

    public const REFERRER = 'https://github.com/discord-php/DiscordPHP';

    /**
     * The logger.
     *
     * @var LoggerInterface Logger.
     */
    protected $logger;

    /**
     * An array of loggers for voice clients.
     *
     * @var ?LoggerInterface[] Loggers.
     */
    protected $voiceLoggers = [];

    /**
     * An array of options passed to the client.
     *
     * @var array Options.
     */
    protected $options;

    /**
     * The authentication token.
     *
     * @var string Token.
     */
    protected $token;

    /**
     * The ReactPHP event loop.
     *
     * @var LoopInterface Event loop.
     */
    protected $loop;

    /**
     * The WebSocket client factory.
     *
     * @var Connector Factory.
     */
    protected $wsFactory;

    /**
     * The WebSocket instance.
     *
     * @var WebSocket Instance.
     */
    protected $ws;

    /**
     * The event handlers.
     *
     * @var Handlers Handlers.
     */
    protected $handlers;

    /**
     * The packet sequence that the client is up to.
     *
     * @var int Sequence.
     */
    protected $seq;

    /**
     * Whether the client is currently reconnecting.
     *
     * @var bool Reconnecting.
     */
    protected $reconnecting = false;

    /**
     * Whether the client is connected to the gateway.
     *
     * @var bool Connected.
     */
    protected $connected = false;

    /**
     * Whether the client is closing.
     *
     * @var bool Closing.
     */
    protected $closing = false;

    /**
     * The session ID of the current session.
     *
     * @var string Session ID.
     */
    protected $sessionId;

    /**
     * An array of guild IDs paired to their voice session IDs.
     *
     * @var string[] Voice Sessions.
     */
    public array $voice_sessions = [];

    /**
     * An array of large guilds that need to be requested for members.
     *
     * @var array Large guilds.
     */
    protected $largeGuilds = [];

    /**
     * An array of large guilds that have been requested for members.
     *
     * @var array Large guilds.
     */
    protected $largeSent = [];

    /**
     * An array of unparsed packets.
     *
     * @var array Unparsed packets.
     */
    protected $unparsedPackets = [];

    /**
     * How many times the client has reconnected.
     *
     * @var int Reconnect count.
     */
    protected $reconnectCount = 0;

    /**
     * The heartbeat interval.
     *
     * @var int Heartbeat interval.
     */
    protected $heartbeatInterval;

    /**
     * The timer that sends the heartbeat packet.
     *
     * @var TimerInterface Timer.
     */
    protected $heartbeatTimer;

    /**
     * The timer that resends the heartbeat packet if a HEARTBEAT_ACK packet is
     * not received in 5 seconds.
     *
     * @var TimerInterface Timer.
     */
    protected $heartbeatAckTimer;

    /**
     * The time that the last heartbeat packet was sent.
     *
     * @var int Epoch time.
     */
    protected $heartbeatTime;

    /**
     * Whether `init` has been emitted.
     *
     * @var bool Emitted.
     */
    protected $emittedInit = false;

    /**
     * The gateway URL that the WebSocket client will connect to.
     *
     * @var string Gateway URL.
     */
    protected $gateway;

    /**
     * The resume_gateway_url that the WebSocket client will reconnect to.
     *
     * @var string resume_gateway_url URL.
     */
    protected $resume_gateway_url;

    /**
     * What encoding the client will use, either `json` or `etf`.
     *
     * @var string Encoding.
     */
    protected $encoding = 'json';

    /**
     * Gateway compressed message payload buffer.
     *
     * @var string Buffer.
     */
    protected $payloadBuffer = '';

    /**
     * zlib decompressor.
     *
     * @var \InflateContext|false
     */
    protected $zlibDecompressor;

    /**
     * Tracks the number of payloads the client has sent in the past 60 seconds.
     *
     * @var int
     */
    protected $payloadCount = 0;

    /**
     * Payload count reset timer.
     *
     * @var TimerInterface
     */
    protected $payloadTimer;

    /**
     * The HTTP client.
     *
     * @var Http Client.
     */
    protected $http;

    /**
     * The part/repository factory.
     *
     * @var Factory Part factory.
     */
    protected $factory;

    /**
     * The cache configuration.
     *
     * @var CacheConfig[] Cache configuration.
     */
    protected $cacheConfig;

    /**
     * The collection class implementing ExCollectionInterface.
     *
     * @var string Collection class.
     */
    protected $collectionClass;

    /**
     * The Client class.
     *
     * @var Client Discord client.
     */
    protected $client;

    /**
     * An array of registered slash commands.
     *
     * @var RegisteredCommand[]
     */
    protected $application_commands;

    /**
     * The voice handler, of clients and packets.
     *
     * @var Manager|null
     */
    public ?Manager $voice = null;

    /**
     * The transport compression setting.
     *
     * @var bool Whether to use transport compression.
     */
    protected $useTransportCompression;

    /**
     * The payload compression setting.
     *
     * @var bool Whether to use payload compression.
     */
    protected $usePayloadCompression;

    /**
     * An array of valid regions.
     *
     * @var ExCollectionInterface<Region>|null
     */
    protected $regions;

    /**
     * Creates a Discord client instance.
     *
     * @param  array             $options Array of options.
     * @throws IntentException
     * @throws \RuntimeException
     */
    public function __construct(array $options = [])
    {
        // x86 need gmp extension for big integer operation
        if (PHP_INT_SIZE === 4 && ! BigInt::init()) {
            throw new \RuntimeException('ext-gmp is not loaded, it is required for 32-bits (x86) PHP.');
        }

        $options = $this->resolveOptions($options);

        $this->options = $options;
        $this->token = $options['token'];
        $this->loop = $options['loop'];
        $this->logger = $options['logger'];

        if (! in_array(php_sapi_name(), ['cli', 'micro'])) {
            $this->logger->critical('DiscordPHP will not run on a webserver. Please use PHP CLI to run a DiscordPHP bot.');
        }

        $this->logger->debug('Initializing DiscordPHP '.self::VERSION.' (DiscordPHP-Http: '.Http::VERSION.' & Gateway: v'.self::GATEWAY_VERSION.') on PHP '.PHP_VERSION);

        $this->cacheConfig = $options['cache'];
        if ($cacheConfig = $this->getCacheConfig()) {
            $this->logger->warning('Attached experimental CacheInterface: '.get_class($cacheConfig->interface));
        }

        $this->collectionClass = $options['collection'];
        if ($this->collectionClass !== Collection::class) {
            $this->logger->warning('Attached experimental Collection: '.$this->collectionClass);
        }

        $connector = new SocketConnector($options['socket_options'], $this->loop);
        $this->wsFactory = new Connector($this->loop, $connector);
        $this->handlers = new Handlers();

        static $important_events = [
            Event::GUILD_CREATE,
            Event::GUILD_DELETE,
            Event::RESUMED,
            Event::READY,
            Event::GUILD_MEMBERS_CHUNK,
            // These are critical for voice functionality such as connecting to voice channels and seeing what memebers are in a voice channel
            Event::VOICE_STATE_UPDATE => 'handleVoiceStateUpdate',
            Event::VOICE_SERVER_UPDATE => 'handleVoiceServerUpdate',
        ];

        if ($disabledImportant = array_values(array_intersect($important_events, $options['disabledEvents']))) {
            $this->logger->warning('Critical events have been disabled, performance may be affected', ['events' => implode(', ', $disabledImportant)]);
        }

        foreach ($options['disabledEvents'] as $event) {
            $this->handlers->removeHandler($event);
        }

        $this->http = new Http(
            'Bot '.$this->token,
            $this->loop,
            $this->options['logger'],
            new React($this->loop, $options['socket_options'])
        );

        $this->factory = new Factory($this);
        $this->client = $this->factory->part(Client::class, []);

        $this->useTransportCompression = $options['useTransportCompression'];
        $this->usePayloadCompression = $options['usePayloadCompression'];
        $this->connectWs();
    }

    /**
     * Handles `VOICE_SERVER_UPDATE` packets.
     *
     * @param Payload $data Packet data.
     */
    protected function handleVoiceServerUpdate(object $data): void
    {
        if (isset($this->voice->clients[$data->d->guild_id])) {
            $this->logger->debug('voice server update received', ['guild' => $data->d->guild_id, 'data' => $data->d]);
            $this->voice->clients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d);
        }
    }

    /**
     * Handles `RESUME` packets.
     *
     * @param Payload $data Packet data.
     */
    protected function handleResume(object $data): void
    {
        $this->logger->info('websocket reconnected to discord');
        $this->emit('reconnected', [$this]);
    }

    /**
     * Handles `READY` packets.
     *
     * @param Payload $data Packet data.
     *
     * @return false|void
     * @throws \Exception
     */
    protected function handleReady(object $data)
    {
        $this->logger->debug('ready packet received');

        $content = $data->d;

        // Check if we received resume_gateway_url
        if (isset($content->resume_gateway_url)) {
            $this->resume_gateway_url = $content->resume_gateway_url;
            $this->logger->debug('resume_gateway_url received', ['url' => $content->resume_gateway_url]);
        }

        // If this is a reconnect we don't want to
        // reparse the READY packet as it would remove
        // all the data cached.
        if ($this->reconnecting) {
            $this->reconnecting = false;
            $this->logger->debug('websocket reconnected to discord through identify');
            $this->emit('reconnected', [$this]);

            return;
        }

        $this->emit('trace', $data->d->_trace);
        $this->logger->debug('discord trace received', ['trace' => $content->_trace]);

        // Set up the user account
        $this->client->fill((array) $content->user);
        $this->client->created = true;
        $this->sessionId = $content->session_id;

        $this->logger->debug('client created and session id stored', ['session_id' => $content->session_id, 'user' => $this->client->user->getPublicAttributes()]);

        // Guilds
        $event = new GuildCreate($this);

        $unavailable = [];

        foreach ($content->guilds as $guild) {
            /** @var PromiseInterface */
            $promise = coroutine([$event, 'handle'], $guild);

            $promise->then(function ($d) use (&$unavailable) {
                if (! empty($d->unavailable)) {
                    $unavailable[$d->id] = $d->unavailable;
                }
            });
        }

        $this->logger->info('stored guilds', ['count' => $this->guilds->count(), 'unavailable' => count($unavailable)]);

        if (count($unavailable) < 1) {
            return $this->ready();
        }

        $guildLoad = new Deferred();

        $onGuildCreate = function ($guild) use (&$unavailable, $guildLoad) {
            if (empty($guild->unavailable)) {
                $this->logger->debug('guild available', ['guild' => $guild->id, 'unavailable' => count($unavailable)]);
                unset($unavailable[$guild->id]);
            }
            if (count($unavailable) < 1) {
                $guildLoad->resolve(null);
            }
        };
        $this->on(Event::GUILD_CREATE, $onGuildCreate);

        $onGuildDelete = function ($guild) use (&$unavailable, $guildLoad) {
            if (! isset($guild->unavailable)) {
                // Rare Undocumented case, $guild->unavailable is missing but actually unavailable (perhaps kicked then unavailable/deleted)
                $this->logger->debug('guild deleted', ['guild' => $guild->id, 'unavailable' => count($unavailable)]);
                unset($unavailable[$guild->id]);
            } elseif ($guild->unavailable) {
                $this->logger->debug('guild unavailable', ['guild' => $guild->id, 'unavailable' => count($unavailable)]);
                unset($unavailable[$guild->id]);
            }
            if (count($unavailable) < 1) {
                $guildLoad->resolve(null);
            }
        };
        $this->on(Event::GUILD_DELETE, $onGuildDelete);

        $guildLoad->promise()->finally(function () use ($onGuildCreate, $onGuildDelete) {
            $this->removeListener(Event::GUILD_CREATE, $onGuildCreate);
            $this->removeListener(Event::GUILD_DELETE, $onGuildDelete);
            $this->logger->info('all guilds are now available', ['count' => $this->guilds->count()]);

            $this->setupChunking();
        });

        if (in_array(Event::GUILD_CREATE, $this->options['disabledEvents'])) {
            $this->ready();
        } else {
            // Emit ready after 60 seconds
            $this->loop->addTimer(60, fn () => $this->ready());
        }
    }

    /**
     * Sets guild member chunking up.
     *
     * @return false|void
     */
    protected function setupChunking()
    {
        if ($this->options['loadAllMembers'] === false) {
            $this->logger->info('loadAllMembers option is disabled, not setting chunking up');

            return $this->ready();
        }

        $this->loop->addPeriodicTimer(5, fn () => $this->checkForChunks);
        $this->logger->info('set up chunking, checking for chunks every 5 seconds');
        $this->checkForChunks();
    }

    /**
     * Checks for any large guilds that need to be chunked.
     */
    protected function checkForChunks(): void
    {
        if ((count($this->largeGuilds) < 1) && (count($this->largeSent) < 1)) {
            $this->ready();

            return;
        }

        if (count($this->largeGuilds) < 1) {
            $this->logger->debug('unprocessed chunks', $this->largeSent);

            return;
        }

        if (is_array($this->options['loadAllMembers'])) {
            foreach ($this->largeGuilds as $key => $guild) {
                if (! in_array($guild, $this->options['loadAllMembers'])) {
                    $this->logger->debug('not fetching members for guild ID '.$guild);
                    unset($this->largeGuilds[$key]);
                }
            }
        }

        $chunks = array_chunk($this->largeGuilds, 50);
        $this->logger->debug('sending '.count($chunks).' chunks with '.count($this->largeGuilds).' large guilds overall');
        $this->largeSent = array_merge($this->largeGuilds, $this->largeSent);
        $this->largeGuilds = [];

        $this->sendChunks($chunks);
    }

    /**
     * Sends chunks of guild member requests.
     *
     * @param array &$chunks
     */
    protected function sendChunks(array &$chunks = []): void
    {
        $chunk = array_pop($chunks);

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

        $this->logger->debug('sending chunk with '.count($chunk).' large guilds');

        foreach ($chunk as $guild_id) {
            $this->requestGuildMembers(
                $guild_id,
                [
                    'query' => '',
                    'limit' => 0,
                ]
            );
        }
        $this->loop->addTimer(1, fn () => $this->sendChunks($chunks));
    }

    /**
     * Handles `VOICE_STATE_UPDATE` packets.
     *
     * @param Payload $data Packet data.
     */
    protected function handleVoiceStateUpdate(object $data): void
    {
        /** @var VoiceStateUpdate */
        $voiceStateUpdate = $this->factory->part(VoiceStateUpdate::class, (array) $data->d, true);

        $this->logger->debug('voice state update received', ['guild' => $voiceStateUpdate->guild_id, 'data' => $voiceStateUpdate]);
        if (isset($this->voice, $this->voice->clients[$voiceStateUpdate->guild_id])) {
            /** @var VoiceClient */
            $client = $this->voice->clients[$voiceStateUpdate->guild_id];
            $client->handleVoiceStateUpdate($voiceStateUpdate);
        }
    }

    /**
     * Handles WebSocket connections received by the client.
     *
     * @param WebSocket $ws WebSocket client.
     */
    public function handleWsConnection(WebSocket $ws): void
    {
        $this->ws = $ws;
        $this->connected = true;

        $this->logger->info('websocket connection has been created');

        $this->payloadCount = 0;
        $this->payloadTimer = $this->loop->addPeriodicTimer(60, function () {
            $this->logger->debug('resetting payload count', ['count' => $this->payloadCount]);
            $this->payloadCount = 0;
            $this->emit('payload_count_reset');
        });

        $ws->on('message', [$this, 'handleWsMessage']);
        $ws->on('close', [$this, 'handleWsClose']);
        $ws->on('error', [$this, 'handleWsError']);
    }

    /**
     * Handles WebSocket messages received by the client.
     *
     * @param Message $message Message object.
     */
    public function handleWsMessage(Message $message): void
    {
        $payload = $message->getPayload();

        if ($message->isBinary()) {
            if ($this->zlibDecompressor) {
                $this->payloadBuffer .= $payload;

                if ($message->getPayloadLength() < 4 || substr($payload, -4) !== "\x00\x00\xff\xff") {
                    return;
                }

                if (($inflated = inflate_add($this->zlibDecompressor, $this->payloadBuffer)) !== false) {
                    $this->processWsMessage($inflated);
                } else {
                    $this->logger->error('failed to inflate payload buffer', ['payload' => $payload, 'buffer' => $this->payloadBuffer, 'payload hex' => bin2hex($payload), 'buffer hex' => bin2hex($this->payloadBuffer)]);
                }
                $this->payloadBuffer = '';
            } else {
                $this->processWsMessage(zlib_decode($payload));
            }
        } else {
            $this->processWsMessage($payload);
        }
    }

    /**
     * Process WebSocket message payloads.
     *
     * @param string $data Message payload.
     */
    protected function processWsMessage(string $data): void
    {
        if (! $decoded = json_decode($data)) {
            $this->logger->warning('failed to decode websocket message', ['payload' => $data]);

            // @todo: handle invalid payload (reconnect), throw exception, or ignore?
            return;
        }

        $this->emit('raw', [$decoded, $this]);

        if (isset($decoded->s)) {
            $this->seq = $decoded->s;
        }

        static $rawOp = [
            Op::OP_DISPATCH => 'handleDispatch',
        ];

        static $op = [
            Op::OP_HEARTBEAT => 'handleHeartbeat',
            Op::OP_RECONNECT => 'handleReconnect',
            Op::OP_INVALID_SESSION => 'handleInvalidSession',
            Op::OP_HELLO => 'handleHello',
            Op::OP_HEARTBEAT_ACK => 'handleHeartbeatAck',
        ];

        isset($rawOp[$decoded->op])
            ? $this->{$rawOp[$decoded->op]}($decoded)
            : (isset($op[$decoded->op])
                ? $this->{$op[$decoded->op]}(Payload::new($decoded->op, $decoded->d, $decoded->s, $decoded->t))
                : $this->logger->debug('unknown op code', ['op' => $decoded->op, 'payload' => $decoded]));
    }

    /**
     * Handles WebSocket closes received by the client.
     *
     * @param int    $op     The close code.
     * @param string $reason The reason the WebSocket closed.
     */
    public function handleWsClose(int $op, string $reason): void
    {
        $this->connected = false;

        if (null !== $this->heartbeatTimer) {
            $this->loop->cancelTimer($this->heartbeatTimer);
            $this->heartbeatTimer = null;
        }

        if (null !== $this->heartbeatAckTimer) {
            $this->loop->cancelTimer($this->heartbeatAckTimer);
            $this->heartbeatAckTimer = null;
        }

        if (null !== $this->payloadTimer) {
            $this->loop->cancelTimer($this->payloadTimer);
            $this->payloadTimer = null;
        }

        if ($this->closing) {
            return;
        }

        $this->logger->warning('websocket closed', ['op' => $op, 'reason' => $reason]);

        if (in_array($op, Op::getCriticalCloseCodes())) {
            $this->logger->error('not reconnecting - critical op code', ['op' => $op, 'reason' => $reason]);
        } else {
            $this->logger->warning('reconnecting in 2 seconds');

            $this->loop->addTimer(2, function () {
                ++$this->reconnectCount;
                $this->reconnecting = true;
                $this->logger->info('starting reconnect', ['reconnect_count' => $this->reconnectCount]);
                $this->connectWs();
            });
        }
    }

    /**
     * Handles WebSocket errors received by the client.
     *
     * @param \Exception $e The error.
     */
    public function handleWsError(\Exception $e): void
    {
        // Pawl pls
        if (strpos($e->getMessage(), 'Tried to write to closed stream') !== false) {
            return;
        }

        $this->logger->error('websocket error', ['e' => $e->getMessage()]);
        $this->emit('error', [$e, $this]);
        $this->ws->close(Op::CLOSE_ABNORMAL, $e->getMessage());
    }

    /**
     * Handles cases when the WebSocket cannot be connected to.
     *
     * @param \Throwable $e
     */
    public function handleWsConnectionFailed(\Throwable $e): void
    {
        $this->logger->error('failed to connect to websocket, retry in 5 seconds', ['e' => $e->getMessage()]);

        $this->loop->addTimer(5, function () {
            $this->connectWs();
        });
    }

    /**
     * Handles dispatch events received by the WebSocket.
     *
     * @param object $data Packet data.
     */
    protected function handleDispatch(object $data): void
    {
        $hData = $this->handlers->getHandler($data->t);

        if (null === $hData) {
            static $handlers = [
                Event::VOICE_STATE_UPDATE => 'handleVoiceStateUpdate',
                Event::VOICE_SERVER_UPDATE => 'handleVoiceServerUpdate',
                Event::RESUMED => 'handleResume',
                Event::READY => 'handleReady',
                Event::GUILD_MEMBERS_CHUNK => 'handleGuildMembersChunk',
            ];

            if (isset($handlers[$data->t]) && ! in_array($data->t, $this->options['disabledEvents'])) {
                $this->{$handlers[$data->t]}(Payload::new($data->op, $data->d, $data->s, $data->t));
            }

            return;
        }

        /** @var Event */
        $handler = new $hData['class']($this);

        $deferred = new Deferred();
        $deferred->promise()->then(function ($d) use ($data, $hData) {
            if (is_array($d) && count($d) === 2) {
                list($new, $old) = $d;
            } else {
                $new = $d;
                $old = null;
            }

            $this->emit($data->t, [$new, $this, $old]);

            foreach ($hData['alternatives'] as $alternative) {
                $this->emit($alternative, [$d, $this]);
            }

            if ($data->t === Event::MESSAGE_CREATE && mentioned($this->client->user, $new)) {
                $this->emit('mention', [$new, $this, $old]);
            }
        }, function ($e) use ($data) {
            if ($e instanceof \Error) {
                throw $e;
            } elseif ($e instanceof \Exception) {
                $this->logger->error('exception while trying to handle dispatch packet', ['packet' => $data->t, 'exception' => $e]);
            } else {
                $this->logger->warning('rejection while trying to handle dispatch packet', ['packet' => $data->t, 'rejection' => $e]);
            }
        });

        $parse = [
            Event::GUILD_CREATE,
            Event::GUILD_DELETE,
        ];

        if (! $this->emittedInit && (! in_array($data->t, $parse))) {
            $this->unparsedPackets[] = function () use (&$handler, &$deferred, &$data) {
                /** @var PromiseInterface */
                $promise = coroutine([$handler, 'handle'], $data->d);
                $promise->then([$deferred, 'resolve'], [$deferred, 'reject']);
            };
        } else {
            /** @var PromiseInterface */
            $promise = coroutine([$handler, 'handle'], $data->d);
            $promise->then([$deferred, 'resolve'], [$deferred, 'reject']);
        }
    }

    /**
     * Handles `GUILD_MEMBERS_CHUNK` packets.
     *
     * @param  Payload    $data Packet data.
     * @throws \Exception
     */
    protected function handleGuildMembersChunk(Payload $data): void
    {
        /** @var GuildMembersChunkData $chunkData */
        $chunkData = $data->d;

        if (! $guild = $this->guilds->get('id', $chunkData->guild_id)) {
            $this->logger->warning('not chunking member, Guild is not cached.', ['guild_id' => $chunkData->guild_id]);

            return;
        }

        $this->logger->debug('received guild member chunk', ['guild_id' => $chunkData->guild_id, 'guild_name' => $guild->name, 'chunk_count' => count($chunkData->members), 'member_collection' => $guild->members->count(), 'member_count' => $guild->member_count, 'progress' => [$chunkData->chunk_index + 1, $chunkData->chunk_count]]);

        $count = $skipped = 0;
        $await = [];
        foreach ($chunkData->members as $member) {
            $userId = $member->user->id;
            if ($guild->members->offsetExists($userId)) {
                continue;
            }

            $member = (array) $member;
            $member['guild_id'] = $chunkData->guild_id;
            $member['status'] = 'offline';
            $await[] = $guild->members->cache->set($userId, $this->factory->part(Member::class, $member, true));

            if (! $this->users->offsetExists($userId)) {
                $await[] = $this->users->cache->set($userId, $this->users->create($member['user'], true));
            }

            ++$count;
        }

        all($await)->then(function () use ($guild, $count, $skipped) {
            $membersCount = $guild->members->count();
            $this->logger->debug('parsed '.$count.' members (skipped '.$skipped.')', ['repository_count' => $membersCount, 'actual_count' => $guild->member_count]);

            if ($membersCount >= $guild->member_count) {
                $this->largeSent = array_diff($this->largeSent, [$guild->id]);

                $this->logger->debug('all users have been loaded', ['guild' => $guild->id, 'member_collection' => $membersCount, 'member_count' => $guild->member_count]);
            }

            if (count($this->largeSent) < 1) {
                $this->ready();
            }
        });
    }

    /**
     * Handles heartbeat packets received by the client.
     *
     * @param object $data Packet data.
     */
    protected function handleHeartbeat(object $data): void
    {
        $this->logger->debug('received heartbeat', ['seq' => $data->d]);

        $payload = Payload::new(
            Op::OP_HEARTBEAT,
            $data->d
        );

        $this->send($payload);
    }

    /**
     * Handles reconnect packets received by the client.
     *
     * @param object $data Packet data.
     */
    protected function handleReconnect(object $data): void
    {
        $this->logger->warning('received opcode 7 for reconnect');

        $this->ws->close(
            Op::CLOSE_UNKNOWN_ERROR,
            'gateway redirecting - opcode 7'
        );
    }

    /**
     * Handles invalid session packets received by the client.
     *
     * @param object $data Packet data.
     */
    protected function handleInvalidSession(object $data): void
    {
        $this->logger->warning('invalid session, re-identifying', ['resumable' => $data->d]);

        $this->loop->addTimer(2, function () use ($data) {
            $data->d
                ? $this->resume(
                    $this->token,
                    $this->sessionId,
                    $this->seq
                )
                : $this->identify();
        });
    }

    /**
     * Handles HELLO packets received by the websocket.
     *
     * @param object $data Packet data.
     */
    protected function handleHello(object $data): void
    {
        $this->logger->info('received hello');
        $this->setupHeartbeat($data->d->heartbeat_interval);
        $this->identify();
    }

    /**
     * Handles heartbeat ACK packets received by the client.
     *
     * @param object $data Packet data.
     */
    protected function handleHeartbeatAck(object $data): void
    {
        $received = microtime(true);
        $diff = $received - $this->heartbeatTime;
        $time = $diff * 1000;

        if (null !== $this->heartbeatAckTimer) {
            $this->loop->cancelTimer($this->heartbeatAckTimer);
            $this->heartbeatAckTimer = null;
        }

        $this->emit('heartbeat-ack', [$time, $this]);
        $this->logger->debug('received heartbeat ack', ['response_time' => $time]);
    }

    /**
     * Used to trigger the initial handshake with the gateway.
     *
     * @link https://discord.com/developers/docs/events/gateway#identifying
     */
    public function identify(): void
    {
        $data = [
            'token' => $this->token,
            'properties' => [
                'os' => PHP_OS,
                'browser' => $this->http->getUserAgent(),
                'device' => $this->http->getUserAgent(),
                'referrer' => self::REFERRER,
                'referring_domain' => self::REFERRER,
            ],
            'compress' => $this->usePayloadCompression,
            'intents' => $this->options['intents'],
        ];

        if (isset($this->options['large_threshold'])) {
            $data['large_threshold'] = $this->options['large_threshold'];
        }

        if (isset($this->options['shard'])) {
            $data['shard'] = $this->options['shard'];
        } elseif (isset($this->options['shard_id'], $this->options['num_shards'])) {
            $data['shard'] = [
                (int) $this->options['shard_id'],
                (int) $this->options['num_shards'],
            ];
        } elseif (isset($this->options['shardId'], $this->options['shardCount'])) {
            $data['shard'] = [
                (int) $this->options['shardId'], // shard_id
                (int) $this->options['shardCount'], // num_shards
            ];
        }

        if (isset($this->options['presence'])) {
            $data['presence'] = $this->options['presence'];
        }

        $payload = Payload::new(
            Op::OP_IDENTIFY,
            $data,
        );

        $this->logger->info('identifying', ['payload' => $payload->__debugInfo()]);

        $this->send($payload);
    }

    /**
     * Used to replay missed events when a disconnected client resumes.
     *
     * @link https://discord.com/developers/docs/events/gateway-events#resume
     * @link https://discord.com/developers/docs/events/gateway#resuming
     *
     * @since 10.19.0
     */
    public function resume(#[\SensitiveParameter] string $token, string $session_id, int $seq): void
    {
        $payload = Payload::new(
            Op::OP_RESUME,
            [
                'session_id' => $session_id,
                'seq' => $seq,
                'token' => $token,
            ],
        );

        $this->logger->info('resuming connection', ['payload' => $payload->__debugInfo()]);

        $this->send($payload);
    }

    /**
     * Sends a heartbeat packet to the Discord gateway.
     */
    public function heartbeat(): void
    {
        $this->logger->debug('sending heartbeat', ['seq' => $this->seq]);

        $payload = Payload::new(
            Op::OP_HEARTBEAT,
            $this->seq
        );

        $this->send($payload, true);
        $this->heartbeatTime = microtime(true);
        $this->emit('heartbeat', [$this->seq, $this]);

        $this->heartbeatAckTimer = $this->loop->addTimer($this->heartbeatInterval / 1000, function () {
            if (! $this->connected) {
                return;
            }

            $this->logger->warning('did not receive heartbeat ACK within heartbeat interval, closing connection');
            $this->ws->close(1001, 'did not receive heartbeat ack');
        });
    }

    /**
     * Used to request all members for a guild or a list of guilds.
     *
     * Ratelimited, once every 30 seconds per guild.
     *
     * The server will send Guild Members Chunk events in response with up to 1000 members per chunk until all members that match the request have been sent.
     *
     * @see self::handleGuildMembersChunk()
     *
     * @link https://discord.com/developers/docs/events/gateway-events#request-guild-members
     *
     * @param Guild|string       $guild_id             ID of the guild or Guild object. Required.
     * @param array              $options
     * @param string             $options['query']     String that username starts with, or an empty string to return all members. Required when not including user_ids.
     * @param int                $options['limit']     Maximum number of members to send matching the query. 0 with empty query returns all. Required when including a query.
     * @param ?bool|null         $options['presences'] Whether to include presences of matched members.
     * @param ?string|array|null $options['user_ids']  Snowflake or array of snowflakes to specify which users to fetch. Required when not including a query.
     * @param ?string|null       $options['nonce']     Nonce to identify the Guild Members Chunk response.
     *
     * @throws \InvalidArgumentException Either query or user_ids must be set.
     *
     * @since 10.19.0
     */
    public function requestGuildMembers($guild_id, array $options = []): void
    {
        if (! isset($options['query']) && ! isset($options['user_ids'])) {
            if (! isset($options['limit'])) {
                $options['limit'] = 0;
            }
            $options['query'] = '';
        }

        if (! is_string($guild_id)) {
            $guild_id = $guild_id->id;
        }

        $payloadData = [
            'guild_id' => $guild_id,
        ];

        if (isset($options['user_ids'])) {
            // If user_ids is set, query and limit must NOT be set
            $payloadData['user_ids'] = is_array($options['user_ids'])
                ? array_values($options['user_ids'])
                : [$options['user_ids']];
        } else {
            // If user_ids is not set, query and limit are required
            $payloadData['query'] = $options['query'] ?? '';
            $payloadData['limit'] = $options['limit'] ?? 0;
        }

        if (array_key_exists('presences', $options)) {
            $payloadData['presences'] = (bool) $options['presences'];
        }

        if (isset($options['nonce'])) {
            $payloadData['nonce'] = (string) $options['nonce'];
        }

        $payload = Payload::new(
            Op::OP_REQUEST_GUILD_MEMBERS,
            $payloadData,
        );

        $this->send($payload);
    }

    /**
     * Used to request soundboard sounds for a list of guilds. The server will send Soundboard Sounds events for each guild in response.
     *
     * @see \Discord\WebSockets\Events\SoundboardSounds
     *
     * @link https://discord.com/developers/docs/events/gateway-events#request-soundboard-sounds
     *
     * @param array $guildIds Array of guild IDs.
     */
    public function requestSoundboardSounds(array $guildIds): void
    {
        $payload = Payload::new(
            Op::OP_REQUEST_SOUNDBOARD_SOUNDS,
            [
                'guild_ids' => $guildIds,
            ],
        );

        $this->send($payload);
    }

    /**
     * Sent when a client wants to join, move, or disconnect from a voice channel.
     *
     * @link https://discord.com/developers/docs/events/gateway-events#update-voice-state
     *
     * @param Guild|string        $guild_id   ID of the guild.
     * @param Channel|string|null $channel_id ID of the voice channel to join, or null to disconnect.
     * @param bool                $self_mute  Whether the client is muted.
     * @param bool                $self_deaf  Whether the client is deafened.
     *
     * @since 10.19.0
     */
    public function updateVoiceState($guild_id, $channel_id = null, bool $self_mute = false, bool $self_deaf = false): void
    {
        if (! is_string($guild_id)) {
            $guild_id = $guild_id->id;
        }

        if (isset($channel_id) && ! is_string($channel_id)) {
            $channel_id = $channel_id->id;
        }

        $payload = Payload::new(
            Op::OP_UPDATE_VOICE_STATE,
            [
                'guild_id' => $guild_id,
                'channel_id' => $channel_id,
                'self_mute' => $self_mute,
                'self_deaf' => $self_deaf,
            ]
        );

        $this->send($payload);
    }

    /**
     * Sent by the client to indicate a presence or status update.
     *
     * @link https://discord.com/developers/docs/events/gateway-events#update-presence
     *
     * @param Activity|null $activity The current client activity, or null.
     *                                Note: Both name and state must be set to use custom, and the only valid fields are `name`, `state`, `type` and `url`.
     * @param bool          $idle     Whether the client is idle.
     * @param string        $status   The current status of the client.
     *                                Must be one of the following:
     *                                online, dnd, idle, invisible, offline
     * @param bool          $afk      Whether the client is AFK.
     *
     * @throws \UnexpectedValueException
     */
    public function updatePresence(?Activity $activity = null, bool $idle = false, string $status = 'online', bool $afk = false): void
    {
        $idle = $idle ? time() * 1000 : null;

        if (null !== $activity) {
            $activity = $activity->getRawAttributes();

            if (! in_array($activity['type'], [Activity::TYPE_PLAYING, Activity::TYPE_STREAMING, Activity::TYPE_LISTENING, Activity::TYPE_WATCHING, Activity::TYPE_CUSTOM, Activity::TYPE_COMPETING])) {
                throw new \UnexpectedValueException("The given activity type ({$activity['type']}) is invalid.");
            }
        }

        static $allowed = ['online', 'dnd', 'idle', 'invisible', 'offline'];

        if (! in_array($status, $allowed)) {
            $status = 'online';
        }

        $payload = Payload::new(
            Op::OP_UPDATE_PRESENCE,
            [
                'since' => $idle,
                'activities' => [$activity],
                'status' => $status,
                'afk' => $afk,
            ],
        );

        $this->send($payload);
    }

    /**
     * Sets the heartbeat timer up.
     *
     * @param int $interval The heartbeat interval in milliseconds.
     */
    protected function setupHeartbeat(int $interval): void
    {
        $this->heartbeatInterval = $interval;
        if (isset($this->heartbeatTimer)) {
            $this->loop->cancelTimer($this->heartbeatTimer);
        }

        $interval = $interval / 1000;
        $this->heartbeatTimer = $this->loop->addPeriodicTimer($interval, [$this, 'heartbeat']);
        $this->heartbeat();

        $this->logger->info('heartbeat timer initialized', ['interval' => $interval * 1000]);
    }

    /**
     * Initializes the connection with the Discord gateway.
     */
    public function connectWs(): void
    {
        $this->setGateway()->then(function ($gateway) {
            if (isset($gateway['session']) && $session = $gateway['session']) {
                $this->logger->debug('session data received', ['session' => $session]);
                if ($session['remaining'] < 2) {
                    $this->logger->error('exceeded number of reconnects allowed, waiting before attempting reconnect', $session);
                    $this->loop->addTimer($session['reset_after'] / 1000, function () {
                        $this->connectWs();
                    });

                    return;
                }
            }

            $this->logger->info('starting connection to websocket', ['gateway' => $this->gateway]);

            /** @var PromiseInterface */
            $promise = ($this->wsFactory)($this->gateway);
            $promise->then([$this, 'handleWsConnection'], [$this, 'handleWsConnectionFailed']);
        });
    }

    /**
     * Sends a packet to the Discord gateway.
     *
     * @param Payload|array $data Packet data.
     */
    public function send(object|array $data, bool $force = false): void
    {
        // Wait until payload count has been reset
        // Keep 5 payloads for heartbeats as required
        if (! $force && $this->payloadCount >= 115) {
            $this->logger->debug('payload not sent, waiting', ['payload' => $data]);
            $this->once('payload_count_reset', fn () => $this->send($data));

            return;
        }

        ++$this->payloadCount;
        $this->ws->send(json_encode($data));
    }

    /**
     * Emits init if it has not been emitted already.
     * @return false|void
     */
    protected function ready()
    {
        if ($this->emittedInit) {
            return false;
        }
        $this->emittedInit = true;

        if (class_exists(Manager::class)) {
            $this->voice = new Manager($this);
            $this->logger->info('voice class initialized');
        }

        $this->logger->info('client is ready');
        $this->emit('init', [$this]);

        if (count($this->listeners('ready'))) {
            $this->logger->info("The 'ready' event is deprecated and will be removed in a future version of DiscordPHP. Please use 'init' instead.");
            $this->emit('ready', [$this]); // deprecated
        }

        foreach ($this->unparsedPackets as $parser) {
            $parser();
        }
    }

    /**
     * Lists voice regions.
     *
     * @return PromiseInterface<ExCollectionInterface<Region>|Region[]> A promise that resolves to a collection of voice regions.
     */
    public function listVoiceRegions(): PromiseInterface
    {
        if (null !== $this->regions) {
            return resolve($this->regions);
        }

        return $this->http->get(Endpoint::LIST_VOICE_REGIONS)->then(function ($response) {
            /** @var ExCollectionInterface<Region> $regions */
            $regions = $this->collectionClass::for(Region::class);

            foreach ($response as $region) {
                $regions->pushItem($this->factory->part(Region::class, (array) $region, true));
            }

            $this->regions = $regions;

            return $regions;
        });
    }

    /**
     * Gets a voice client from a guild ID. Returns null if there is no voice client.
     *
     * @param string $guild_id The guild ID to look up.
     *
     * @return VoiceClient|null
     */
    public function getVoiceClient(string $guild_id): ?VoiceClient
    {
        return isset($this->voice) ? ($this->voice->clients[$guild_id] ?? null) : null;
    }

    /**
     * Joins a voice channel.
     *
     * @param Channel $channel The channel to join.
     * @param bool    $mute    Whether you should be mute when you join the channel.
     * @param bool    $deaf    Whether you should be deaf when you join the channel.
     *
     * @throws \RuntimeException If the voice manager is not initialized.
     *
     * @since 10.0.0 Removed argument $check that has no effect (it is always checked)
     * @since 4.0.0
     *
     * @return PromiseInterface<VoiceClient>
     */
    public function joinVoiceChannel(Channel $channel, $mute = false, $deaf = true): PromiseInterface
    {
        return isset($this->voice)
            ? $this->voice->joinChannel($channel, $this, $this->voice_sessions, $mute, $deaf)
            : reject(new \RuntimeException('Voice manager is not initialized.'));
    }

    /**
     * Handles voice state update events.
     *
     * @param VoiceServerUpdate $vs      The voice state update event.
     * @param Channel           $channel The voice channel.
     * @param array             $data    Reference to the data array for the voice client.
     *
     * @todo Remove this function in v11, as it's no longer needed.
     */
    protected function voiceStateUpdate(VoiceServerUpdate $vs, Channel $channel, array &$data): void
    {
        if ($vs->guild_id !== $channel->guild_id) {
            return; // This voice state update isn't for our guild.
        }
        $this->voice_sessions[$channel->guild_id] = $vs->session_id;
        $this->removeListener(Event::VOICE_STATE_UPDATE, fn () => $this->voiceStateUpdate($vs, $channel, $data));
    }

    /**
     * Handles voice server update events.
     *
     * @param VoiceServerUpdate $vs       The voice server update event.
     * @param Channel           $channel  The voice channel.
     * @param array             $data     Reference to the data array for the voice client.
     * @param Deferred          $deferred Reference to the deferred object for the voice client. Rejects on error or if voice manager is not initialized.
     */
    protected function voiceServerUpdate(VoiceServerUpdate $vs, Channel $channel, array &$data, Deferred &$deferred): void
    {
        if (! isset($this->voice)) {
            $deferred->reject(new \RuntimeException('Voice manager is not initialized.'));

            return;
        }

        if ($vs->guild_id !== $channel->guild_id) {
            return; // This voice server update isn't for our guild.
        }

        $data['token'] = $vs->token;
        $data['endpoint'] = $vs->endpoint;
        $data['dnsConfig'] = $this->options['dnsConfig'];
        $this->logger->info('received token and endpoint for voice session', ['guild' => $channel->guild_id, 'token' => $vs->token, 'endpoint' => $vs->endpoint]);

        $vc = $this->voice->clients[$channel->guild_id] ??= new VoiceClient($this, $channel, $this->voice_sessions, $data, deferred: $deferred, manager: $this->voice);

        $vc->once('ready', function () use ($vc, $deferred) {
            $this->logger->info('voice client is ready');
            $deferred->resolve($vc);
        });
        $vc->once('error', function ($e) use ($deferred) {
            $this->logger->error('error initializing voice client', ['e' => $e->getMessage()]);
            $deferred->reject($e);
        });
        $vc->once('close', function () use ($channel) {
            $this->logger->warning('voice client closed');
            unset($this->voice->clients[$channel->guild_id]);
        });

        $vc->setData(
            array_merge(
                $vc->data,
                [
                    'token' => $vs->token,
                    'endpoint' => $vs->endpoint,
                    'session' => $vc->data['session'] ?? null,
                ],
                ['dnsConfig' => $this->options['dnsConfig']]
            )
        );

        $this->voiceLoggers[$channel->guild_id] = $this->logger;
        $this->removeListener(Event::VOICE_SERVER_UPDATE, fn () => $this->voiceServerUpdate($vs, $channel, $data, $deferred));
    }

    /**
     * Retrieves and sets the gateway URL for the client.
     *
     * @param string|null $gateway Gateway URL to set.
     *
     * @return PromiseInterface
     */
    protected function setGateway(?string $gateway = null): PromiseInterface
    {
        $deferred = new Deferred();

        if (null === $gateway) {
            $this->http->get(Endpoint::GATEWAY_BOT)->then(function ($response) use ($deferred) {
                /** @var GetGatewayBot $part */
                $part = $this->factory->part(GetGatewayBot::class, (array) $response);
                $this->emit('gateway', [$part, $this]);
                if ($part->shards > 1) {
                    $this->logger->info('Please contact the DiscordPHP devs at https://discord.gg/dphp or https://github.com/discord-php/DiscordPHP/issues if you are interested in assisting us with sharding support development.');
                }
                $this->buildParams($deferred, $this->resume_gateway_url ?? $part->url, $part->session_start_limit);
            }, function ($e) use ($deferred) {
                // Can't access the API server so we will use the default gateway.
                $this->logger->warning('could not retrieve gateway, using default');
                $this->buildParams($deferred, 'wss://gateway.discord.gg');
            });
        } else {
            $this->buildParams($deferred, $gateway);
        }

        return $deferred->promise()->then(function ($gateway) {
            $this->logger->info('gateway retrieved and set', $gateway);

            return $gateway;
        }, function ($e) {
            $this->logger->error('error obtaining gateway', ['e' => $e->getMessage()]);

            return $e;
        });
    }

    /**
     * Builds the gateway connection parameters and resolves the deferred with the gateway URL and session information.
     *
     * @param Deferred          $deferred The deferred object to resolve with the gateway and session data.
     * @param string            $gateway  The base gateway URL to connect to.
     * @param SessionStartLimit $session  Optional session information. If null, a default session is used.
     */
    protected function buildParams(Deferred $deferred, string $gateway, ?SessionStartLimit $session = null): void
    {
        static $defaultSession = [
            'total' => 1000,
            'remaining' => 1000,
            'reset_after' => 0,
            'max_concurrency' => 1,
        ];

        $session = $session ?? $this->factory->part(SessionStartLimit::class, $defaultSession);
        $params = [
            'v' => self::GATEWAY_VERSION,
            'encoding' => $this->encoding,
        ];

        if ($this->useTransportCompression) {
            if ($this->zlibDecompressor = inflate_init(ZLIB_ENCODING_DEFLATE)) {
                $params['compress'] = 'zlib-stream';
            }
            // @todo: add support for zstd-stream
        }

        $query = http_build_query($params);
        $this->gateway = trim($gateway, '/').'/?'.$query;

        $deferred->resolve(['gateway' => $this->gateway, 'session' => $session->jsonSerialize()]);
    }

    /**
     * Resolves the options.
     *
     * @param array $options Array of options.
     *
     * @return array           Options.
     * @throws IntentException
     */
    protected function resolveOptions(array $options = []): array
    {
        $resolver = new OptionsResolver();

        $resolver
            ->setRequired('token')
            ->setDefined([
                'token',
                'loop',
                'logger',
                'loadAllMembers',
                'disabledEvents',
                'storeMessages',
                'retrieveBans',
                'large_threshold',
                'shard',
                'shard_id',
                'num_shards',
                'shardId',
                'shardCount',
                'presence',
                'intents',
                'socket_options',
                'dnsConfig',
                'cache',
                'collection',
                'useTransportCompression',
                'usePayloadCompression',
            ])
            ->setDefaults([
                'logger' => null,
                'loadAllMembers' => false,
                'disabledEvents' => [],
                'storeMessages' => false,
                'retrieveBans' => false,
                'large_threshold' => null,
                'shard' => null,
                'shard_id' => null,
                'num_shards' => null,
                'shardId' => null,
                'shardCount' => null,
                'presence' => null,
                'intents' => Intents::getDefaultIntents(),
                'socket_options' => [],
                'cache' => [AbstractRepository::class => null], // use LegacyCacheWrapper
                'collection' => Collection::class,
                'useTransportCompression' => true,
                'usePayloadCompression' => true,
            ])
            ->setAllowedTypes('token', 'string')
            ->setAllowedTypes('logger', ['null', LoggerInterface::class])
            ->setAllowedTypes('loop', LoopInterface::class)
            ->setAllowedTypes('loadAllMembers', ['bool', 'array'])
            ->setAllowedTypes('disabledEvents', 'array')
            ->setAllowedTypes('storeMessages', 'bool')
            ->setAllowedTypes('retrieveBans', ['bool', 'array'])
            ->setAllowedTypes('large_threshold', ['null', 'int'])
            ->setAllowedTypes('shard', ['null', 'array'])
            ->setAllowedTypes('shard_id', ['null', 'int'])
            ->setAllowedTypes('num_shards', ['null', 'int'])
            ->setAllowedTypes('shardId', ['null', 'int'])
            ->setAllowedTypes('shardCount', ['null', 'int'])
            ->setAllowedTypes('presence', ['null', 'array'])
            ->setAllowedTypes('intents', ['array', 'int'])
            ->setAllowedTypes('socket_options', 'array')
            ->setAllowedTypes('dnsConfig', ['string', \React\Dns\Config\Config::class])
            ->setAllowedTypes('cache', ['array', CacheConfig::class, \React\Cache\CacheInterface::class, \Psr\SimpleCache\CacheInterface::class])
            ->setNormalizer('cache', function ($options, $value) {
                if (! is_array($value)) {
                    if (! ($value instanceof CacheConfig)) {
                        $value = new CacheConfig($value);
                    }

                    return [AbstractRepository::class => $value];
                }

                return $value;
            })
            ->setAllowedTypes('collection', 'string')
            ->setNormalizer('collection', function ($options, $value) {
                if (is_string($value) && class_exists($value) && is_subclass_of($value, ExCollectionInterface::class)) {
                    return $value;
                }

                return Collection::class;
            })
            ->setAllowedTypes('useTransportCompression', 'bool')
            ->setAllowedTypes('usePayloadCompression', 'bool');

        $options = $resolver->resolve($options);

        $options['loop'] ??= Loop::get();

        if (null === $options['logger']) {
            $streamHandler = new StreamHandler('php://stdout', Level::Debug);
            $lineFormatter = new LineFormatter(null, null, true, true);
            $streamHandler->setFormatter($lineFormatter);
            $logger = new Monolog('DiscordPHP', [$streamHandler]);
            $options['logger'] = $logger;
        }

        if (! isset($options['dnsConfig'])) {
            $dnsConfig = \React\Dns\Config\Config::loadSystemConfigBlocking();
            if (! $dnsConfig->nameservers) {
                $dnsConfig->nameservers[] = '8.8.8.8';
            }

            $options['dnsConfig'] = $dnsConfig;
        }

        if (is_array($options['intents'])) {
            $intent = 0;
            $validIntents = Intents::getValidIntents();

            foreach ($options['intents'] as $idx => $i) {
                if (! in_array($i, $validIntents)) {
                    throw new IntentException('Given intent at index '.$idx.' is invalid.');
                }

                $intent |= $i;
            }

            $options['intents'] = $intent;
        }

        if ($options['loadAllMembers'] && ! ($options['intents'] & Intents::GUILD_MEMBERS)) {
            throw new IntentException('You have enabled the `loadAllMembers` option but have not enabled the required `GUILD_MEMBERS` intent.'.
            'See the documentation on the `loadAllMembers` property for more information: http://discord-php.github.io/DiscordPHP/#basics');
        }

        // Discord doesn't currently support IPv6
        // This prevents xdebug from catching exceptions when trying to fetch IPv6
        // for Discord
        $options['socket_options']['happy_eyeballs'] = false;

        return $options;
    }

    /**
     * Adds a large guild to the large guild array.
     *
     * @param Guild $guild The guild.
     */
    public function addLargeGuild(Part $guild): void
    {
        $this->largeGuilds[] = $guild->id;
    }

    /**
     * Starts the ReactPHP event loop.
     */
    public function run(): void
    {
        $this->loop->run();
    }

    /**
     * Closes the Discord client.
     *
     * @param bool $closeLoop Whether to close the loop as well. Default true.
     */
    public function close(bool $closeLoop = true): void
    {
        $this->closing = true;
        if ($this->ws) {
            $this->ws->close($closeLoop ? Op::CLOSE_UNKNOWN_ERROR : Op::CLOSE_NORMAL, 'discordphp closing...');
        }
        $this->emit('closed', [$this]);
        $this->logger->info('discord closed');

        if ($closeLoop) {
            $this->loop->stop();
        }
    }

    /**
     * Allows access to the part/repository factory.
     *
     * @param string $class   The class to build.
     * @param mixed  $data    Data to create the object.
     * @param bool   $created Whether the object is created (if part).
     *
     * @return Part|AbstractRepository
     *
     * @see Factory::create()
     *
     * @since 10.0.0 Use `new $class($discord, ...)`.
     */
    public function factory(string $class, $data = [], bool $created = false)
    {
        return $this->factory->create($class, $data, $created);
    }

    /**
     * Gets the factory.
     *
     * @return Factory
     */
    public function getFactory(): Factory
    {
        return $this->factory;
    }

    /**
     * Gets the HTTP client.
     *
     * @return Http
     */
    public function getHttpClient(): Http
    {
        return $this->http;
    }

    /**
     * Gets the loop being used by the client.
     *
     * @return LoopInterface
     */
    public function getLoop(): LoopInterface
    {
        return $this->loop;
    }

    /**
     * Gets the logger being used.
     *
     * @return LoggerInterface
     */
    public function getLogger(): LoggerInterface
    {
        return $this->logger;
    }

    /**
     * Gets the HTTP client.
     *
     * @return Http
     *
     * @deprecated Use Discord::getHttpClient()
     */
    public function getHttp(): Http
    {
        return $this->http;
    }

    /**
     * Gets the cache configuration.
     *
     * @param string $repository_class Repository class name.
     *
     * @return ?CacheConfig
     */
    public function getCacheConfig($repository_class = AbstractRepository::class)
    {
        if (! array_key_exists($repository_class, $this->cacheConfig)) {
            $repository_class = AbstractRepository::class;
        }

        return $this->cacheConfig[$repository_class];
    }

    /**
     * Gets the collection class being used by the client.
     *
     * @return string
     *
     * @since 10.43.1
     */
    public function getCollectionClass(): string
    {
        return $this->collectionClass;
    }

    /**
     * Handles dynamic get calls to the client.
     *
     * @param string $name Variable name.
     *
     * @return mixed
     */
    public function __get(string $name)
    {
        static $allowed = ['loop', 'options', 'logger', 'http', 'application_commands'];

        if (in_array($name, $allowed)) {
            return $this->{$name};
        }

        if (null === $this->client) {
            return;
        }

        return $this->client->{$name};
    }

    /**
     * Handles dynamic set calls to the client.
     *
     * @param string $name  Variable name.
     * @param mixed  $value Value to set.
     */
    public function __set(string $name, $value): void
    {
        if (null === $this->client) {
            return;
        }

        $this->client->{$name} = $value;
    }

    /**
     * Sets the client instance.
     *
     * @param Client $client The client instance.
     */
    public function setClient(Client $client): void
    {
        $this->client = $client;
    }

    /**
     * Gets a cached channel.
     *
     * @param string|int $channel_id ID of the channel.
     *
     * @return Channel|null null if not found in the cache.
     */
    public function getChannel($channel_id): ?Channel
    {
        foreach ($this->guilds as $guild) {
            if ($channel = $guild->channels->get('id', $channel_id)) {
                return $channel;
            }
        }

        if ($channel = $this->private_channels->get('id', $channel_id)) {
            return $channel;
        }

        return null;
    }

    /**
     * Add listener for incoming application command from interaction.
     *
     * @param array|string  $names
     * @param callable|null $callback
     * @param callable|null $autocomplete_callback
     *
     * @throws \LogicException
     *
     * @return RegisteredCommand
     */
    public function listenCommand($names, ?callable $callback = null, ?callable $autocomplete_callback = null): RegisteredCommand
    {
        if (! is_array($names)) {
            $names = [$names];
        }

        // registering base command
        if (count($names) === 1) {
            $name = array_shift($names);
            if (isset($this->application_commands[$name])) {
                throw new \LogicException("The command `{$name}` already exists.");
            }

            return $this->application_commands[$name] = new RegisteredCommand($this, $name, $callback, $autocomplete_callback);
        }

        $baseCommand = array_shift($names);

        if (! isset($this->application_commands[$baseCommand])) {
            $this->listenCommand($baseCommand);
        }

        return $this->application_commands[$baseCommand]->addSubCommand($names, $callback, $autocomplete_callback);
    }

    /**
     * Handles dynamic calls to the client.
     *
     * @param string $name   Function name.
     * @param array  $params Function parameters.
     *
     * @return mixed
     */
    public function __call(string $name, array $params)
    {
        if (null === $this->client) {
            return;
        }

        return call_user_func_array([$this->client, $name], $params);
    }

    /**
     * Returns an array that can be used to describe the internal state of this
     * object.
     *
     * @return array
     */
    public function __debugInfo(): array
    {
        static $secrets = [
            'token' => '*****',
        ];
        $replace = array_intersect_key($secrets, $this->options);
        $config = $replace + $this->options;

        unset($config['loop'], $config['logger']);

        $config[] = $this->client;

        return $config;
    }
}
