<?php

namespace PhpAmqpLib\Wire;

use PhpAmqpLib\Channel\AbstractChannel;
use PhpAmqpLib\Exception;
use PhpAmqpLib\Wire;

/**
 * Iterator implemented for transparent integration with AMQPWriter::write_[array|table]()
 */
abstract class AMQPAbstractCollection implements \Iterator, \ArrayAccess
{
    //protocol defines available field types and their corresponding symbols
    const PROTOCOL_RBT = 'rabbit'; //pseudo proto

    //Abstract data types
    const T_INT_SHORTSHORT = 1;
    const T_INT_SHORTSHORT_U = 2;
    const T_INT_SHORT = 3;
    const T_INT_SHORT_U = 4;
    const T_INT_LONG = 5;
    const T_INT_LONG_U = 6;
    const T_INT_LONGLONG = 7;
    const T_INT_LONGLONG_U = 8;

    const T_DECIMAL = 9;
    const T_TIMESTAMP = 10;
    const T_VOID = 11;

    const T_BOOL = 12;

    const T_STRING_SHORT = 13;
    const T_STRING_LONG = 14;

    const T_ARRAY = 15;
    const T_TABLE = 16;

    const T_BYTES = 17;

    const T_FLOAT = 18;
    const T_DOUBLE = 19;

    /**
     * @var string
     */
    private static $protocol;

    /*
     * Field types messy mess http://www.rabbitmq.com/amqp-0-9-1-errata.html#section_3
     * Default behaviour is to use rabbitMQ compatible field-set
     * Define AMQP_STRICT_FLD_TYPES=true to use strict AMQP instead
     * @var array<int, string>
     */
    private static $types_080 = array(
        self::T_INT_LONG => 'I',
        self::T_DECIMAL => 'D',
        self::T_TIMESTAMP => 'T',
        self::T_STRING_LONG => 'S',
        self::T_TABLE => 'F'
    );

    /**
     * @var array<int, string>
     */
    private static $types_091 = array(
        self::T_INT_SHORTSHORT => 'b',
        self::T_INT_SHORTSHORT_U => 'B',
        self::T_INT_SHORT => 'U',
        self::T_INT_SHORT_U => 'u',
        self::T_INT_LONG => 'I',
        self::T_INT_LONG_U => 'i',
        self::T_INT_LONGLONG => 'L',
        self::T_INT_LONGLONG_U => 'l',
        self::T_FLOAT => 'f',
        self::T_DOUBLE => 'd',
        self::T_DECIMAL => 'D',
        self::T_TIMESTAMP => 'T',
        self::T_VOID => 'V',
        self::T_BOOL => 't',
        self::T_STRING_SHORT => 's',
        self::T_STRING_LONG => 'S',
        self::T_ARRAY => 'A',
        self::T_TABLE => 'F',
        self::T_BYTES => 'x',
    );

    /**
     * @var array<int, string>
     */
    private static $types_rabbit = array(
        self::T_INT_SHORTSHORT => 'b',
        self::T_INT_SHORTSHORT_U => 'B',
        self::T_INT_SHORT => 's',
        self::T_INT_SHORT_U => 'u',
        self::T_INT_LONG => 'I',
        self::T_INT_LONG_U => 'i',
        self::T_INT_LONGLONG => 'l',
        self::T_FLOAT => 'f',
        self::T_DOUBLE => 'd',
        self::T_DECIMAL => 'D',
        self::T_TIMESTAMP => 'T',
        self::T_VOID => 'V',
        self::T_BOOL => 't',
        self::T_STRING_LONG => 'S',
        self::T_ARRAY => 'A',
        self::T_TABLE => 'F',
        self::T_BYTES => 'x',
    );

    /**
     * @var array
     */
    protected $data = array();

    public function __construct(array $data = null)
    {
        if (!empty($data)) {
            $this->data = $this->encodeCollection($data);
        }
    }

    /**
     * @return int
     */
    abstract public function getType();

