<?php

declare(strict_types=1);

namespace PHPMax\Protocol\Tcp;

use PHPMax\Exception\ProtocolException;

final class MsgpackPayloadCodec
{
    private const WRAPPED_VALUE_EXT_CODE = 1;
    private const MAX_WRAPPED_EXTENSION_DEPTH = 32;

    


    public function encode($payload): string
    {
        if ($payload === null) {
            return '';
        }
        if (function_exists('msgpack_pack') && !$this->containsFallbackOnlyValue($payload)) {
             
            $packed = msgpack_pack($payload);
            return $packed;
        }

        return $this->packValue($payload);
    }

    


    public function decode(string $payloadBytes)
    {
        if ($payloadBytes === '') {
            return [];
        }
        $offset = 0;
        return $this->unpackValue($payloadBytes, $offset, 0);
    }

    


    private function packValue($value): string
    {
        if ($value === null) {
            return "\xC0";
        }
        if ($value === false) {
            return "\xC2";
        }
        if ($value === true) {
            return "\xC3";
        }
        if (is_int($value)) {
            return $this->packInt($value);
        }
        if (is_float($value)) {
            return "\xCB" . pack('E', $value);
        }
        if (is_string($value)) {
            return $this->packString($value);
        }
        if ($value instanceof BinaryString) {
            return $this->packBinary($value->bytes());
        }
        if ($value instanceof MessagePackExtension) {
            return $this->packExtension($value);
        }
        if (is_array($value)) {
            return $this->isList($value) ? $this->packArray($value) : $this->packMap($value);
        }

        throw new ProtocolException('Unsupported MessagePack value type: ' . gettype($value));
    }

    private function packInt(int $value): string
    {
        if ($value >= 0 && $value <= 0x7F) {
            return chr($value);
        }
        if ($value < 0 && $value >= -32) {
            return chr(0xE0 | ($value + 32));
        }
        if ($value >= 0 && $value <= 0xFF) {
            return "\xCC" . pack('C', $value);
        }
        if ($value >= 0 && $value <= 0xFFFF) {
            return "\xCD" . pack('n', $value);
        }
        if ($value >= 0 && $value <= 0xFFFFFFFF) {
            return "\xCE" . pack('N', $value);
        }
        if ($value >= 0) {
            return "\xCF" . $this->packUInt64($value);
        }
        if ($value >= -128 && $value < 0) {
            return "\xD0" . pack('c', $value);
        }
        if ($value >= -32768 && $value < 0) {
            return "\xD1" . pack('n', $value & 0xFFFF);
        }
        if ($value >= -2147483648 && $value < 0) {
            return "\xD2" . pack('N', $value & 0xFFFFFFFF);
        }

        return "\xD3" . $this->packInt64($value);
    }

    private function packString(string $value): string
    {
        $length = strlen($value);
        if ($length <= 31) {
            return chr(0xA0 | $length) . $value;
        }
        if ($length <= 0xFF) {
            return "\xD9" . pack('C', $length) . $value;
        }
        if ($length <= 0xFFFF) {
            return "\xDA" . pack('n', $length) . $value;
        }

        return "\xDB" . pack('N', $length) . $value;
    }

    private function packBinary(string $value): string
    {
        $length = strlen($value);
        if ($length <= 0xFF) {
            return "\xC4" . pack('C', $length) . $value;
        }
        if ($length <= 0xFFFF) {
            return "\xC5" . pack('n', $length) . $value;
        }

        return "\xC6" . pack('N', $length) . $value;
    }

    private function packExtension(MessagePackExtension $extension): string
    {
        $value = $extension->data();
        $length = strlen($value);
        $type = pack('C', $extension->type() & 0xFF);

        switch ($length) {
            case 1:
                return "\xD4" . $type . $value;
            case 2:
                return "\xD5" . $type . $value;
            case 4:
                return "\xD6" . $type . $value;
            case 8:
                return "\xD7" . $type . $value;
            case 16:
                return "\xD8" . $type . $value;
        }

        if ($length <= 0xFF) {
            return "\xC7" . pack('C', $length) . $type . $value;
        }
        if ($length <= 0xFFFF) {
            return "\xC8" . pack('n', $length) . $type . $value;
        }

        return "\xC9" . pack('N', $length) . $type . $value;
    }

    


    private function packArray(array $items): string
    {
        $length = count($items);
        if ($length <= 15) {
            $out = chr(0x90 | $length);
        } elseif ($length <= 0xFFFF) {
            $out = "\xDC" . pack('n', $length);
        } else {
            $out = "\xDD" . pack('N', $length);
        }
        foreach ($items as $item) {
            $out .= $this->packValue($item);
        }
        return $out;
    }

    


