<?php

namespace XF\Util;

use function chr, count, is_array, is_string, ord, strlen;

class Ip
{
	/**
	 * @return string|false
	 */
	public static function stringToBinary(string $ip, bool $throw = true)
	{
		$output = @inet_pton($ip);
		if ($output === false && $throw)
		{
			throw new \InvalidArgumentException('Invalid string IP: ' . $ip);
		}

		return $output;
	}

	/**
	 * @return string|false
	 */
	public static function binaryToString(
		string $ip,
		bool $shorten = true,
		bool $throw = true
	)
	{
		$output = @inet_ntop($ip);
		if ($output === false && $throw)
		{
			throw new \InvalidArgumentException('Invalid binary IP: ' . $ip);
		}

		if (strlen($ip) === 16 && !$shorten)
		{
			$output = implode(':', array_map(
				function ($block)
				{
					return str_pad($block, 4, '0', STR_PAD_LEFT);
				},
				explode(':', $output)
			));
		}

		return $output;
	}

	/**
	 * Converts a string based IP (v4 or v6) to a 4 or 16 byte string.
	 * This tries to identify not only 192.168.1.1 and 2001::1:2:3:4 style IPs,
	 * but integer encoded IPv4 and already binary encoded IPs. IPv4
	 * embedded in IPv6 via things like ::ffff:192.168.1.1 is also detected.
	 *
	 * @param string|int $ip
	 *
	 * @return bool|string False on failure, binary data otherwise
	 *
	 * @deprecated Use \XF\Util\Ip::stringToBinary instead
	 */
	public static function convertIpStringToBinary($ip)
	{
		$originalIp = $ip;

		if (strlen($ip) == 4)
		{
			// already encoded IPv4
			return $ip;
		}

		if (strlen($ip) == 16 && preg_match('/[^0-9a-f.:]/i', $ip))
		{
			// already encoded IPv6
			return $ip;
		}

		$ip = trim($ip, " \t");

		if (strpos($ip, ':') !== false)
		{
			// IPv6
			if (preg_match('#:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$#i', $ip, $match))
			{
				// embedded IPv4, just treat as IPv4
				$long = ip2long($match[1]);
				if (!$long)
				{
					return false;
				}

				return static::convertHexToBin(
					str_pad(dechex($long), 8, '0', STR_PAD_LEFT)
				);
			}

			if (strpos($ip, '::') !== false)
			{
				if (substr_count($ip, '::') > 1)
				{
					// ambiguous
					return false;
				}

				$delims = substr_count($ip, ':');
				if ($delims > 7)
				{
					return false;
				}

				$ip = str_replace('::', str_repeat(':0', 8 - $delims) . ':', $ip);
				if ($ip[0] == ':')
				{
					$ip = '0' . $ip;
				}
			}

			$ip = strtolower($ip);

			$parts = explode(':', $ip);
			if (count($parts) != 8)
			{
				return false;
			}

			foreach ($parts AS &$part)
			{
				$len = strlen($part);
				if ($len > 4 || preg_match('/[^0-9a-f]/', $part))
				{
					return false;
				}

				if ($len < 4)
				{
					$part = str_repeat('0', 4 - $len) . $part;
				}
			}

			$hex = implode('', $parts);
			if (strlen($hex) != 32)
			{
				return false;
			}

			if (preg_match('/^00000000000000000000ffff([0-9a-f]{8})$/', $hex, $match))
			{
				// ::ffff:IPv4 address that was written in pure IPv6 form, treat as an IPv4 address
				return static::convertHexToBin($match[1]);
			}

			return static::convertHexToBin($hex);
		}

		if (strpos($ip, '.'))
		{
			// IPv4
			if (!preg_match('#(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})#', $ip, $match))
			{
				return false;
			}

			$long = ip2long($match[1]);
			if ($long === false)
			{
				return false;
			}

			return static::convertHexToBin(
				str_pad(dechex($long), 8, '0', STR_PAD_LEFT)
			);
		}

		if (strlen($ip) == 4 || strlen($ip) == 16)
		{
			// already binary encoded
			return $ip;
		}

		if (is_numeric($originalIp) && $originalIp < 2 ** 32)
		{
			// IPv4 as integer
			return static::convertHexToBin(
				str_pad(dechex($originalIp), 8, '0', STR_PAD_LEFT)
			);
		}

		return false;
	}

