<?php
/**
 * IP2Location database reader for Security Pack.
 *
 * Reads IP2Location BIN format databases.
 *
 * @package ArkHost_Security_Pack
 */

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * IP2Location BIN database reader.
 *
 * Based on IP2Location PHP Module but simplified for our needs.
 */
class ARKSP_IP2Location {

	/**
	 * Database file handle.
	 *
	 * @var resource|null
	 */
	private $handle = null;

	/**
	 * Database type.
	 *
	 * @var int
	 */
	private $db_type = 0;

	/**
	 * Database column count.
	 *
	 * @var int
	 */
	private $db_column = 0;

	/**
	 * Database year.
	 *
	 * @var int
	 */
	private $db_year = 0;

	/**
	 * Database month.
	 *
	 * @var int
	 */
	private $db_month = 0;

	/**
	 * Database day.
	 *
	 * @var int
	 */
	private $db_day = 0;

	/**
	 * IPv4 database count.
	 *
	 * @var int
	 */
	private $ipv4_count = 0;

	/**
	 * IPv4 database address.
	 *
	 * @var int
	 */
	private $ipv4_addr = 0;

	/**
	 * IPv6 database count.
	 *
	 * @var int
	 */
	private $ipv6_count = 0;

	/**
	 * IPv6 database address.
	 *
	 * @var int
	 */
	private $ipv6_addr = 0;

	/**
	 * IPv4 index base address.
	 *
	 * @var int
	 */
	private $ipv4_index_addr = 0;

	/**
	 * IPv6 index base address.
	 *
	 * @var int
	 */
	private $ipv6_index_addr = 0;

	/**
	 * Constructor.
	 *
	 * @param string $db_path Path to database file.
	 */
	public function __construct( $db_path ) {
		if ( ! file_exists( $db_path ) ) {
			return;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$this->handle = fopen( $db_path, 'rb' );

		if ( ! $this->handle ) {
			return;
		}

		// Read database header.
		$this->db_type   = $this->read_byte( 1 );
		$this->db_column = $this->read_byte( 2 );
		$this->db_year   = $this->read_byte( 3 );
		$this->db_month  = $this->read_byte( 4 );
		$this->db_day    = $this->read_byte( 5 );

		$this->ipv4_count = $this->read_word( 6 );
		$this->ipv4_addr  = $this->read_word( 10 );
		$this->ipv6_count = $this->read_word( 14 );
		$this->ipv6_addr  = $this->read_word( 18 );

		$this->ipv4_index_addr = $this->read_word( 22 );
		$this->ipv6_index_addr = $this->read_word( 26 );
	}

	/**
	 * Destructor.
	 */
	public function __destruct() {
		if ( $this->handle ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $this->handle );
		}
	}

	/**
	 * Lookup an IP address.
	 *
	 * @param string $ip IP address.
	 * @return array|null
	 */
	public function lookup( $ip ) {
		if ( ! $this->handle ) {
			return null;
		}

		// Determine IP version.
		$ip_version = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? 6 : 4;

		if ( 4 === $ip_version ) {
			return $this->lookup_ipv4( $ip );
		}

		return $this->lookup_ipv6( $ip );
	}

