<?php

declare(strict_types=1);

namespace PhpWA\Protocol\Binary;

use PhpWA\Exception\ProtocolException;
use PhpWA\Protocol\Jid\Jid;
use PhpWA\Protocol\Jid\JidDomain;
use PhpWA\Runtime\SafeRuntime;

final class BinaryCodec
{
    /** @var TokenDictionary */ private $tokens;
    /** @var BinaryCodecOptions */ private $options;
    /** @var int */ private $nodeCount = 0;

    public function __construct(?TokenDictionary $tokens = null, ?BinaryCodecOptions $options = null)
    {
        $this->tokens = $tokens ?? TokenDictionary::defaults();
        $this->options = $options ?? new BinaryCodecOptions();
    }

    public function encode(BinaryNode $node): string
    {
        $this->nodeCount = 0;
        $writer = new BinaryWriter($this->options->maxFrameBytes);
        $writer->byte(0); // uncompressed payload flag
        $this->encodeNode($node, $writer, 1);
        return $writer->bytes();
    }

    public function decode(string $frame): BinaryNode
    {
        $length = strlen($frame);
        if ($length === 0) {
            throw new ProtocolException('Cannot decode an empty WABinary frame.');
        }
        if ($length > $this->options->maxFrameBytes) {
            throw new ProtocolException('WABinary frame exceeds configured limit.');
        }
        $flags = ord($frame[0]);
        if (($flags & ~2) !== 0) {
            throw new ProtocolException('Unsupported WABinary frame flags.');
        }
        $payload = substr($frame, 1);
        if (($flags & 2) !== 0) {
            if (!function_exists('zlib_decode')) {
                throw new ProtocolException('Compressed WABinary frame requires ext-zlib.');
            }
            $maximum = $this->options->maxInflatedBytes;
            $inflated = SafeRuntime::call(static function () use ($payload, $maximum) {
                return zlib_decode($payload, $maximum);
            }, false);
            if (!is_string($inflated)) {
                throw new ProtocolException('Unable to inflate WABinary frame within configured limit.');
            }
            $payload = $inflated;
        }
        if (strlen($payload) > $this->options->maxInflatedBytes) {
            throw new ProtocolException('Inflated WABinary payload exceeds configured limit.');
        }

        $this->nodeCount = 0;
        $reader = new BinaryReader($payload);
        $node = $this->decodeNode($reader, 1);
        if ($reader->remaining() !== 0) {
            throw new ProtocolException('Trailing bytes after WABinary node.');
        }
        return $node;
    }

    private function encodeNode(BinaryNode $node, BinaryWriter $writer, int $depth): void
    {
        $this->countNode($depth);
        $attributes = $node->attributes();
        if (count($attributes) > $this->options->maxAttributesPerNode) {
            throw new ProtocolException('BinaryNode has too many attributes.');
        }
        $children = $node->childNodes();
        if (count($children) > $this->options->maxChildrenPerNode) {
            throw new ProtocolException('BinaryNode has too many children.');
        }

        $hasContent = $node->contentType() !== BinaryNode::CONTENT_NONE;
        $this->writeListStart($writer, 2 * count($attributes) + 1 + ($hasContent ? 1 : 0));
        $this->writeString($writer, $node->tag());
        foreach ($attributes as $key => $value) {
            $this->writeString($writer, $key);
            $this->writeString($writer, $value);
        }

        if ($node->contentType() === BinaryNode::CONTENT_TEXT) {
            $this->writeString($writer, (string) $node->textContent());
        } elseif ($node->contentType() === BinaryNode::CONTENT_BYTES) {
            $bytes = (string) $node->bytesContent();
            $this->writeByteLength($writer, strlen($bytes));
            $writer->append($bytes);
        } elseif ($node->contentType() === BinaryNode::CONTENT_CHILDREN) {
            $this->writeListStart($writer, count($children));
            foreach ($children as $child) {
                $this->encodeNode($child, $writer, $depth + 1);
            }
        }
    }

