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

use CPlus\MaxMind\Db\Reader\Decoder;
use CPlus\MaxMind\Db\Reader\InvalidDatabaseException;
use CPlus\MaxMind\Db\Reader\Metadata;
use CPlus\MaxMind\Db\Reader\Util;

/**
 * Instances of this class provide a reader for the MaxMind DB format. IP
 * addresses can be looked up using the <code>get</code> method.
 */
class Reader {

	private static $DATA_SECTION_SEPARATOR_SIZE  = 16;
	private static $METADATA_START_MARKER        = "\xAB\xCD\xEFMaxMind.com";
	private static $METADATA_START_MARKER_LENGTH = 14;
	private static $METADATA_MAX_SIZE            = 131072; // 128 * 1024 = 128KB

	private $decoder;
	private $fileHandle;
	private $fileSize;
	private $ipV4Start;
	private $metadata;

	/**
	 * Constructs a Reader for the MaxMind DB format. The file passed to it must
	 * be a valid MaxMind DB file such as a GeoIp2 database file.
	 *
	 * @param string $database
	 *                         the MaxMind DB file to use
	 *
	 * @throws \InvalidArgumentException                   for invalid database path or unknown arguments
	 * @throws CPlus\MaxMind\Db\Reader\InvalidDatabaseException
	 *                                                     if the database is invalid or there is an error reading
	 *                                                     from it
	 */
	public function __construct( $database ) {
		if ( func_num_args() !== 1 ) {
			throw new \InvalidArgumentException(
				'The constructor takes exactly one argument.'
			);
		}

		if ( ! is_readable( $database ) ) {
			throw new \InvalidArgumentException(
				"The file \"$database\" does not exist or is not readable."
			);
		}
		$this->fileHandle = @fopen( $database, 'rb' );
		if ( $this->fileHandle === false ) {
			throw new \InvalidArgumentException(
				"Error opening \"$database\"."
			);
		}
		$this->fileSize = @filesize( $database );
		if ( $this->fileSize === false ) {
			throw new \UnexpectedValueException(
				"Error determining the size of \"$database\"."
			);
		}

		$start               = $this->findMetadataStart( $database );
		$metadataDecoder     = new Decoder( $this->fileHandle, $start );
		list($metadataArray) = $metadataDecoder->decode( $start );
		$this->metadata      = new Metadata( $metadataArray );
		$this->decoder       = new Decoder(
			$this->fileHandle,
			$this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
		);
	}

	/**
	 * Looks up the <code>address</code> in the MaxMind DB.
	 *
	 * @param string $ipAddress
	 *                          the IP address to look up
	 *
	 * @throws \BadMethodCallException   if this method is called on a closed database
	 * @throws \InvalidArgumentException if something other than a single IP address is passed to the method
	 * @throws InvalidDatabaseException
	 *                                   if the database is invalid or there is an error reading
	 *                                   from it
	 *
	 * @return array the record for the IP address
	 */
	public function get( $ipAddress ) {
		if ( func_num_args() !== 1 ) {
			throw new \InvalidArgumentException(
				'Method takes exactly one argument.'
			);
		}

		if ( ! is_resource( $this->fileHandle ) ) {
			throw new \BadMethodCallException(
				'Attempt to read from a closed MaxMind DB.'
			);
		}

		if ( ! filter_var( $ipAddress, FILTER_VALIDATE_IP ) ) {
			throw new \InvalidArgumentException(
				"The value \"$ipAddress\" is not a valid IP address."
			);
		}

		if ( $this->metadata->ipVersion === 4 && strrpos( $ipAddress, ':' ) ) {
			throw new \InvalidArgumentException(
				"Error looking up $ipAddress. You attempted to look up an"
				. ' IPv6 address in an IPv4-only database.'
			);
		}
		$pointer = $this->findAddressInTree( $ipAddress );
		if ( $pointer === 0 ) {
			return null;
		}

		return $this->resolveDataPointer( $pointer );
	}

	private function findAddressInTree( $ipAddress ) {
		// XXX - could simplify. Done as a byte array to ease porting
		$rawAddress = array_merge( unpack( 'C*', inet_pton( $ipAddress ) ) );

		$bitCount = count( $rawAddress ) * 8;

		// The first node of the tree is always node 0, at the beginning of the
		// value
		$node = $this->startNode( $bitCount );

		for ( $i = 0; $i < $bitCount; $i++ ) {
			if ( $node >= $this->metadata->nodeCount ) {
				break;
			}
			$tempBit = 0xFF & $rawAddress[ $i >> 3 ];
			$bit     = 1 & ( $tempBit >> 7 - ( $i % 8 ) );

			$node = $this->readNode( $node, $bit );
		}
		if ( $node === $this->metadata->nodeCount ) {
			// Record is empty
			return 0;
		} elseif ( $node > $this->metadata->nodeCount ) {
			// Record is a data pointer
			return $node;
		}
		throw new InvalidDatabaseException( 'Something bad happened' );
	}