    /**
     * @param mixed $val
     * @param int|null $type
     * @param string $key
     */
    final protected function setValue($val, $type = null, $key = null)
    {
        if ($val instanceof self) {
            if ($type && ($type !== $val->getType())) {
                throw new Exception\AMQPInvalidArgumentException(
                    sprintf(
                        'Attempted to add instance of %s representing type [%s] as mismatching type [%s]',
                        get_class($val),
                        $val->getType(),
                        $type
                    )
                );
            }
            $type = $val->getType();
        } elseif ($type) { //ensuring data integrity and that all members are properly validated
            switch ($type) {
                case self::T_ARRAY:
                    throw new Exception\AMQPInvalidArgumentException('Arrays must be passed as AMQPArray instance');
                case self::T_TABLE:
                    throw new Exception\AMQPInvalidArgumentException('Tables must be passed as AMQPTable instance');
                case self::T_DECIMAL:
                    if (!($val instanceof AMQPDecimal)) {
                        throw new Exception\AMQPInvalidArgumentException(
                            'Decimal values must be instance of AMQPDecimal'
                        );
                    }
                    break;
            }
        }

        if ($type) {
            self::checkDataTypeIsSupported($type, false);
            $val = array($type, $val);
        } else {
            $val = $this->encodeValue($val);
        }

        if ($key === null) {
            $this->data[] = $val;
        } else {
            $this->data[$key] = $val;
        }
    }

    /**
     * @return array
     */
    final public function getNativeData()
    {
        return $this->decodeCollection($this->data);
    }

    /**
     * @param array $val
     * @return array
     */
    final protected function encodeCollection(array $val)
    {
        foreach ($val as $k => $v) {
            $val[$k] = $this->encodeValue($v);
        }

        return $val;
    }

    /**
     * @param array $val
     * @return array
     */
    final protected function decodeCollection(array $val)
    {
        foreach ($val as $k => $v) {
            $val[$k] = $this->decodeValue($v[1], $v[0]);
        }

        return $val;
    }

    public function offsetExists($offset): bool
    {
        return isset($this->data[$offset]);
    }

    /**
     * @param mixed $offset
     * @return mixed
     */
    #[\ReturnTypeWillChange]
    public function offsetGet($offset)
    {
        $value = isset($this->data[$offset]) ? $this->data[$offset] : null;

        return is_array($value) ? $value[1] : $value;
    }

    public function offsetSet($offset, $value): void
    {
        $this->setValue($value, null, $offset);
    }

    public function offsetUnset($offset): void
    {
        unset($this->data[$offset]);
    }

    /**
     * @param mixed $val
     * @return mixed
     * @throws Exception\AMQPOutOfBoundsException
     */
    protected function encodeValue($val)
    {
        if (is_string($val)) {
            $val = $this->encodeString($val);
        } elseif (is_float($val)) {
            $val = $this->encodeFloat($val);
        } elseif (is_int($val)) {
            $val = $this->encodeInt($val);
        } elseif (is_bool($val)) {
            $val = $this->encodeBool($val);
        } elseif (is_null($val)) {
            $val = $this->encodeVoid();
        } elseif ($val instanceof \DateTimeInterface) {
            $val = array(self::T_TIMESTAMP, $val->getTimestamp());
        } elseif ($val instanceof AMQPDecimal) {
            $val = array(self::T_DECIMAL, $val);
        } elseif ($val instanceof self) {
            //avoid silent type correction of strictly typed values
            self::checkDataTypeIsSupported($val->getType(), false);
            $val = array($val->getType(), $val);
        } elseif (is_array($val)) {
            //AMQP specs says "Field names MUST start with a letter, '$' or '#'"
            //so beware, some servers may raise an exception with 503 code in cases when indexed
            // array is encoded as table
            if (self::isProtocol(Wire\Constants080::VERSION)) {
                //080 doesn't support arrays, forcing table
                $val = array(self::T_TABLE, new AMQPTable($val));
            } elseif (empty($val) || (array_keys($val) === range(0, count($val) - 1))) {
                $val = array(self::T_ARRAY, new AMQPArray($val));
            } else {
                $val = array(self::T_TABLE, new AMQPTable($val));
            }
        } else {
            throw new Exception\AMQPOutOfBoundsException(
                sprintf('Encountered value of unsupported type: %s', gettype($val))
            );
        }

        return $val;
    }

    /**
     * @param mixed $val
     * @param int $type
     * @return array|bool|\DateTime|null
     */
    protected function decodeValue($val, $type)
    {
        if ($val instanceof self) {
            //covering arrays and tables
            $val = $val->getNativeData();
        } else {
            switch ($type) {
                case self::T_BOOL:
                    $val = (bool) $val;
                    break;
                case self::T_TIMESTAMP:
                    $val = \DateTime::createFromFormat('U', $val);
                    break;
                case self::T_VOID:
                    $val = null;
                    break;
                case self::T_ARRAY:
                case self::T_TABLE:
                    throw new Exception\AMQPLogicException(
                        sprintf(
                            '%s %s',
                            'Encountered an array/table struct which is not an instance of AMQPCollection.',
                            'This is considered a bug and should be fixed, please report'
                        )
                    );
            }
        }

        return $val;
    }