    private function decodeNode(BinaryReader $reader, int $depth): BinaryNode
    {
        $this->countNode($depth);
        $listSize = $this->readListSize($reader, $reader->byte());
        if ($listSize < 1) {
            throw new ProtocolException('Invalid empty WABinary node.');
        }
        $header = $this->readString($reader, $reader->byte());
        if ($header === '') {
            throw new ProtocolException('Invalid WABinary node header.');
        }
        $attributeCount = ($listSize - 1) >> 1;
        if ($attributeCount > $this->options->maxAttributesPerNode) {
            throw new ProtocolException('WABinary node attribute limit exceeded.');
        }
        $attributes = [];
        for ($index = 0; $index < $attributeCount; $index++) {
            $key = $this->readString($reader, $reader->byte());
            $value = $this->readString($reader, $reader->byte());
            if ($key === '') {
                throw new ProtocolException('WABinary attribute key cannot be empty.');
            }
            $attributes[$key] = $value;
        }

        if (($listSize % 2) !== 0) {
            return new BinaryNode($header, $attributes);
        }
        $contentTag = $reader->byte();
        if ($this->isListTag($contentTag)) {
            $childCount = $this->readListSize($reader, $contentTag);
            if ($childCount > $this->options->maxChildrenPerNode) {
                throw new ProtocolException('WABinary node child limit exceeded.');
            }
            $children = [];
            for ($index = 0; $index < $childCount; $index++) {
                $children[] = $this->decodeNode($reader, $depth + 1);
            }
            return BinaryNode::children($header, $attributes, $children);
        }
        if ($contentTag === BinaryTags::BINARY_8) {
            return BinaryNode::bytes($header, $attributes, $reader->bytes($reader->byte()));
        }
        if ($contentTag === BinaryTags::BINARY_20) {
            return BinaryNode::bytes($header, $attributes, $reader->bytes($reader->integer20()));
        }
        if ($contentTag === BinaryTags::BINARY_32) {
            return BinaryNode::bytes($header, $attributes, $reader->bytes($reader->integer(4)));
        }
        return BinaryNode::text($header, $attributes, $this->readString($reader, $contentTag));
    }

    private function countNode(int $depth): void
    {
        if ($depth > $this->options->maxDepth) {
            throw new ProtocolException('WABinary tree depth limit exceeded.');
        }
        $this->nodeCount++;
        if ($this->nodeCount > $this->options->maxNodes) {
            throw new ProtocolException('WABinary node count limit exceeded.');
        }
    }

    private function writeString(BinaryWriter $writer, ?string $value): void
    {
        if ($value === null) {
            $writer->byte(BinaryTags::LIST_EMPTY);
            return;
        }
        if ($value === '') {
            $this->writeRawString($writer, $value);
            return;
        }
        $token = $this->tokens->lookup($value);
        if ($token !== null) {
            if ($token['dict'] !== null) {
                $writer->byte(BinaryTags::DICTIONARY_0 + $token['dict']);
            }
            $writer->byte($token['index']);
            return;
        }
        if ($this->isNibble($value)) {
            $this->writePacked($writer, $value, BinaryTags::NIBBLE_8);
            return;
        }
        if ($this->isHex($value)) {
            $this->writePacked($writer, $value, BinaryTags::HEX_8);
            return;
        }
        $jid = Jid::parse($value);
        if ($jid instanceof Jid) {
            $this->writeJid($writer, $jid);
            return;
        }
        $this->writeRawString($writer, $value);
    }

    private function writeRawString(BinaryWriter $writer, string $value): void
    {
        if (preg_match('//u', $value) !== 1) {
            throw new ProtocolException('WABinary textual value is not valid UTF-8.');
        }
        $this->writeByteLength($writer, strlen($value));
        $writer->append($value);
    }

    private function writeByteLength(BinaryWriter $writer, int $length): void
    {
        if ($length < 0 || $length >= 4294967296) {
            throw new ProtocolException('WABinary byte string is too large.');
        }
        if ($length >= 1048576) {
            $writer->byte(BinaryTags::BINARY_32);
            $writer->integer($length, 4);
        } elseif ($length >= 256) {
            $writer->byte(BinaryTags::BINARY_20);
            $writer->integer20($length);
        } else {
            $writer->byte(BinaryTags::BINARY_8);
            $writer->byte($length);
        }
    }

