<?php

namespace XF;

use XF\Util\Str;

use function array_key_exists, floatval, intval, is_array, is_scalar, is_string, strval;

class InputFilterer
{
	protected $stringCleaning = [
		// strip a bunch of control characters
		"\x00" => '', // null
		"\x01" => '', // start of heading
		"\x02" => '', // start of text
		"\x03" => '', // end of text
		"\x04" => '', // end of transmission
		"\x05" => '', // enquiry
		"\x06" => '', // ack
		"\x07" => '', // bell
		"\x08" => '', // backspace
		"\x0B" => '', // vertical tab
		"\x0C" => '', // form feed
		"\x0D" => '', // carriage returns, because jQuery does so in .val()
		"\x0E" => '', // shift out
		"\x0F" => '', // shift in
		"\x10" => '', // data link escape
		"\x11" => '', // device ctrl 1
		"\x12" => '', // device ctrl 2
		"\x13" => '', // device ctrl 3
		"\x14" => '', // device ctrl 4
		"\x15" => '', // negative ack
		"\x16" => '', // sync idle
		"\x17" => '', // end of transmission block
		"\x18" => '', // cancel
		"\x19" => '', // end of medium
		"\x1A" => '', // substitute
		"\x1B" => '', // escape
		"\x1C" => '', // file sep
		"\x1D" => '', // group sep
		"\x1E" => '', // record sep
		"\x1F" => '', // unit sep
		"\x7F" => '', // delete

		// more UTF-8 control characters
		"\xC2\x80" => '',
		"\xC2\x81" => '',
		"\xC2\x82" => '',
		"\xC2\x83" => '',
		"\xC2\x84" => '',
		"\xC2\x85" => '',
		"\xC2\x86" => '',
		"\xC2\x87" => '',
		"\xC2\x88" => '',
		"\xC2\x89" => '',
		"\xC2\x8A" => '',
		"\xC2\x8B" => '',
		"\xC2\x8C" => '',
		"\xC2\x8D" => '',
		"\xC2\x8E" => '',
		"\xC2\x8F" => '',
		"\xC2\x90" => '',
		"\xC2\x91" => '',
		"\xC2\x92" => '',
		"\xC2\x93" => '',
		"\xC2\x94" => '',
		"\xC2\x95" => '',
		"\xC2\x96" => '',
		"\xC2\x97" => '',
		"\xC2\x98" => '',
		"\xC2\x99" => '',
		"\xC2\x9A" => '',
		"\xC2\x9B" => '',
		"\xC2\x9C" => '',
		"\xC2\x9D" => '',
		"\xC2\x9E" => '',
		"\xC2\x9F" => '',

		"\xC2\xA0" => ' ', // nbsp
		"\xC2\xAD" => '', // soft hyphen
		"\xE2\x80\x8B" => '', // zero width space
		"\xEF\xBB\xBF" => '', // zero width nbsp
	];

	protected $fullUnicode = false;

	public function __construct($fullUnicode = false)
	{
		$this->fullUnicode = $fullUnicode;
	}

	public function filterArray(array $array, array $filters)
	{
		$output = [];

		foreach ($filters AS $key => $type)
		{
			$value = array_key_exists($key, $array) ? $array[$key] : null;

			if (is_array($type))
			{
				if (!is_array($value))
				{
					$value = [];
				}
				$output[$key] = $this->filterArray($value, $type);
			}
			else
			{
				$output[$key] = $this->filter($value, $type);
			}
		}

		return $output;
	}

	public function filter($value, $type, ?array $options = null)
	{
		if (!is_array($options))
		{
			$optionParts = explode(',', $type);
			$type = array_shift($optionParts);
			$options = [];

			foreach ($optionParts AS $part)
			{
				$option = explode(':', Str::trim($part), 2);
				if (!isset($option[1]))
				{
					$option[1] = true;
				}
				else
				{
					$option[1] = Str::trim($option[1]);
				}
				$options[Str::trim($option[0])] = $option[1];
			}
		}

		$type = Str::trim(strtolower($type));

		if ($type && $type[0] === '?')
		{
			$nullable = true;
			$type = substr($type, 1);
		}
		else
		{
			$nullable = false;
		}

		if (!$type)
		{
			throw new \LogicException("No filter type provided");
		}

		if ($nullable && $value === null)
		{
			return null;
		}

		return $this->cleanInternal($value, $type, $options);
	}

