<?php
// phpcs:ignoreFile -- this is a third-party lib
namespace CP\MaxMind\Db\Reader;

class Decoder {

	private $fileStream;
	private $pointerBase;
	// This is only used for unit testing
	private $pointerTestHack;
	private $switchByteOrder;

	private $types = array(
		0  => 'extended',
		1  => 'pointer',
		2  => 'utf8_string',
		3  => 'double',
		4  => 'bytes',
		5  => 'uint16',
		6  => 'uint32',
		7  => 'map',
		8  => 'int32',
		9  => 'uint64',
		10 => 'uint128',
		11 => 'array',
		12 => 'container',
		13 => 'end_marker',
		14 => 'boolean',
		15 => 'float',
	);

	public function __construct(
		$fileStream,
		$pointerBase = 0,
		$pointerTestHack = false
	) {
		$this->fileStream      = $fileStream;
		$this->pointerBase     = $pointerBase;
		$this->pointerTestHack = $pointerTestHack;

		$this->switchByteOrder = $this->isPlatformLittleEndian();
	}

	public function decode( $offset ) {
		list(, $ctrlByte) = unpack(
			'C',
			Util::read( $this->fileStream, $offset, 1 )
		);
		$offset++;

		$type = $this->types[ $ctrlByte >> 5 ];

		// Pointers are a special case, we don't read the next $size bytes, we
		// use the size to determine the length of the pointer and then follow
		// it.
		if ( $type === 'pointer' ) {
			list($pointer, $offset) = $this->decodePointer( $ctrlByte, $offset );

			// for unit testing
			if ( $this->pointerTestHack ) {
				return array( $pointer );
			}

			list($result) = $this->decode( $pointer );

			return array( $result, $offset );
		}

		if ( $type === 'extended' ) {
			list(, $nextByte) = unpack(
				'C',
				Util::read( $this->fileStream, $offset, 1 )
			);

			$typeNum = $nextByte + 7;

			if ( $typeNum < 8 ) {
				throw new InvalidDatabaseException(
					'Something went horribly wrong in the decoder. An extended type '
					. 'resolved to a type number < 8 ('
					. $this->types[ $typeNum ]
					. ')'
				);
			}

			$type = $this->types[ $typeNum ];
			$offset++;
		}

		list($size, $offset) = $this->sizeFromCtrlByte( $ctrlByte, $offset );

		return $this->decodeByType( $type, $offset, $size );
	}

	private function decodeByType( $type, $offset, $size ) {
		switch ( $type ) {
			case 'map':
				return $this->decodeMap( $size, $offset );
			case 'array':
				return $this->decodeArray( $size, $offset );
			case 'boolean':
				return array( $this->decodeBoolean( $size ), $offset );
		}

		$newOffset = $offset + $size;
		$bytes     = Util::read( $this->fileStream, $offset, $size );
		switch ( $type ) {
			case 'utf8_string':
				return array( $this->decodeString( $bytes ), $newOffset );
			case 'double':
				$this->verifySize( 8, $size );

				return array( $this->decodeDouble( $bytes ), $newOffset );
			case 'float':
				$this->verifySize( 4, $size );

				return array( $this->decodeFloat( $bytes ), $newOffset );
			case 'bytes':
				return array( $bytes, $newOffset );
			case 'uint16':
			case 'uint32':
				return array( $this->decodeUint( $bytes ), $newOffset );
			case 'int32':
				return array( $this->decodeInt32( $bytes ), $newOffset );
			case 'uint64':
			case 'uint128':
				return array( $this->decodeBigUint( $bytes, $size ), $newOffset );
			default:
				throw new InvalidDatabaseException(
					'Unknown or unexpected type: ' . $type
				);
		}
	}

	private function verifySize( $expected, $actual ) {
		if ( $expected !== $actual ) {
			throw new InvalidDatabaseException(
				"The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
			);
		}
	}

	private function decodeArray( $size, $offset ) {
		$array = array();

		for ( $i = 0; $i < $size; $i++ ) {
			list($value, $offset) = $this->decode( $offset );
			array_push( $array, $value );
		}

		return array( $array, $offset );
	}

	private function decodeBoolean( $size ) {
		return $size === 0 ? false : true;
	}

	private function decodeDouble( $bits ) {
		// XXX - Assumes IEEE 754 double on platform
		list(, $double) = unpack( 'd', $this->maybeSwitchByteOrder( $bits ) );

		return $double;
	}