    private function writeListStart(BinaryWriter $writer, int $size): void
    {
        if ($size < 0 || $size > 65535) {
            throw new ProtocolException('WABinary list size is out of range.');
        }
        if ($size === 0) {
            $writer->byte(BinaryTags::LIST_EMPTY);
        } elseif ($size < 256) {
            $writer->byte(BinaryTags::LIST_8);
            $writer->byte($size);
        } else {
            $writer->byte(BinaryTags::LIST_16);
            $writer->integer($size, 2);
        }
    }

    private function writeJid(BinaryWriter $writer, Jid $jid): void
    {
        if ($jid->device() !== null) {
            $writer->byte(BinaryTags::AD_JID);
            $writer->byte($jid->domainType());
            $writer->byte((int) $jid->device());
            $this->writeString($writer, $jid->user());
            return;
        }
        $writer->byte(BinaryTags::JID_PAIR);
        if ($jid->user() === '') {
            $writer->byte(BinaryTags::LIST_EMPTY);
        } else {
            $this->writeString($writer, $jid->user());
        }
        $this->writeString($writer, $jid->server());
    }

    private function writePacked(BinaryWriter $writer, string $value, int $tag): void
    {
        $length = strlen($value);
        if ($length === 0 || $length > BinaryTags::PACKED_MAX) {
            throw new ProtocolException('Invalid packed WABinary string length.');
        }
        $writer->byte($tag);
        $packedLength = intdiv($length + 1, 2);
        if (($length % 2) !== 0) {
            $packedLength |= 128;
        }
        $writer->byte($packedLength);
        for ($index = 0; $index < $length; $index += 2) {
            $first = $this->packCharacter($value[$index], $tag);
            $second = $index + 1 < $length ? $this->packCharacter($value[$index + 1], $tag) : 15;
            $writer->byte(($first << 4) | $second);
        }
    }

    private function packCharacter(string $character, int $tag): int
    {
        if ($character >= '0' && $character <= '9') {
            return ord($character) - 48;
        }
        if ($tag === BinaryTags::NIBBLE_8) {
            if ($character === '-') { return 10; }
            if ($character === '.') { return 11; }
        } elseif ($tag === BinaryTags::HEX_8) {
            if ($character >= 'A' && $character <= 'F') { return ord($character) - 55; }
            if ($character >= 'a' && $character <= 'f') { return ord($character) - 87; }
        }
        throw new ProtocolException('Invalid character in packed WABinary string.');
    }

    private function isNibble(string $value): bool
    {
        $length = strlen($value);
        if ($length === 0 || $length > BinaryTags::PACKED_MAX) { return false; }
        for ($index = 0; $index < $length; $index++) {
            $character = $value[$index];
            if (!(($character >= '0' && $character <= '9') || $character === '-' || $character === '.')) { return false; }
        }
        return true;
    }

    private function isHex(string $value): bool
    {
        $length = strlen($value);
        if ($length === 0 || $length > BinaryTags::PACKED_MAX) { return false; }
        for ($index = 0; $index < $length; $index++) {
            $character = $value[$index];
            if (!(($character >= '0' && $character <= '9') || ($character >= 'A' && $character <= 'F'))) { return false; }
        }
        return true;
    }

    private function readString(BinaryReader $reader, int $tag): string
    {
        if ($tag >= 1 && $tag < $this->tokens->singleCount()) {
            return $this->tokens->single($tag);
        }
        if ($tag >= BinaryTags::DICTIONARY_0 && $tag <= BinaryTags::DICTIONARY_3) {
            return $this->tokens->double($tag - BinaryTags::DICTIONARY_0, $reader->byte());
        }
        if ($tag === BinaryTags::LIST_EMPTY) { return ''; }
        if ($tag === BinaryTags::BINARY_8) { return $this->readUtf8($reader->bytes($reader->byte())); }
        if ($tag === BinaryTags::BINARY_20) { return $this->readUtf8($reader->bytes($reader->integer20())); }
        if ($tag === BinaryTags::BINARY_32) { return $this->readUtf8($reader->bytes($reader->integer(4))); }
        if ($tag === BinaryTags::JID_PAIR) { return $this->readJidPair($reader); }
        if ($tag === BinaryTags::AD_JID) { return $this->readAdJid($reader); }
        if ($tag === BinaryTags::FB_JID) { return $this->readFbJid($reader); }
        if ($tag === BinaryTags::INTEROP_JID) { return $this->readInteropJid($reader); }
        if ($tag === BinaryTags::HEX_8 || $tag === BinaryTags::NIBBLE_8) { return $this->readPacked($reader, $tag); }
        throw new ProtocolException('Invalid WABinary string tag: ' . $tag);
    }