	/**
	 * Converts a hex string to binary
	 *
	 * @param string $hex
	 *
	 * @return string
	 */
	public static function convertHexToBin($hex)
	{
		if (function_exists('hex2bin'))
		{
			return hex2bin($hex);
		}

		$len = strlen($hex);

		if ($len % 2)
		{
			trigger_error('Hexadecimal input string must have an even length', E_USER_WARNING);
		}

		if (strspn($hex, '0123456789abcdefABCDEF') != $len)
		{
			trigger_error('Input string must be hexadecimal string', E_USER_WARNING);
		}

		return pack('H*', $hex);
	}

	/**
	 * Converts a binary string containing IPv4 or v6 data to a printable/human
	 * readable version. If shortening is enabled, IPv6 data will be collapsed
	 * as much as possible.
	 *
	 * @param string $ip Binary IP data
	 * @param bool $shorten
	 *
	 * @return bool|string
	 *
	 * @deprecated Use \XF\Util\Ip::binaryToString instead
	 */
	public static function convertIpBinaryToString($ip, $shorten = true)
	{
		if (strlen($ip) == 4)
		{
			// IPv4
			$parts = [];
			foreach (str_split($ip) AS $char)
			{
				$parts[] = ord($char);
			}

			return implode('.', $parts);
		}

		if (strlen($ip) == 16)
		{
			// IPv6
			$parts = [];
			$chunks = str_split($ip);
			for ($i = 0; $i < 16; $i += 2)
			{
				$char1 = $chunks[$i];
				$char2 = $chunks[$i + 1];

				$part = sprintf('%02x%02x', ord($char1), ord($char2));
				if ($shorten)
				{
					// reduce this to the shortest length possible, but keep 1 zero if needed
					$part = ltrim($part, '0');
					if (!strlen($part))
					{
						$part = '0';
					}
				}
				$parts[] = $part;
			}

			$output = implode(':', $parts);
			if ($shorten)
			{
				$output = preg_replace_callback(
					'/((^0|:0){2,})(.*)$/',
					function ($matches)
					{
						return ':' . (strlen($matches[3]) ? $matches[3] : ':');
					},
					$output
				);

				if ($output == ':')
				{
					// correct way of writing an IPv6 address of all zeroes
					$output = '::';
				}

				if (preg_match('/^::ffff:([0-9a-f]{2})([0-9a-f]{2}):([0-9a-f]{2})([0-9a-f]{2})$/i', $output, $match))
				{
					// IPv4-mapped IPv6
					$output = '::ffff:' . hexdec($match[1]) . '.' . hexdec($match[2]) . '.'
						. hexdec($match[3]) . '.' . hexdec($match[4]);
				}
			}

			return strtolower($output);
		}

		if (preg_match('/^[0-9]+$/', $ip))
		{
			return long2ip($ip + 0);
		}

		return false;
	}