	private function decodeFloat( $bits ) {
		// XXX - Assumes IEEE 754 floats on platform
		list(, $float) = unpack( 'f', $this->maybeSwitchByteOrder( $bits ) );

		return $float;
	}

	private function decodeInt32( $bytes ) {
		$bytes       = $this->zeroPadLeft( $bytes, 4 );
		list(, $int) = unpack( 'l', $this->maybeSwitchByteOrder( $bytes ) );

		return $int;
	}

	private function decodeMap( $size, $offset ) {
		$map = array();

		for ( $i = 0; $i < $size; $i++ ) {
			list($key, $offset)   = $this->decode( $offset );
			list($value, $offset) = $this->decode( $offset );
			$map[ $key ]          = $value;
		}

		return array( $map, $offset );
	}

	private $pointerValueOffset = array(
		1 => 0,
		2 => 2048,
		3 => 526336,
		4 => 0,
	);

	private function decodePointer( $ctrlByte, $offset ) {
		$pointerSize = ( ( $ctrlByte >> 3 ) & 0x3 ) + 1;

		$buffer = Util::read( $this->fileStream, $offset, $pointerSize );
		$offset = $offset + $pointerSize;

		$packed = $pointerSize === 4
			? $buffer
			: ( pack( 'C', $ctrlByte & 0x7 ) ) . $buffer;

		$unpacked = $this->decodeUint( $packed );
		$pointer  = $unpacked + $this->pointerBase
			+ $this->pointerValueOffset[ $pointerSize ];

		return array( $pointer, $offset );
	}

	private function decodeUint( $bytes ) {
		list(, $int) = unpack( 'N', $this->zeroPadLeft( $bytes, 4 ) );

		return $int;
	}

	private function decodeBigUint( $bytes, $byteLength ) {
		$maxUintBytes = log( PHP_INT_MAX, 2 ) / 8;

		if ( $byteLength === 0 ) {
			return 0;
		}

		$numberOfLongs = ceil( $byteLength / 4 );
		$paddedLength  = $numberOfLongs * 4;
		$paddedBytes   = $this->zeroPadLeft( $bytes, $paddedLength );
		$unpacked      = array_merge( unpack( "N$numberOfLongs", $paddedBytes ) );

		$integer = 0;

		// 2^32
		$twoTo32 = '4294967296';

		foreach ( $unpacked as $part ) {
			// We only use gmp or bcmath if the final value is too big
			if ( $byteLength <= $maxUintBytes ) {
				$integer = ( $integer << 32 ) + $part;
			} elseif ( extension_loaded( 'gmp' ) ) {
				$integer = gmp_strval( gmp_add( gmp_mul( $integer, $twoTo32 ), $part ) );
			} elseif ( extension_loaded( 'bcmath' ) ) {
				$integer = bcadd( bcmul( $integer, $twoTo32 ), $part );
			} else {
				throw new \RuntimeException(
					'The gmp or bcmath extension must be installed to read this database.'
				);
			}
		}

		return $integer;
	}

	private function decodeString( $bytes ) {
		// XXX - NOOP. As far as I know, the end user has to explicitly set the
		// encoding in PHP. Strings are just bytes.
		return $bytes;
	}

	private function sizeFromCtrlByte( $ctrlByte, $offset ) {
		$size        = $ctrlByte & 0x1f;
		$bytesToRead = $size < 29 ? 0 : $size - 28;
		$bytes       = Util::read( $this->fileStream, $offset, $bytesToRead );
		$decoded     = $this->decodeUint( $bytes );

		if ( $size === 29 ) {
			$size = 29 + $decoded;
		} elseif ( $size === 30 ) {
			$size = 285 + $decoded;
		} elseif ( $size > 30 ) {
			$size = ( $decoded & ( 0x0FFFFFFF >> ( 32 - ( 8 * $bytesToRead ) ) ) )
				+ 65821;
		}

		return array( $size, $offset + $bytesToRead );
	}

	private function zeroPadLeft( $content, $desiredLength ) {
		return str_pad( $content, $desiredLength, "\x00", STR_PAD_LEFT );
	}

	private function maybeSwitchByteOrder( $bytes ) {
		return $this->switchByteOrder ? strrev( $bytes ) : $bytes;
	}

	private function isPlatformLittleEndian() {
		$testint = 0x00FF;
		$packed  = pack( 'S', $testint );

		return $testint === current( unpack( 'v', $packed ) );
	}
}