    /**
     * @param string $val
     * @return array
     */
    protected function encodeString($val)
    {
        return array(self::T_STRING_LONG, $val);
    }

    /**
     * @param int $val
     * @return array
     */
    protected function encodeInt($val)
    {
        if (($val >= -2147483648) && ($val <= 2147483647)) {
            $ev = array(self::T_INT_LONG, $val);
        } elseif (self::isProtocol(Wire\Constants080::VERSION)) {
            //080 doesn't support longlong
            $ev = $this->encodeString((string) $val);
        } else {
            $ev = array(self::T_INT_LONGLONG, $val);
        }

        return $ev;
    }

    /**
     * @param float $val
     * @return array
     */
    protected function encodeFloat($val)
    {
        return $this->encodeString((string) $val);
    }

    /**
     * @param bool $val
     * @return array
     */
    protected function encodeBool($val)
    {
        $val = (bool) $val;

        return self::isProtocol(Wire\Constants080::VERSION)
            ? array(self::T_INT_LONG, (int) $val)
            : array(self::T_BOOL, $val);
    }

    /**
     * @return array
     */
    protected function encodeVoid()
    {
        return self::isProtocol(Wire\Constants080::VERSION) ? $this->encodeString('') : array(self::T_VOID, null);
    }

    /**
     * @return string
     */
    final public static function getProtocol()
    {
        if (self::$protocol === null) {
            self::$protocol = defined('AMQP_STRICT_FLD_TYPES') && AMQP_STRICT_FLD_TYPES ?
                AbstractChannel::getProtocolVersion() :
                self::PROTOCOL_RBT;
        }

        return self::$protocol;
    }

    /**
     * @param string $proto
     * @return bool
     */
    final public static function isProtocol($proto)
    {
        return self::getProtocol() === $proto;
    }

    /**
     * @return array  [dataTypeConstant => dataTypeSymbol]
     */
    final public static function getSupportedDataTypes()
    {
        switch ($proto = self::getProtocol()) {
            case Wire\Constants080::VERSION:
                $types = self::$types_080;
                break;
            case Wire\Constants091::VERSION:
                $types = self::$types_091;
                break;
            case self::PROTOCOL_RBT:
                $types = self::$types_rabbit;
                break;
            default:
                throw new Exception\AMQPOutOfRangeException(sprintf('Unknown protocol: %s', $proto));
        }

        return $types;
    }

    /**
     * @param string $type
     * @param bool $return Whether to return or raise AMQPOutOfRangeException
     * @return boolean
     */
    final public static function checkDataTypeIsSupported($type, $return = true)
    {
        try {
            $supported = self::getSupportedDataTypes();
            if (!isset($supported[$type])) {
                throw new Exception\AMQPOutOfRangeException(sprintf(
                    'AMQP-%s doesn\'t support data of type [%s]',
                    self::getProtocol(),
                    $type
                ));
            }
            return true;
        } catch (Exception\AMQPOutOfRangeException $ex) {
            if (!$return) {
                throw $ex;
            }

            return false;
        }
    }

    /**
     * @param int $type
     * @return string
     */
    final public static function getSymbolForDataType($type)
    {
        $types = self::getSupportedDataTypes();
        if (!isset($types[$type])) {
            throw new Exception\AMQPOutOfRangeException(sprintf(
                'AMQP-%s doesn\'t support data of type [%s]',
                self::getProtocol(),
                $type
            ));
        }

        return $types[$type];
    }

    /**
     * @param string $symbol
     * @return integer
     */
    final public static function getDataTypeForSymbol($symbol)
    {
        $symbols = array_flip(self::getSupportedDataTypes());
        if (!isset($symbols[$symbol])) {
            throw new Exception\AMQPOutOfRangeException(sprintf(
                'AMQP-%s doesn\'t define data of type [%s]',
                self::getProtocol(),
                $symbol
            ));
        }

        return $symbols[$symbol];
    }

    /**
     * @return mixed
     */
    #[\ReturnTypeWillChange]
    public function current()
    {
        return current($this->data);
    }

    /**
     * @return mixed
     */
    #[\ReturnTypeWillChange]
    public function key()
    {
        return key($this->data);
    }

    public function next(): void
    {
        next($this->data);
    }

    public function rewind(): void
    {
        reset($this->data);
    }

    public function valid(): bool
    {
        return key($this->data) !== null;
    }
}