	// keep track of our input filtering types
	public const STR = 'str';
	public const STRING = 'string';
	public const NUM = 'num';
	public const UNUM = 'unum';
	public const INT = 'int';
	public const INTEGER = 'integer';
	public const UINT = 'uint';
	public const UNSIGNED = 'unsigned';
	public const POSINT = 'posint';
	public const POSITIVE_INTEGER = 'positive-integer';
	public const FLOAT = 'float';
	public const BOOL = 'bool';
	public const BOOLEAN = 'boolean';
	public const ARRAY = 'array';
	public const JSON_ARRAY = 'json-array';
	public const TIME_OFFSET = 'timeoffset';
	public const DATETIME = 'datetime';

	protected function cleanInternal($value, $type, array $options)
	{
		switch ($type)
		{
			case self::STR:
			case self::STRING:
				if (is_scalar($value))
				{
					$value = str_replace("\r\n", "\n", strval($value));
					if (!preg_match('/^./us', $value))
					{
						$value = '';
					}
				}
				else
				{
					$value = '';
				}

				if (empty($options['no-clean']))
				{
					$value = $this->cleanString($value, false);
				}

				if (empty($options['no-trim']))
				{
					$value = $this->unicode_trim($value);
				}
				break;

			case self::NUM:
				if (is_scalar($value))
				{
					$value = $this->normalizeDecimalSeparator($value);
					$value = floatval($value);
					if ($value === -INF)
					{
						$value = PHP_INT_MIN;
					}
					else if ($value === INF)
					{
						$value = PHP_INT_MAX;
					}

					$value = strval($value) + 0;
				}
				else
				{
					$value = 0;
				}
				break;

			case self::UNUM:
				if (is_scalar($value))
				{
					$value = $this->normalizeDecimalSeparator($value);
					$value = floatval($value);
					if ($value === -INF)
					{
						$value = PHP_INT_MIN;
					}
					else if ($value === INF)
					{
						$value = PHP_INT_MAX;
					}

					$value = strval($value) + 0;
					if ($value < 0)
					{
						$value = 0;
					}
				}
				else
				{
					$value = 0;
				}
				break;

			case self::INT:
			case self::INTEGER:
				if (is_scalar($value))
				{
					$value = intval($value);
				}
				else
				{
					$value = 0;
				}
				break;

			case self::UINT:
			case self::UNSIGNED:
				if (is_scalar($value))
				{
					$value = intval($value);
					if ($value < 0)
					{
						$value = 0;
					}
				}
				else
				{
					$value = 0;
				}
				break;

			case self::POSINT:
			case self::POSITIVE_INTEGER:
				if (is_scalar($value))
				{
					$value = intval($value);
					if ($value < 1)
					{
						$value = 1;
					}
				}
				else
				{
					$value = 1;
				}
				break;

			case self::FLOAT:
				if (is_scalar($value))
				{
					$value = $this->normalizeDecimalSeparator($value);
					$value = floatval($value);
					if ($value === -INF)
					{
						$value = PHP_INT_MIN;
					}
					else if ($value === INF)
					{
						$value = PHP_INT_MAX;
					}
				}
				else
				{
					$value = 0;
				}
				break;

			case self::BOOL:
			case self::BOOLEAN:
				$value = (bool) $value;
				break;

			case self::ARRAY:
				if (!is_array($value))
				{
					$value = [];
				}

				if (empty($options['no-clean']))
				{
					$value = $this->cleanArrayStrings($value);
				}
				break;

			case self::JSON_ARRAY:
				if (is_string($value))
				{
					$value = json_decode($value, true);
					if (!is_array($value))
					{
						$value = [];
					}
				}
				else if (!is_array($value))
				{
					$value = [];
				}

				if (empty($options['no-clean']))
				{
					$value = $this->cleanArrayStrings($value);
				}
				break;

			case self::TIME_OFFSET:
				if (is_array($value) && isset($value['amount']) && isset($value['unit']))
				{
					$amount = is_scalar($value['amount']) ? intval($value['amount']) : 0;
					$unit = is_scalar($value['unit']) ? Str::trim($value['unit']) : '';

					switch ($unit)
					{
						case 'seconds':
						case 'minutes':
						case 'hours':
						case 'days':
						case 'weeks':
						case 'months':
						case 'years':
							$valid = true;
							break;

						default:
							$valid = false;
					}

					if ($valid && $amount)
					{
						if ($amount > 0)
						{
							$amount = "+$amount";
						}
						$value = strtotime("$amount $unit", 0);
					}
					else
					{
						$value = 0;
					}
				}
				else
				{
					$value = 0;
				}
				break;

			case self::DATETIME:
				if (!empty($options['tz']))
				{
					// $options['tz'] accepts either a timezone string, or a DateTimeZone object
					$tz = $options['tz'] instanceof \DateTimeZone ? $options['tz'] : new \DateTimeZone($options['tz']);
				}
				else
				{
					$tz = \XF::language()->getTimeZone();
				}
				$useDtObject = !empty($options['obj']);

				if (is_scalar($value) && $value)
				{
					$value = Str::trim(strval($value));
					if (!$value || is_numeric($value))
					{
						$value = intval($value);
					}
					else
					{
						try
						{
							$dt = new \DateTime($value, $tz);
							if (!empty($options['end']))
							{
								$dt->setTime(23, 59, 59);
							}

							$value = $useDtObject ? $dt : intval($dt->format('U'));
						}
						catch (\Exception $e)
						{
							// probably a formatting issue, ignore
							$value = empty($options['obj']) ? 0 : null;
						}
					}
				}
				else
				{
					$value = empty($options['obj']) ? 0 : null;
				}

				if ($useDtObject)
				{
					if (!$value)
					{
						$value = null;
					}
					else if (!($value instanceof \DateTime))
					{
						$value = new \DateTime('@' . $value);
					}
				}
				break;

			default:
				if (preg_match('/^array-(.*)$/', $type, $match))
				{
					if (!is_array($value))
					{
						$value = [];
					}
					else
					{
						foreach ($value AS &$innerValue)
						{
							$innerValue = $this->filter($innerValue, $match[1], $options);
						}
					}
				}
				else
				{
					throw new \InvalidArgumentException("Unknown filter type $type");
				}
		}

		return $value;
	}