    private function readJidPair(BinaryReader $reader): string
    {
        $user = $this->readString($reader, $reader->byte());
        $server = $this->readString($reader, $reader->byte());
        if ($server === '') { throw new ProtocolException('Invalid empty JID server.'); }
        return $user . '@' . $server;
    }

    private function readAdJid(BinaryReader $reader): string
    {
        $domain = $reader->byte();
        $device = $reader->byte();
        $user = $this->readString($reader, $reader->byte());
        $server = 's.whatsapp.net';
        if ($domain === JidDomain::LID) { $server = 'lid'; }
        elseif ($domain === JidDomain::HOSTED) { $server = 'hosted'; }
        elseif ($domain === JidDomain::HOSTED_LID) { $server = 'hosted.lid'; }
        return Jid::encode($user, $server, $device);
    }

    private function readFbJid(BinaryReader $reader): string
    {
        $user = $this->readString($reader, $reader->byte());
        $device = $reader->integer(2);
        $server = $this->readString($reader, $reader->byte());
        return $user . ':' . $device . '@' . $server;
    }

    private function readInteropJid(BinaryReader $reader): string
    {
        $user = $this->readString($reader, $reader->byte());
        $device = $reader->integer(2);
        $integrator = $reader->integer(2);
        $server = 'interop';
        $before = $reader->offset();
        try {
            $server = $this->readString($reader, $reader->byte());
        } catch (ProtocolException $error) {
            $reader->setOffset($before);
        }
        return $integrator . '-' . $user . ':' . $device . '@' . $server;
    }

    private function readPacked(BinaryReader $reader, int $tag): string
    {
        $start = $reader->byte();
        $count = $start & 127;
        $value = '';
        $lastByte = null;
        for ($index = 0; $index < $count; $index++) {
            $byte = $reader->byte();
            $lastByte = $byte;
            $value .= $this->unpackCharacter(($byte >> 4) & 15, $tag);
            $value .= $this->unpackCharacter($byte & 15, $tag);
        }
        if (($start >> 7) !== 0) {
            // Both packed alphabets use low nibble 0xF as the odd-length
            // sentinel. HEX decodes that nibble to the valid character "F",
            // while NIBBLE decodes it to NUL, so validation must happen on the
            // raw nibble rather than on the decoded string.
            if (!is_int($lastByte) || ($lastByte & 15) !== 15 || $value === '') {
                throw new ProtocolException('Invalid packed WABinary padding.');
            }
            $value = substr($value, 0, -1);
        }
        return $value;
    }

    private function unpackCharacter(int $value, int $tag): string
    {
        if ($value >= 0 && $value <= 9) { return chr(48 + $value); }
        if ($tag === BinaryTags::NIBBLE_8) {
            if ($value === 10) { return '-'; }
            if ($value === 11) { return '.'; }
            if ($value === 15) { return "\0"; }
        } elseif ($tag === BinaryTags::HEX_8 && $value >= 10 && $value <= 15) {
            return chr(55 + $value);
        }
        throw new ProtocolException('Invalid packed WABinary character.');
    }

    private function readListSize(BinaryReader $reader, int $tag): int
    {
        if ($tag === BinaryTags::LIST_EMPTY) { return 0; }
        if ($tag === BinaryTags::LIST_8) { return $reader->byte(); }
        if ($tag === BinaryTags::LIST_16) { return $reader->integer(2); }
        throw new ProtocolException('Invalid WABinary list tag: ' . $tag);
    }

    private function isListTag(int $tag): bool
    {
        return $tag === BinaryTags::LIST_EMPTY || $tag === BinaryTags::LIST_8 || $tag === BinaryTags::LIST_16;
    }

    private function readUtf8(string $value): string
    {
        if (preg_match('//u', $value) !== 1) {
            throw new ProtocolException('Invalid UTF-8 in WABinary string.');
        }
        return $value;
    }
}
