<?php

namespace App\Services;

use App\Models\Mikrotik;
use Illuminate\Support\Carbon;

class MikrotikApiService
{
    public function testConnection(Mikrotik|array $config): array
    {
        $config = $this->normalizeConfig($config);
        $start = microtime(true);

        $socket = $this->openSocket($config);
        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $milliseconds = (int) round((microtime(true) - $start) * 1000);

            return [
                'connected' => true,
                'message' => 'Connected',
                'response_time_ms' => $milliseconds,
                'last_checked_at' => Carbon::now(),
                'last_connected_at' => Carbon::now(),
            ];
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function fetchProfiles(Mikrotik $mikrotik): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/profile/print']);

            $profiles = [];
            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }

                $word = $sentence[0] ?? '';
                if ($word === '!done') {
                    break;
                }

                if ($word === '!re') {
                    $data = $this->wordsToAssoc($sentence);
                    if (! empty($data['name'])) {
                        $profiles[] = [
                            'name' => $data['name'],
                            'rate' => $data['rate'] ?? null,
                            'local_name' => $data['name'],
                            'mikrotik_server_id' => $mikrotik->id,
                        ];
                    }
                }

                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
            }

            return $profiles;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function fetchSecrets(Mikrotik $mikrotik): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/secret/print']);

            $secrets = [];
            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }

                $word = $sentence[0] ?? '';
                if ($word === '!done') {
                    break;
                }

                if ($word === '!re') {
                    $data = $this->wordsToAssoc($sentence);
                    if (! empty($data['name'])) {
                        $secrets[] = [
                            'name' => $data['name'],
                            'profile' => $data['profile'] ?? null,
                            'remote_address' => $data['remote-address'] ?? null,
                            'disabled' => isset($data['disabled']) && $data['disabled'] !== 'false',
                            'comment' => $data['comment'] ?? null,
                            'service' => $data['service'] ?? null,
                            'routeros_attrs' => $data,
                        ];
                    }
                }

                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
            }

            return $secrets;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function fetchActiveSessions(Mikrotik $mikrotik): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/active/print']);

            $active = [];
            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }

                $word = $sentence[0] ?? '';
                if ($word === '!done') {
                    break;
                }

                if ($word === '!re') {
                    $data = $this->wordsToAssoc($sentence);
                    if (! empty($data['name'])) {
                        $active[] = [
                            'name' => $data['name'],
                            'address' => $data['address'] ?? null,
                            'profile' => $data['profile'] ?? null,
                            'service' => $data['service'] ?? null,
                            'uptime' => $data['uptime'] ?? null,
                            'caller_id' => $data['caller-id'] ?? null,
                            'comment' => $data['comment'] ?? null,
                            'routeros_attrs' => $data,
                        ];
                    }
                }

                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
            }

            return $active;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    /** Return a one-shot traffic sample for a dynamic PPPoE interface. */
    public function fetchPppTraffic(Mikrotik $mikrotik, string $username): array
    {
        $username = trim($username);
        if ($username === '') {
            throw new \InvalidArgumentException('PPPoE username is required.');
        }

        $lastException = null;
        foreach (['<pppoe-'.$username.'>', $username] as $interface) {
            try {
                return $this->monitorInterfaceTraffic($mikrotik, $interface);
            } catch (\Throwable $exception) {
                $lastException = $exception;
            }
        }

        throw $lastException ?? new \RuntimeException('PPPoE traffic interface was not found.');
    }

    private function monitorInterfaceTraffic(Mikrotik $mikrotik, string $interface): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/interface/monitor-traffic', '=interface='.$interface, '=once=']);
            $sample = null;

            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }
                $word = $sentence[0] ?? '';
                if ($word === '!re') {
                    $sample = $this->wordsToAssoc($sentence);
                }
                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
                if ($word === '!done') {
                    break;
                }
            }

            if (! $sample) {
                throw new \RuntimeException('No traffic sample returned for '.$interface.'.');
            }

            return [
                'interface' => $interface,
                'rx_bps' => (int) ($sample['rx-bits-per-second'] ?? 0),
                'tx_bps' => (int) ($sample['tx-bits-per-second'] ?? 0),
                'rx_packets_per_second' => (int) ($sample['rx-packets-per-second'] ?? 0),
                'tx_packets_per_second' => (int) ($sample['tx-packets-per-second'] ?? 0),
            ];
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function addSecret(Mikrotik $mikrotik, array $secret): void
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);

            $sentence = ['/ppp/secret/add', '=name='.$secret['username']];
            if (! empty($secret['password'])) {
                $sentence[] = '=password='.$secret['password'];
            }
            if (! empty($secret['profile'])) {
                $sentence[] = '=profile='.$secret['profile'];
            }
            if (! empty($secret['remote_address'])) {
                $sentence[] = '=remote-address='.$secret['remote_address'];
            }
            if (isset($secret['disabled'])) {
                $sentence[] = '=disabled='.($secret['disabled'] ? 'yes' : 'no');
            }
            if (! empty($secret['comment'])) {
                $sentence[] = '=comment='.$secret['comment'];
            }

            $this->writeSentence($socket, $sentence);
            $this->consumeResponse($socket);
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    /**
     * Add many PPP secrets over one RouterOS connection.
     *
     * @return array<int, array{username:string, success:bool, message:?string}>
     */
    public function addSecrets(Mikrotik $mikrotik, array $secrets): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);
        $results = [];

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);

            foreach ($secrets as $secret) {
                $username = trim((string) ($secret['username'] ?? ''));

                try {
                    $sentence = ['/ppp/secret/add', '=name='.$username];
                    if (! empty($secret['password'])) {
                        $sentence[] = '=password='.$secret['password'];
                    }
                    if (! empty($secret['profile'])) {
                        $sentence[] = '=profile='.$secret['profile'];
                    }
                    if (! empty($secret['remote_address'])) {
                        $sentence[] = '=remote-address='.$secret['remote_address'];
                    }
                    $sentence[] = '=disabled='.(empty($secret['disabled']) ? 'no' : 'yes');
                    if (! empty($secret['comment'])) {
                        $sentence[] = '=comment='.$secret['comment'];
                    }

                    $this->writeSentence($socket, $sentence);
                    $this->consumeResponse($socket);
                    $results[] = ['username' => $username, 'success' => true, 'message' => null];
                } catch (\Throwable $exception) {
                    $results[] = ['username' => $username, 'success' => false, 'message' => $exception->getMessage()];
                }
            }

            return $results;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function updateSecret(Mikrotik $mikrotik, string $username, array $secret): bool
    {
        $secretId = $this->findSecretId($mikrotik, $username);
        if ($secretId === null) {
            return false;
        }

        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);

            $sentence = ['/ppp/secret/set', '=.id='.$secretId];
            if (isset($secret['username'])) {
                $sentence[] = '=name='.$secret['username'];
            }
            if (! empty($secret['password'])) {
                $sentence[] = '=password='.$secret['password'];
            }
            if (array_key_exists('profile', $secret)) {
                $sentence[] = '=profile='.($secret['profile'] ?? '');
            }
            if (array_key_exists('remote_address', $secret)) {
                $sentence[] = '=remote-address='.($secret['remote_address'] ?? '');
            }
            if (array_key_exists('disabled', $secret)) {
                $sentence[] = '=disabled='.($secret['disabled'] ? 'yes' : 'no');
            }
            if (array_key_exists('comment', $secret)) {
                $sentence[] = '=comment='.($secret['comment'] ?? '');
            }

            $this->writeSentence($socket, $sentence);
            $this->consumeResponse($socket);

            return true;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function deleteSecret(Mikrotik $mikrotik, string $username): void
    {
        $secretId = $this->findSecretId($mikrotik, $username);
        if ($secretId === null) {
            return;
        }

        $config = $this->normalizeConfig($mikrotik);
        try {
            $this->removeSecretWithArgument($config, '=.id='.$secretId);
        } catch (\RuntimeException $exception) {
            if (! str_contains(strtolower($exception->getMessage()), 'missing =.id=')) {
                throw $exception;
            }

            // Some RouterOS builds/API proxies use the legacy `numbers`
            // argument used by the original application instead of `.id`.
            $this->removeSecretWithArgument($config, '=numbers='.$username);
        }
    }

    private function removeSecretWithArgument(array $config, string $argument): void
    {
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/secret/remove', $argument]);
            $this->consumeResponse($socket);
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function setSecretDisabled(Mikrotik $mikrotik, string $username, bool $disabled): void
    {
        $secretId = $this->findSecretId($mikrotik, $username);
        if ($secretId === null) {
            throw new \RuntimeException('PPP secret was not found on MikroTik: '.$username);
        }

        $this->setSecretDisabledById($mikrotik, $secretId, $disabled);
    }

    public function disconnectActivePppSession(Mikrotik $mikrotik, string $username): int
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);
        $removed = 0;

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/active/print', '=.proplist=.id,name', '?name='.$username]);
            $ids = [];
            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence) || $this->isDone($sentence)) {
                    break;
                }
                if ($this->isTrap($sentence)) {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
                if (($sentence[0] ?? null) === '!re') {
                    $id = $this->wordsToAssoc($sentence)['.id'] ?? null;
                    if ($id) {
                        $ids[] = $id;
                    }
                }
            }
            foreach ($ids as $id) {
                $this->writeSentence($socket, ['/ppp/active/remove', '=.id='.$id]);
                $this->consumeResponse($socket);
                $removed++;
            }

            return $removed;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function setSecretDisabledById(Mikrotik $mikrotik, string $secretId, bool $disabled): void
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, [
                $disabled ? '/ppp/secret/disable' : '/ppp/secret/enable',
                '=numbers='.$secretId,
            ]);
            $this->consumeResponse($socket);
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function setSecretProfileById(Mikrotik $mikrotik, string $secretId, string $profile, bool $disabled = false): void
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);
        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            // `/ppp/secret/set` requires the RouterOS item id, while the
            // enable/disable commands use the `numbers` selector.
            $this->writeSentence($socket, ['/ppp/secret/set', '=.id='.$secretId, '=profile='.$profile, '=disabled='.($disabled ? 'yes' : 'no')]);
            $this->consumeResponse($socket);
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    /**
     * Restore a paid PPPoE customer and verify the final state from RouterOS.
     *
     * @return array{id:string,name:string,profile:?string,disabled:bool}
     */
    public function enableCustomerSecret(Mikrotik $mikrotik, string $username, ?string $profile = null): array
    {
        $username = trim($username);
        if ($username === '') {
            throw new \RuntimeException('Customer PPPoE username is missing.');
        }

        $secretId = $this->findSecretId($mikrotik, $username);
        if (! $secretId) {
            throw new \RuntimeException('PPP secret was not found on MikroTik: '.$username);
        }

        $profile = trim((string) $profile);
        if ($profile !== '') {
            $this->setSecretProfileById($mikrotik, $secretId, $profile, false);
        }

        // RouterOS enable/disable commands use `numbers`; profile `/set`
        // uses `.id`. Keep these command-specific selectors separate.
        $this->setSecretDisabledById($mikrotik, $secretId, false);

        $secret = collect($this->fetchSecrets($mikrotik))->first(
            fn (array $item) => strcasecmp(trim((string) ($item['name'] ?? '')), $username) === 0
        );
        if (! $secret) {
            throw new \RuntimeException('PPP secret disappeared during payment activation: '.$username);
        }
        if ((bool) ($secret['disabled'] ?? true)) {
            throw new \RuntimeException('MikroTik did not enable PPP secret after payment: '.$username);
        }
        if ($profile !== '' && strcasecmp(trim((string) ($secret['profile'] ?? '')), $profile) !== 0) {
            throw new \RuntimeException('MikroTik did not apply the paid package profile: '.$profile);
        }

        return [
            'id' => $secretId,
            'name' => $username,
            'profile' => $secret['profile'] ?? null,
            'disabled' => false,
        ];
    }

    public function configureExpiredPaymentProfile(Mikrotik $mikrotik, array $settings): void
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);
        $profile = $settings['profile'];
        $poolName = 'ISP-EXPIRED-POOL';
        $expiredList = 'ISP-EXPIRED-USERS';
        $paymentList = 'ISP-PAYMENT-SERVERS';

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);

            if (! $this->resourceExists($socket, '/ip/pool/print', 'name', $poolName)) {
                $this->writeSentence($socket, ['/ip/pool/add', '=name='.$poolName, '=ranges='.$settings['pool'], '=comment=ISP Billing expired customer pool']);
                $this->consumeResponse($socket);
            }
            if (! $this->resourceExists($socket, '/ppp/profile/print', 'name', $profile)) {
                $this->writeSentence($socket, ['/ppp/profile/add', '=name='.$profile, '=remote-address='.$poolName, '=address-list='.$expiredList, '=only-one=yes', '=comment=ISP Billing payment redirect profile']);
                $this->consumeResponse($socket);
            }

            foreach (array_filter([$settings['domain'] ?? null, $settings['ip'] ?? null]) as $destination) {
                if (! $this->resourceExists($socket, '/ip/firewall/address-list/print', 'address', $destination)) {
                    $this->writeSentence($socket, ['/ip/firewall/address-list/add', '=list='.$paymentList, '=address='.$destination, '=comment=ISP Billing payment destination']);
                    $this->consumeResponse($socket);
                }
            }

            $rules = [
                ['protocol' => 'udp', 'dst-port' => '53', 'action' => 'accept', 'comment' => 'ISP Billing expired allow DNS UDP'],
                ['protocol' => 'tcp', 'dst-port' => '53', 'action' => 'accept', 'comment' => 'ISP Billing expired allow DNS TCP'],
                ['dst-address-list' => $paymentList, 'action' => 'accept', 'comment' => 'ISP Billing expired allow payment'],
                ['action' => 'drop', 'comment' => 'ISP Billing expired block internet'],
            ];
            foreach (array_reverse($rules) as $rule) {
                if ($this->resourceExists($socket, '/ip/firewall/filter/print', 'comment', $rule['comment'])) {
                    continue;
                }
                $sentence = ['/ip/firewall/filter/add', '=chain=forward', '=src-address-list='.$expiredList, '=place-before=0'];
                foreach ($rule as $key => $value) {
                    $sentence[] = '='.$key.'='.$value;
                }
                $this->writeSentence($socket, $sentence);
                $this->consumeResponse($socket);
            }
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    private function resourceExists($socket, string $path, string $field, string $value): bool
    {
        $this->writeSentence($socket, [$path, '?'.$field.'='.$value]);
        $exists = false;
        while (true) {
            $sentence = $this->readSentence($socket);
            if (empty($sentence) || $this->isDone($sentence)) {
                return $exists;
            }
            if (($sentence[0] ?? null) === '!re') {
                $exists = true;
            }
            if (($sentence[0] ?? null) === '!trap') {
                throw new \RuntimeException($this->buildErrorMessage($sentence));
            }
        }
    }

    public function findSecretIdByRemoteAddress(Mikrotik $mikrotik, string $remoteAddress): ?string
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/secret/print', '?remote-address='.$remoteAddress]);

            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }

                $word = $sentence[0] ?? '';
                if ($word === '!done') {
                    break;
                }

                if ($word === '!re') {
                    $data = $this->wordsToAssoc($sentence);
                    if (! empty($data['.id'])) {
                        return $data['.id'];
                    }
                }

                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
            }

            return null;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    public function findSecretId(Mikrotik $mikrotik, string $username): ?string
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            $this->writeSentence($socket, ['/ppp/secret/print', '?name='.$username]);

            while (true) {
                $sentence = $this->readSentence($socket);
                if (empty($sentence)) {
                    break;
                }

                $word = $sentence[0] ?? '';
                if ($word === '!done') {
                    break;
                }

                if ($word === '!re') {
                    $data = $this->wordsToAssoc($sentence);
                    if (! empty($data['.id'])) {
                        return $data['.id'];
                    }
                }

                if ($word === '!trap') {
                    throw new \RuntimeException($this->buildErrorMessage($sentence));
                }
            }

            return null;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    /** @return array<string, array{success:bool,message:?string}> */
    public function setSecretsDisabledBulk(Mikrotik $mikrotik, array $secretIds, bool $disabled): array
    {
        $config = $this->normalizeConfig($mikrotik);
        $socket = $this->openSocket($config);
        $results = [];

        try {
            $this->apiLogin($socket, $config['username'], $config['password']);
            foreach ($secretIds as $customerId => $secretId) {
                try {
                    $this->writeSentence($socket, [
                        $disabled ? '/ppp/secret/disable' : '/ppp/secret/enable',
                        '=numbers='.$secretId,
                    ]);
                    $this->consumeResponse($socket);
                    $results[(string) $customerId] = ['success' => true, 'message' => null];
                } catch (\Throwable $exception) {
                    $results[(string) $customerId] = ['success' => false, 'message' => $exception->getMessage()];
                }
            }

            return $results;
        } finally {
            if (is_resource($socket)) {
                fclose($socket);
            }
        }
    }

    private function consumeResponse($socket): void
    {
        while (true) {
            $sentence = $this->readSentence($socket);
            if (empty($sentence) || $this->isDone($sentence)) {
                return;
            }
            if ($this->isTrap($sentence)) {
                throw new \RuntimeException($this->buildErrorMessage($sentence));
            }
        }
    }

    private function normalizeConfig(Mikrotik|array $config): array
    {
        if ($config instanceof Mikrotik) {
            $config = [
                'host' => $config->ip_address,
                'port' => $config->api_port,
                'username' => $config->username,
                'password' => $config->password,
                'ssl_enabled' => $config->ssl_enabled ?? false,
            ];
        }

        return [
            'host' => $config['host'] ?? $config['ip_address'] ?? null,
            'port' => (int) ($config['port'] ?? $config['api_port'] ?? 8728),
            'username' => $config['username'] ?? null,
            'password' => $config['password'] ?? null,
            'ssl_enabled' => filter_var($config['ssl_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN),
        ];
    }

    private function openSocket(array $config)
    {
        $scheme = $config['ssl_enabled'] ? 'ssl' : 'tcp';
        $remote = sprintf('%s://%s:%s', $scheme, $config['host'], $config['port']);
        $contextOptions = [];

        if ($config['ssl_enabled']) {
            $contextOptions['ssl'] = [
                'verify_peer' => false,
                'verify_peer_name' => false,
                'allow_self_signed' => true,
            ];
        }

        $context = stream_context_create($contextOptions);
        $socket = @stream_socket_client($remote, $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context);

        if ($socket === false) {
            throw new \RuntimeException(sprintf(
                'Unable to reach MikroTik API at %s:%d%s. Verify RouterOS IP service, firewall and port forwarding.',
                $config['host'],
                $config['port'],
                $errstr ? ' ('.$errstr.')' : ''
            ));
        }

        stream_set_timeout($socket, 10);

        return $socket;
    }

    private function apiLogin($socket, string $username, string $password): void
    {
        $this->writeSentence($socket, ['/login', '=name='.$username, '=password='.$password]);
        $first = $this->readSentence($socket);

        if ($this->isTrap($first)) {
            throw new \RuntimeException($this->buildErrorMessage($first));
        }

        if ($this->isDone($first)) {
            return;
        }

        $challenge = $this->wordsToAssoc($first)['ret'] ?? null;
        if (empty($challenge)) {
            throw new \RuntimeException('MikroTik API login failed.');
        }

        $hashed = '00'.bin2hex(md5(chr(0).$password.hex2bin($challenge), true));
        $this->writeSentence($socket, ['/login', '=name='.$username, '=response='.$hashed]);

        $second = $this->readSentence($socket);
        if ($this->isTrap($second) || ! $this->isDone($second)) {
            throw new \RuntimeException($this->buildErrorMessage($second));
        }
    }

    private function readSentence($socket): array
    {
        $sentence = [];
        while (true) {
            $word = $this->readWord($socket);
            if ($word === null) {
                break;
            }
            if ($word === '') {
                break;
            }
            $sentence[] = $word;
        }

        return $sentence;
    }

    private function readWord($socket): ?string
    {
        $length = $this->readLength($socket);
        if ($length === null) {
            return null;
        }

        if ($length === 0) {
            return '';
        }

        return $this->readBytes($socket, $length);
    }

    private function readLength($socket): ?int
    {
        $data = $this->readBytes($socket, 1);
        if ($data === '') {
            return null;
        }

        $first = ord($data);
        if ($first < 0x80) {
            return $first;
        }

        if ($first < 0xC0) {
            $data = $this->readBytes($socket, 1);

            return (($first & 0x3F) << 8) + ord($data);
        }

        if ($first < 0xE0) {
            $data = $this->readBytes($socket, 2);

            return (($first & 0x1F) << 16) + (ord($data[0]) << 8) + ord($data[1]);
        }

        if ($first < 0xF0) {
            $data = $this->readBytes($socket, 3);

            return (($first & 0x0F) << 24) + (ord($data[0]) << 16) + (ord($data[1]) << 8) + ord($data[2]);
        }

        if ($first < 0xF8) {
            $data = $this->readBytes($socket, 4);

            return (ord($data[0]) << 24) + (ord($data[1]) << 16) + (ord($data[2]) << 8) + ord($data[3]);
        }

        throw new \RuntimeException('Invalid RouterOS API word length encoding.');
    }

    private function readBytes($socket, int $length): string
    {
        $buffer = '';
        while (strlen($buffer) < $length) {
            $chunk = fread($socket, $length - strlen($buffer));
            if ($chunk === false || $chunk === '') {
                throw new \RuntimeException('Failed to read data from MikroTik API connection.');
            }
            $buffer .= $chunk;
        }

        return $buffer;
    }

    private function writeSentence($socket, array $words): void
    {
        foreach ($words as $word) {
            $this->writeWord($socket, $word);
        }
        $this->writeWord($socket, '');
    }

    private function writeWord($socket, string $word): void
    {
        $length = strlen($word);
        if ($length < 0x80) {
            fwrite($socket, chr($length));
        } elseif ($length < 0x4000) {
            fwrite($socket, chr(($length >> 8) | 0x80));
            fwrite($socket, chr($length & 0xFF));
        } elseif ($length < 0x200000) {
            fwrite($socket, chr(($length >> 16) | 0xC0));
            fwrite($socket, chr(($length >> 8) & 0xFF));
            fwrite($socket, chr($length & 0xFF));
        } elseif ($length < 0x10000000) {
            fwrite($socket, chr(($length >> 24) | 0xE0));
            fwrite($socket, chr(($length >> 16) & 0xFF));
            fwrite($socket, chr(($length >> 8) & 0xFF));
            fwrite($socket, chr($length & 0xFF));
        } else {
            fwrite($socket, chr(0xF0));
            fwrite($socket, pack('N', $length));
        }
        fwrite($socket, $word);
    }

    private function isTrap(array $sentence): bool
    {
        return isset($sentence[0]) && $sentence[0] === '!trap';
    }

    private function isDone(array $sentence): bool
    {
        return isset($sentence[0]) && $sentence[0] === '!done';
    }

    private function wordsToAssoc(array $sentence): array
    {
        $data = [];
        foreach ($sentence as $word) {
            if (! str_starts_with($word, '=')) {
                continue;
            }

            $parts = explode('=', substr($word, 1), 2);
            if (count($parts) === 2) {
                $data[$parts[0]] = $parts[1];
            }
        }

        return $data;
    }

    private function buildErrorMessage(array $sentence): string
    {
        $data = $this->wordsToAssoc($sentence);

        return $data['message'] ?? $data['errmsg'] ?? 'MikroTik API error';
    }
}