	public function normalizeDecimalSeparator($value)
	{
		$decimalSep = \XF::language()['decimal_point'];

		if (strpos($value, $decimalSep) !== false && $decimalSep !== '.')
		{
			$value = str_replace($decimalSep, '.', $value);
		}

		return $value;
	}

	public function cleanString($string, $trim = true)
	{
		if (!$this->fullUnicode)
		{
			// only cover the BMP as MySQL only supports that unless opting into utf8mb4
			$string = preg_replace('/[\xF0-\xF7].../', '', $string);
		}

		$string = strtr(strval($string), $this->stringCleaning);
		if ($trim)
		{
			$string = $this->unicode_trim($string);
		}

		return $string;
	}

	public function cleanArrayStrings(array $input, $trim = true)
	{
		foreach ($input AS &$v)
		{
			if (is_string($v))
			{
				$v = str_replace("\r\n", "\n", $v);
				if (!preg_match('/^./us', $v))
				{
					$v = '';
				}
				$v = $this->cleanString($v, $trim);
			}
			else if (is_array($v))
			{
				$v = $this->cleanArrayStrings($v, $trim);
			}
		}

		return $input;
	}

	public function getFullUnicode()
	{
		return $this->fullUnicode;
	}

	public function setFullUnicode($fullUnicode)
	{
		$this->fullUnicode = $fullUnicode;
	}

	public function getNewArrayFilterer(array $input = [])
	{
		return new InputFiltererArray($this, $input);
	}

	protected function unicode_trim($string): string
	{
		// trim leading and trailing invisible unicode control characters
		$string = preg_replace('/^[\p{Cc}\s]+|[\p{Cc}\s]+$/u', '', $string);

		return Str::trim($string);
	}
}