	/**
	 * Lookup IPv4 address.
	 *
	 * @param string $ip IPv4 address.
	 * @return array|null
	 */
	private function lookup_ipv4( $ip ) {
		if ( $this->ipv4_count <= 0 ) {
			return null;
		}

		$ip_num = sprintf( '%u', ip2long( $ip ) );

		// Use index for faster lookup.
		$index = ( $ip_num >> 16 );

		$low  = 0;
		$high = $this->ipv4_count;

		if ( $this->ipv4_index_addr > 0 ) {
			$low  = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) );
			$high = $this->read_word( $this->ipv4_index_addr + ( $index * 8 ) + 4 );
		}

		// Binary search.
		while ( $low <= $high ) {
			$mid     = (int) ( ( $low + $high ) / 2 );
			$ip_from = $this->read_word( $this->ipv4_addr + $mid * $this->db_column * 4 );
			$ip_to   = $this->read_word( $this->ipv4_addr + ( $mid + 1 ) * $this->db_column * 4 );

			if ( $ip_num >= $ip_from && $ip_num < $ip_to ) {
				return $this->read_record( $this->ipv4_addr + $mid * $this->db_column * 4, 4 );
			}

			if ( $ip_num < $ip_from ) {
				$high = $mid - 1;
			} else {
				$low = $mid + 1;
			}
		}

		return null;
	}

	/**
	 * Lookup IPv6 address.
	 *
	 * @param string $ip IPv6 address.
	 * @return array|null
	 */
	private function lookup_ipv6( $ip ) {
		if ( $this->ipv6_count <= 0 ) {
			return null;
		}

		$ip_num = $this->ipv6_to_number( $ip );

		$low  = 0;
		$high = $this->ipv6_count;

		if ( $this->ipv6_index_addr > 0 ) {
			$index_value = bcdiv( $ip_num, bcpow( '2', '112' ) );
			$index       = bcmod( $index_value, '65536' );

			$low  = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 );
			$high = $this->read_word( $this->ipv6_index_addr + (int) $index * 8 + 4 );
		}

		// Binary search.
		while ( $low <= $high ) {
			$mid     = (int) ( ( $low + $high ) / 2 );
			$ip_from = $this->read_ipv6( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) );
			$ip_to   = $this->read_ipv6( $this->ipv6_addr + ( $mid + 1 ) * ( $this->db_column * 4 + 12 ) );

			if ( bccomp( $ip_num, $ip_from ) >= 0 && bccomp( $ip_num, $ip_to ) < 0 ) {
				return $this->read_record( $this->ipv6_addr + $mid * ( $this->db_column * 4 + 12 ) + 12, 6 );
			}

			if ( bccomp( $ip_num, $ip_from ) < 0 ) {
				$high = $mid - 1;
			} else {
				$low = $mid + 1;
			}
		}

		return null;
	}

	/**
	 * Read record from database.
	 *
	 * @param int $row_addr  Row address.
	 * @param int $ip_version IP version.
	 * @return array
	 */
	private function read_record( $row_addr, $ip_version ) {
		$record = array(
			'country_code' => '-',
			'country_name' => '-',
		);

		// Country is always at column 0 for DB1-DB24.
		$country_pos = $row_addr + 4;

		if ( 6 === $ip_version ) {
			$country_pos = $row_addr;
		}

		$country_offset = $this->read_word( $country_pos );

		if ( $country_offset > 0 ) {
			$record['country_code'] = $this->read_string( $country_offset );
			$record['country_name'] = $this->read_string( $country_offset + 3 );
		}

		return $record;
	}

	/**
	 * Read a byte from database.
	 *
	 * Note: Direct file operations are required for reading the binary IP2Location database format.
	 * WP_Filesystem is not suitable for binary file parsing.
	 *
	 * @param int $pos Position.
	 * @return int
	 */
	private function read_byte( $pos ) {
		fseek( $this->handle, $pos - 1, SEEK_SET );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
		$data = fread( $this->handle, 1 );
		return unpack( 'C', $data )[1];
	}

	/**
	 * Read a 32-bit word from database.
	 *
	 * @param int $pos Position.
	 * @return int
	 */
	private function read_word( $pos ) {
		fseek( $this->handle, $pos - 1, SEEK_SET );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
		$data = fread( $this->handle, 4 );
		return unpack( 'V', $data )[1];
	}

	/**
	 * Read a string from database.
	 *
	 * @param int $pos Position.
	 * @return string
	 */
	private function read_string( $pos ) {
		fseek( $this->handle, $pos, SEEK_SET );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
		$length = unpack( 'C', fread( $this->handle, 1 ) )[1];
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
		return fread( $this->handle, $length );
	}

	/**
	 * Read IPv6 address from database.
	 *
	 * @param int $pos Position.
	 * @return string
	 */
	private function read_ipv6( $pos ) {
		fseek( $this->handle, $pos - 1, SEEK_SET );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Binary database parsing requires direct file access.
		$data = fread( $this->handle, 16 );
		$int  = unpack( 'V4', $data );

		$result = bcadd( bcadd( bcmul( $int[4], bcpow( '4294967296', '3' ) ), bcmul( $int[3], bcpow( '4294967296', '2' ) ) ), bcadd( bcmul( $int[2], '4294967296' ), $int[1] ) );

		return $result;
	}

	/**
	 * Convert IPv6 to number.
	 *
	 * @param string $ip IPv6 address.
	 * @return string
	 */
	private function ipv6_to_number( $ip ) {
		$bin = inet_pton( $ip );
		$hex = bin2hex( $bin );

		$result = '0';
		for ( $i = 0; $i < strlen( $hex ); $i++ ) {
			$result = bcadd( bcmul( $result, '16' ), hexdec( $hex[ $i ] ) );
		}

		return $result;
	}

	/**
	 * Get database type string.
	 *
	 * @return string
	 */
	public function get_database_type() {
		if ( ! $this->handle ) {
			return '';
		}

		$types = array(
			1  => 'DB1 (Country)',
			2  => 'DB2 (Country + ISP)',
			3  => 'DB3 (Country + Region + City)',
			4  => 'DB4 (Country + Region + City + ISP)',
			5  => 'DB5 (Country + Region + City + Lat/Long)',
			11 => 'DB11 (Full)',
			24 => 'DB24 (Full)',
		);

		$date = sprintf( '%04d-%02d-%02d', 2000 + $this->db_year, $this->db_month, $this->db_day );

		$type = isset( $types[ $this->db_type ] ) ? $types[ $this->db_type ] : 'DB' . $this->db_type;

		return $type . ' (' . $date . ')';
	}
}