	private function startNode( $length ) {
		// Check if we are looking up an IPv4 address in an IPv6 tree. If this
		// is the case, we can skip over the first 96 nodes.
		if ( $this->metadata->ipVersion === 6 && $length === 32 ) {
			return $this->ipV4StartNode();
		}
		// The first node of the tree is always node 0, at the beginning of the
		// value
		return 0;
	}

	private function ipV4StartNode() {
		// This is a defensive check. There is no reason to call this when you
		// have an IPv4 tree.
		if ( $this->metadata->ipVersion === 4 ) {
			return 0;
		}

		if ( $this->ipV4Start ) {
			return $this->ipV4Start;
		}
		$node = 0;

		for ( $i = 0; $i < 96 && $node < $this->metadata->nodeCount; $i++ ) {
			$node = $this->readNode( $node, 0 );
		}
		$this->ipV4Start = $node;

		return $node;
	}

	private function readNode( $nodeNumber, $index ) {
		$baseOffset = $nodeNumber * $this->metadata->nodeByteSize;

		// XXX - probably could condense this.
		switch ( $this->metadata->recordSize ) {
			case 24:
				$bytes        = Util::read( $this->fileHandle, $baseOffset + $index * 3, 3 );
				list(, $node) = unpack( 'N', "\x00" . $bytes );

				return $node;
			case 28:
				$middleByte     = Util::read( $this->fileHandle, $baseOffset + 3, 1 );
				list(, $middle) = unpack( 'C', $middleByte );
				if ( $index === 0 ) {
					$middle = ( 0xF0 & $middle ) >> 4;
				} else {
					$middle = 0x0F & $middle;
				}
				$bytes        = Util::read( $this->fileHandle, $baseOffset + $index * 4, 3 );
				list(, $node) = unpack( 'N', chr( $middle ) . $bytes );

				return $node;
			case 32:
				$bytes        = Util::read( $this->fileHandle, $baseOffset + $index * 4, 4 );
				list(, $node) = unpack( 'N', $bytes );

				return $node;
			default:
				throw new InvalidDatabaseException(
					'Unknown record size: '
					. $this->metadata->recordSize
				);
		}
	}

	private function resolveDataPointer( $pointer ) {
		$resolved = $pointer - $this->metadata->nodeCount
			+ $this->metadata->searchTreeSize;
		if ( $resolved > $this->fileSize ) {
			throw new InvalidDatabaseException(
				"The MaxMind DB file's search tree is corrupt"
			);
		}

		list($data) = $this->decoder->decode( $resolved );

		return $data;
	}

	/*
	 * This is an extremely naive but reasonably readable implementation. There
	 * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
	 * an issue, but I suspect it won't be.
	 */
	private function findMetadataStart( $filename ) {
		$handle                           = $this->fileHandle;
		$fstat                            = fstat( $handle );
		$fileSize                         = $fstat['size'];
		$marker                           = self::$METADATA_START_MARKER;
		$markerLength                     = self::$METADATA_START_MARKER_LENGTH;
		$metadataMaxLengthExcludingMarker
			= min( self::$METADATA_MAX_SIZE, $fileSize ) - $markerLength;

		for ( $i = 0; $i <= $metadataMaxLengthExcludingMarker; $i++ ) {
			for ( $j = 0; $j < $markerLength; $j++ ) {
				fseek( $handle, $fileSize - $i - $j - 1 );
				$matchBit = fgetc( $handle );
				if ( $matchBit !== $marker[ $markerLength - $j - 1 ] ) {
					continue 2;
				}
			}

			return $fileSize - $i;
		}
		throw new InvalidDatabaseException(
			"Error opening database file ($filename). " .
			'Is this a valid MaxMind DB file?'
		);
	}

	/**
	 * @throws \InvalidArgumentException if arguments are passed to the method
	 * @throws \BadMethodCallException   if the database has been closed
	 *
	 * @return Metadata object for the database
	 */
	public function metadata() {
		if ( func_num_args() ) {
			throw new \InvalidArgumentException(
				'Method takes no arguments.'
			);
		}

		// Not technically required, but this makes it consistent with
		// C extension and it allows us to change our implementation later.
		if ( ! is_resource( $this->fileHandle ) ) {
			throw new \BadMethodCallException(
				'Attempt to read from a closed MaxMind DB.'
			);
		}

		return $this->metadata;
	}

	/**
	 * Closes the MaxMind DB and returns resources to the system.
	 *
	 * @throws \Exception
	 *                    if an I/O error occurs
	 */
	public function close() {
		if ( ! is_resource( $this->fileHandle ) ) {
			throw new \BadMethodCallException(
				'Attempt to close a closed MaxMind DB.'
			);
		}
		fclose( $this->fileHandle );
	}
}