	/**
	 * Gets the range of binary-encoded IPs that match the given
	 * CIDR block size. Supports IPv4 and v6. Ranges can be checked
	 * via >= lower and <= upper.
	 *
	 * @param string $ip Binary IP
	 * @param integer $cidr CIDR range
	 *
	 * @return array|string If no CIDR is specified or if the CIDR specifies only one address, a string with that address.
	 * 		Otherwise, an array with a lower and upper bound.
	 */
	public static function getIpCidrMatchRange($ip, $cidr)
	{
		if (!$cidr)
		{
			return $ip;
		}

		$bytes = strlen($ip);
		$bits = $bytes * 8;
		if ($cidr >= $bits)
		{
			return $ip; // exact match
		}

		$prefixBytes = (int) floor($cidr / 8);
		$remainingBits = ($cidr - $prefixBytes * 8);

		$prefix = substr($ip, 0, $prefixBytes);
		if ($remainingBits)
		{
			$partialByteOrd = ord($ip[$prefixBytes]); // first character after full prefix bytes
			$mask = (1 << 8 - $remainingBits) - 1;

			$upperBound = chr($partialByteOrd | $mask);
			$lowerBound = chr($partialByteOrd & ~$mask);
			$boundLength = 1;
		}
		else
		{
			$upperBound = '';
			$lowerBound = '';
			$boundLength = 0;
		}

		$suffixBytes = $bytes - $prefixBytes - $boundLength;
		if ($suffixBytes)
		{
			$lowerSuffix = str_repeat(chr(0), $suffixBytes);
			$upperSuffix = str_repeat(chr(255), $suffixBytes);
		}
		else
		{
			$lowerSuffix = '';
			$upperSuffix = '';
		}

		return [$prefix . $lowerBound . $lowerSuffix, $prefix . $upperBound . $upperSuffix];
	}

	/**
	 * Determines if a particular IP matches a IP CIDR range.
	 * Both IPs must be binary encoded
	 *
	 * @param string $testIp Binary encoded IP to test if within range
	 * @param string $rangeIp Binary encoded IP to make the range from
	 * @param integer $cidr CIDR block size
	 *
	 * @return bool
	 */
	public static function ipMatchesCidrRange($testIp, $rangeIp, $cidr)
	{
		$range = static::getIpCidrMatchRange($rangeIp, $cidr);
		if (is_string($range))
		{
			return ($testIp === $range);
		}
		else
		{
			return static::ipMatchesRange($testIp, $range[0], $range[1]);
		}
	}

	/**
	 * Simplifies checking if an IP is within a range.
	 * All IPs and ranges must be binary encoded.
	 *
	 * @param string $testIp
	 * @param string $lowerBound
	 * @param string $upperBound
	 *
	 * @return bool
	 */
	public static function ipMatchesRange(
		string $testIp,
		string $lowerBound,
		string $upperBound
	): bool
	{
		return (
			strlen($testIp) === strlen($lowerBound) &&
			strcmp($testIp, $lowerBound) >= 0 &&
			strcmp($testIp, $upperBound) <= 0
		);
	}

	/**
	 * @param array|string $checkIps List of IPs to check for a match
	 * @param array $firstByteRangeList List of binary IP ranges [start, end] keyed by [binary first byte]
	 *
	 * @return array|null
	 */
	public static function checkIpsAgainstBinaryRangeList($checkIps, array $firstByteRangeList)
	{
		if (!is_array($checkIps))
		{
			$checkIps = [$checkIps];
		}

		foreach ($checkIps AS $ip)
		{
			$binary = static::stringToBinary($ip, false);
			if (!$binary)
			{
				continue;
			}

			$firstByte = $binary[0];

			if (empty($firstByteRangeList[$firstByte]))
			{
				continue;
			}

			foreach ($firstByteRangeList[$firstByte] AS $range)
			{
				if (static::ipMatchesRange($binary, $range[0], $range[1]))
				{
					return $range;
				}
			}
		}

		return null;
	}