    private function packMap(array $items): string
    {
        $length = count($items);
        if ($length <= 15) {
            $out = chr(0x80 | $length);
        } elseif ($length <= 0xFFFF) {
            $out = "\xDE" . pack('n', $length);
        } else {
            $out = "\xDF" . pack('N', $length);
        }
        foreach ($items as $key => $value) {
            $out .= $this->packValue($key);
            $out .= $this->packValue($value);
        }
        return $out;
    }

    


    private function containsFallbackOnlyValue($value): bool
    {
        if ($value instanceof BinaryString || $value instanceof MessagePackExtension) {
            return true;
        }
        if (!is_array($value)) {
            return false;
        }
        foreach ($value as $item) {
            if ($this->containsFallbackOnlyValue($item)) {
                return true;
            }
        }
        return false;
    }

    


    private function unpackValue(string $bytes, int &$offset, int $extensionDepth)
    {
        if ($offset >= strlen($bytes)) {
            throw new ProtocolException('MessagePack: unexpected end of payload');
        }

        $prefix = ord($bytes[$offset]);
        $offset++;

        if ($prefix <= 0x7F) {
            return $prefix;
        }
        if ($prefix >= 0xE0) {
            return $prefix - 0x100;
        }
        if (($prefix & 0xE0) === 0xA0) {
            return $this->readBytes($bytes, $offset, $prefix & 0x1F);
        }
        if (($prefix & 0xF0) === 0x90) {
            return $this->unpackArray($bytes, $offset, $prefix & 0x0F, $extensionDepth);
        }
        if (($prefix & 0xF0) === 0x80) {
            return $this->unpackMap($bytes, $offset, $prefix & 0x0F, $extensionDepth);
        }

        switch ($prefix) {
            case 0xC0:
                return null;
            case 0xC2:
                return false;
            case 0xC3:
                return true;
            case 0xCC:
                return $this->readUInt($bytes, $offset, 1);
            case 0xCD:
                return $this->readUInt($bytes, $offset, 2);
            case 0xCE:
                return $this->readUInt($bytes, $offset, 4);
            case 0xCF:
                return $this->readUInt64($bytes, $offset);
            case 0xD0:
                return $this->readInt($bytes, $offset, 1);
            case 0xD1:
                return $this->readInt($bytes, $offset, 2);
            case 0xD2:
                return $this->readInt($bytes, $offset, 4);
            case 0xD3:
                return $this->readInt64($bytes, $offset);
            case 0xD9:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 1));
            case 0xDA:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 2));
            case 0xDB:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 4));
            case 0xC4:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 1));
            case 0xC5:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 2));
            case 0xC6:
                return $this->readBytes($bytes, $offset, $this->readUInt($bytes, $offset, 4));
            case 0xC7:
                return $this->unpackExtension($bytes, $offset, $this->readUInt($bytes, $offset, 1), $extensionDepth);
            case 0xC8:
                return $this->unpackExtension($bytes, $offset, $this->readUInt($bytes, $offset, 2), $extensionDepth);
            case 0xC9:
                return $this->unpackExtension($bytes, $offset, $this->readUInt($bytes, $offset, 4), $extensionDepth);
            case 0xD4:
                return $this->unpackExtension($bytes, $offset, 1, $extensionDepth);
            case 0xD5:
                return $this->unpackExtension($bytes, $offset, 2, $extensionDepth);
            case 0xD6:
                return $this->unpackExtension($bytes, $offset, 4, $extensionDepth);
            case 0xD7:
                return $this->unpackExtension($bytes, $offset, 8, $extensionDepth);
            case 0xD8:
                return $this->unpackExtension($bytes, $offset, 16, $extensionDepth);
            case 0xDC:
                return $this->unpackArray($bytes, $offset, $this->readUInt($bytes, $offset, 2), $extensionDepth);
            case 0xDD:
                return $this->unpackArray($bytes, $offset, $this->readUInt($bytes, $offset, 4), $extensionDepth);
            case 0xDE:
                return $this->unpackMap($bytes, $offset, $this->readUInt($bytes, $offset, 2), $extensionDepth);
            case 0xDF:
                return $this->unpackMap($bytes, $offset, $this->readUInt($bytes, $offset, 4), $extensionDepth);
            case 0xCA:
                $raw = $this->readBytes($bytes, $offset, 4);
                $unpacked = unpack('G', $raw);
                return $unpacked[1];
            case 0xCB:
                $raw = $this->readBytes($bytes, $offset, 8);
                $unpacked = unpack('E', $raw);
                return $unpacked[1];
        }

        throw new ProtocolException('Unsupported MessagePack prefix 0x' . strtoupper(dechex($prefix)));
    }

    


    private function unpackArray(string $bytes, int &$offset, int $length, int $extensionDepth): array
    {
        $items = [];
        for ($i = 0; $i < $length; $i++) {
            $items[] = $this->unpackValue($bytes, $offset, $extensionDepth);
        }
        return $items;
    }

    


    private function unpackMap(string $bytes, int &$offset, int $length, int $extensionDepth): array
    {
        $items = [];
        for ($i = 0; $i < $length; $i++) {
            $key = $this->unpackValue($bytes, $offset, $extensionDepth);
            if (!is_int($key) && !is_string($key)) {
                throw new ProtocolException('MessagePack map key must be an integer or string');
            }
            $items[$key] = $this->unpackValue($bytes, $offset, $extensionDepth);
        }
        return $items;
    }

    





    private function unpackExtension(string $bytes, int &$offset, int $length, int $extensionDepth)
    {
        $type = $this->readSignedByte($bytes, $offset);
        $data = $this->readBytes($bytes, $offset, $length);

        if ($type !== self::WRAPPED_VALUE_EXT_CODE) {
            return new MessagePackExtension($type, $data);
        }
        if ($extensionDepth >= self::MAX_WRAPPED_EXTENSION_DEPTH) {
            throw new ProtocolException('MessagePack wrapped extension nesting limit exceeded');
        }
        if ($data === '') {
            throw new ProtocolException('MessagePack wrapped extension payload is empty');
        }

        $innerOffset = 0;
        $value = $this->unpackValue($data, $innerOffset, $extensionDepth + 1);
        if ($innerOffset !== strlen($data)) {
            throw new ProtocolException('MessagePack wrapped extension contains trailing data');
        }

        return $value;
    }

    private function readSignedByte(string $bytes, int &$offset): int
    {
        $value = $this->readUInt($bytes, $offset, 1);

        return $value > 0x7F ? $value - 0x100 : $value;
    }

    private function readUInt(string $bytes, int &$offset, int $length): int
    {
        $raw = $this->readBytes($bytes, $offset, $length);
        if ($length === 1) {
            return ord($raw);
        }
        if ($length === 2) {
            $parts = unpack('n', $raw);
            return (int) $parts[1];
        }
        $parts = unpack('N', $raw);
        return (int) $parts[1];
    }

    private function readInt(string $bytes, int &$offset, int $length): int
    {
        $value = $this->readUInt($bytes, $offset, $length);
        $bits = $length * 8;
        $sign = 1 << ($bits - 1);
        if (($value & $sign) !== 0) {
            $value -= 1 << $bits;
        }
        return $value;
    }

    private function packUInt64(int $value): string
    {
        $high = intdiv($value, 4294967296);
        $low = $value % 4294967296;

        return pack('N2', $high, $low);
    }

    private function packInt64(int $value): string
    {
        if ($value >= 0) {
            return $this->packUInt64($value);
        }

        $absMinusOne = -($value + 1);
        $high = intdiv($absMinusOne, 4294967296);
        $low = $absMinusOne % 4294967296;

        return pack('N2', (~$high) & 0xFFFFFFFF, (~$low) & 0xFFFFFFFF);
    }

    private function readUInt64(string $bytes, int &$offset): int
    {
        $raw = $this->readBytes($bytes, $offset, 8);
        $parts = unpack('Nhigh/Nlow', $raw);
        $high = (int) $parts['high'];
        $low = (int) $parts['low'];

        if ($high > 0x7FFFFFFF) {
            throw new ProtocolException('MessagePack uint64 exceeds PHP integer range');
        }

        return $high * 4294967296 + $low;
    }

    private function readInt64(string $bytes, int &$offset): int
    {
        $raw = $this->readBytes($bytes, $offset, 8);
        $parts = unpack('Nhigh/Nlow', $raw);
        $high = (int) $parts['high'];
        $low = (int) $parts['low'];

        if (($high & 0x80000000) === 0) {
            return $high * 4294967296 + $low;
        }

        if ($high === 0x80000000 && $low === 0) {
            return PHP_INT_MIN;
        }

        $magnitude = ((~$high) & 0xFFFFFFFF) * 4294967296 + ((~$low) & 0xFFFFFFFF) + 1;

        return -$magnitude;
    }

    private function readBytes(string $bytes, int &$offset, int $length): string
    {
        if ($offset + $length > strlen($bytes)) {
            throw new ProtocolException('MessagePack: unexpected end of payload');
        }
        $raw = substr($bytes, $offset, $length);
        $offset += $length;
        return $raw;
    }

    


    private function isList(array $value): bool
    {
        $index = 0;
        foreach ($value as $key => $_) {
            if ($key !== $index) {
                return false;
            }
            $index++;
        }
        return true;
    }
}
