<?php
// Bencode.php
// Created: 2022-01-13
// Updated: 2024-08-01

namespace Index\Bencode;

use InvalidArgumentException;
use RuntimeException;
use Index\IO\GenericStream;
use Index\IO\Stream;
use Index\IO\TempFileStream;

/**
 * Provides a Bencode serialiser.
 */
final class Bencode {
    /**
     * Default maximum depth.
     *
     * @var int
     */
    public const DEFAULT_DEPTH = 512;

    /**
     * Returns the Bencoded representation of a value.
     *
     * @param mixed $value The value being encoded. Can by string, integer, array or an object.
     * @param int $depth Set the maximum depth. Must be greater than zero.
     * @throws RuntimeException If $depth is an invalid value.
     * @return string Returns a Bencoded string.
     */
    public static function encode(mixed $value, int $depth = self::DEFAULT_DEPTH): string {
        if($depth < 1)
            throw new RuntimeException('Maximum depth reached, structure is too dense.');

        switch(gettype($value)) {
            case 'string':
                return sprintf('%d:%s', strlen($value), $value);

            case 'integer':
                return sprintf('i%de', $value);

            case 'array':
                if(array_is_list($value)) {
                    $output = 'l';
                    foreach($value as $item)
                        $output .= self::encode($item, $depth - 1);
                } else {
                    $output = 'd';
                    foreach($value as $_key => $_value) {
                        $output .= self::encode((string)$_key, $depth - 1);
                        $output .= self::encode($_value, $depth - 1);
                    }
                }

                return $output . 'e';

            case 'object':
                if($value instanceof IBencodeSerialisable)
                    return self::encode($value->bencodeSerialise(), $depth - 1);

                $value = get_object_vars($value);
                $output = 'd';

                foreach($value as $_key => $_value) {
                    $output .= self::encode((string)$_key, $depth - 1);
                    $output .= self::encode($_value, $depth - 1);
                }

                return $output . 'e';

            default:
                return '';
        }
    }

    /**
     * Decodes a bencoded string.
     *
     * @param mixed $bencoded The bencoded string being decoded. Can be an Index Stream, readable resource or a string.
     * @param int $depth Maximum nesting depth of the structure being decoded. The value must be greater than 0.
     * @param bool $associative When true, Bencoded dictionaries will be returned as associative arrays; when false, Bencoded dictionaries will be returned as objects.
     * @throws InvalidArgumentException If $bencoded string is not set to a valid type.
     * @throws InvalidArgumentException If $depth is not greater than 0.
     * @throws RuntimeException If the Bencoded stream is in an invalid state.
     * @return mixed Returns the bencoded value as an appropriate PHP type.
     */
    public static function decode(mixed $bencoded, int $depth = self::DEFAULT_DEPTH, bool $associative = true): mixed {
        if(is_string($bencoded)) {
            $bencoded = TempFileStream::fromString($bencoded);
            $bencoded->seek(0);
        } elseif(is_resource($bencoded))
            $bencoded = new GenericStream($bencoded);
        elseif(!($bencoded instanceof Stream))
            throw new InvalidArgumentException('$bencoded must be a string, an Index Stream or a file resource.');

        if($depth < 1)
            throw new InvalidArgumentException('Maximum depth reached, structure is too dense.');

        $char = $bencoded->readChar();
        if($char === null)
            throw new RuntimeException('Unexpected end of stream in $bencoded.');

        switch($char) {
            case 'i':
                $number = '';

                for(;;) {
                    $char = $bencoded->readChar();
                    if($char === null)
                        throw new RuntimeException('Unexpected end of stream while parsing integer.');
                    if($char === 'e')
                        break;
                    if($char === '-' && $number !== '')
                        throw new RuntimeException('Unexpected - (minus) while parsing integer.');
                    if($char === '0' && $number === '-')
                        throw new RuntimeException('Negative integer zero.');
                    if($char === '0' && $number === '0')
                        throw new RuntimeException('Integer double zero.');
                    if(!ctype_digit($char))
                        throw new RuntimeException('Unexpected character while parsing integer.');
                    $number .= $char;
                }

                return (int)$number;

            case 'l':
                $list = [];

                for(;;) {
                    $char = $bencoded->readChar();
                    if($char === null)
                        throw new RuntimeException('Unexpected end of stream while parsing list.');
                    if($char === 'e')
                        break;
                    $bencoded->seek(-1, Stream::CURRENT);
                    $list[] = self::decode($bencoded, $depth - 1, $associative);
                }

                return $list;

            case 'd':
                $dict = [];

                for(;;) {
                    $char = $bencoded->readChar();
                    if($char === null)
                        throw new RuntimeException('Unexpected end of stream while parsing dictionary');
                    if($char === 'e')
                        break;
                    if(!ctype_digit($char))
                        throw new RuntimeException('Unexpected dictionary key type, expected a string.');
                    $bencoded->seek(-1, Stream::CURRENT);
                    $dict[self::decode($bencoded, $depth - 1, $associative)] = self::decode($bencoded, $depth - 1, $associative);
                }

                if(!$associative)
                    $dict = (object)$dict;

                return $dict;

            default:
                if(!ctype_digit($char))
                    throw new RuntimeException('Unexpected character while parsing string.');

                $length = $char;

                for(;;) {
                    $char = $bencoded->readChar();
                    if($char === null)
                        throw new RuntimeException('Unexpected end of character while parsing string length.');
                    if($char === ':')
                        break;
                    if($char === '0' && $length === '0')
                        throw new RuntimeException('Integer double zero while parsing string length.');
                    if(!ctype_digit($char))
                        throw new RuntimeException('Unexpected character while parsing string length.');

                    $length .= $char;
                }

                return $bencoded->read((int)$length);
        }
    }
}