	/**
	 * Parses a human readable IP range string into a machine processable version.
	 * IPv4 can be specified with CIDR or 192.168.* style ranges. IPv6 supports CIDR only.
	 *
	 * @param string $ip Human readable IPv4 or v6 IP
	 *
	 * @return array|bool False on failure, Otherwise array with following keys:
	 * 		- printable: human readable version of the IP range. May be adjusted to standardize display slightly
	 * 		- binary: binary version of provided IP. IPv4 missing octets are considered to be 0.
	 * 		- cidr: the final CIDR range found/used. If a IPv4 partial is provided, this will be determined from number of missing octets. 0 for exact.
	 * 		- isRange: true if the provided IP actually spans a range
	 *		- startRange: binary version of lower bound IP
	 *		- endRange: binary version of upper bound IP
	 */
	public static function parseIpRangeString($ip)
	{
		$ip = trim($ip);
		$niceIp = $ip;

		if (preg_match('#/(\d+)$#', $ip, $match))
		{
			$ip = substr($ip, 0, -strlen($match[0]));
			$cidr = $match[1];
			if ($cidr && $cidr < 8)
			{
				$cidr = 8;
				$niceIp = $ip . "/$cidr";
			}
		}
		else
		{
			$cidr = 0;
		}

		if (strpos($ip, ':') !== false)
		{
			// IPv6 -- no partials, only CIDR
			$binary = static::stringToBinary($ip, false);
			if ($binary === false)
			{
				return false;
			}
		}
		else
		{
			$ip = preg_replace('/\.+$/', '', $ip);
			if (!preg_match('/^\d+(\.\d+){0,2}(\.\d+|\.\*)?$/', $ip))
			{
				return false;
			}

			if (substr($ip, -2) == '.*')
			{
				$ip = substr($ip, 0, -2);
			}

			$ipParts = explode('.', $ip);
			foreach ($ipParts AS $part)
			{
				if ($part < 0 || $part > 255)
				{
					return false;
				}
			}

			$localCidr = 32;
			while (count($ipParts) < 4)
			{
				$ipParts[] = 0;
				$localCidr -= 8;
			}

			if (!$cidr && $localCidr != 32)
			{
				$cidr = $localCidr;
				$niceIp = $ip . '.*';
			}

			$binary = static::stringToBinary(implode('.', $ipParts), false);
			if (!$binary)
			{
				return false;
			}
		}

		$range = static::getIpCidrMatchRange($binary, $cidr);

		return [
			'printable' => $niceIp,
			'binary' => $binary,
			'cidr' => $cidr,
			'isRange' => is_array($range),
			'startRange' => is_string($range) ? $range : $range[0],
			'endRange' => is_string($range) ? $range : $range[1],
		];
	}

	protected static $lookupCache = [];

	/**
	 * Resolves the host name of an IP address
	 *
	 * @param string $ip
	 *
	 * @return string
	 */
	public static function getHost($ip)
	{
		if (isset(static::$lookupCache[$ip]))
		{
			return static::$lookupCache[$ip];
		}

		$decompressed = static::binaryToString($ip, false, false);
		if ($decompressed !== false)
		{
			$ip = $decompressed;
		}

		if (strpos($ip, ':') !== false)
		{
			// we need to uncompress the hex address to split this up
			$binary = static::stringToBinary($ip, false);
			if (!$binary)
			{
				return '';
			}
			$checkIp = static::binaryToString($binary, false, false);
			if (!$checkIp)
			{
				return false;
			}

			$checkIp = str_replace(':', '', $checkIp);
			$parts = str_split($checkIp);
			$dnsRecord = implode('.', array_reverse($parts)) . '.ip6.arpa';
		}
		else
		{
			$parts = explode('.', $ip);
			if (count($parts) != 4)
			{
				return '';
			}

			$dnsRecord = implode('.', array_reverse($parts)) . '.in-addr.arpa';
		}

		$lookup = false;

		try
		{
			if (function_exists('dns_get_record'))
			{
				$host = @dns_get_record($dnsRecord, DNS_PTR);
				if (isset($host[0]['target']))
				{
					$lookup = $host[0]['target'];
				}
			}
			else
			{
				$lookup = gethostbyaddr($ip);
			}
		}
		catch (\Exception $e)
		{
			// bad lookup
		}

		if (!$lookup)
		{
			$lookup = $ip;
		}

		static::$lookupCache[$ip] = $lookup;
		return $lookup;
	}
}
