<?php

/**
 * This file is part of the CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CodeIgniter\Validation;

use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\Validation\Exceptions\ValidationException;
use CodeIgniter\View\RendererInterface;
use Config\Validation as ValidationConfig;
use InvalidArgumentException;

/**
 * Validator
 */
class Validation implements ValidationInterface
{
	/**
	 * Files to load with validation functions.
	 *
	 * @var array
	 */
	protected $ruleSetFiles;

	/**
	 * The loaded instances of our validation files.
	 *
	 * @var array
	 */
	protected $ruleSetInstances = [];

	/**
	 * Stores the actual rules that should
	 * be ran against $data.
	 *
	 * @var array
	 */
	protected $rules = [];

	/**
	 * The data that should be validated,
	 * where 'key' is the alias, with value.
	 *
	 * @var array
	 */
	protected $data = [];

	/**
	 * Any generated errors during validation.
	 * 'key' is the alias, 'value' is the message.
	 *
	 * @var array
	 */
	protected $errors = [];

	/**
	 * Stores custom error message to use
	 * during validation. Where 'key' is the alias.
	 *
	 * @var array
	 */
	protected $customErrors = [];

	/**
	 * Our configuration.
	 *
	 * @var ValidationConfig
	 */
	protected $config;

	/**
	 * The view renderer used to render validation messages.
	 *
	 * @var RendererInterface
	 */
	protected $view;

	/**
	 * Validation constructor.
	 *
	 * @param ValidationConfig  $config
	 * @param RendererInterface $view
	 */
	public function __construct($config, RendererInterface $view)
	{
		$this->ruleSetFiles = $config->ruleSets;

		$this->config = $config;

		$this->view = $view;
	}

	/**
	 * Runs the validation process, returning true/false determining whether
	 * validation was successful or not.
	 *
	 * @param array|null  $data    The array of data to validate.
	 * @param string|null $group   The predefined group of rules to apply.
	 * @param string|null $dbGroup The database group to use.
	 *
	 * @return boolean
	 */
	public function run(array $data = null, string $group = null, string $dbGroup = null): bool
	{
		$data = $data ?? $this->data;

		// i.e. is_unique
		$data['DBGroup'] = $dbGroup;

		$this->loadRuleSets();
		$this->loadRuleGroup($group);

		// If no rules exist, we return false to ensure
		// the developer didn't forget to set the rules.
		if (empty($this->rules))
		{
			return false;
		}

		// Replace any placeholders (e.g. {id}) in the rules with
		// the value found in $data, if any.
		$this->rules = $this->fillPlaceholders($this->rules, $data);

		// Need this for searching arrays in validation.
		helper('array');

		// Run through each rule. If we have any field set for
		// this rule, then we need to run them through!
		foreach ($this->rules as $field => $setup)
		{
			// Blast $rSetup apart, unless it's already an array.
			$rules = $setup['rules'] ?? $setup;

			if (is_string($rules))
			{
				$rules = $this->splitRules($rules);
			}

			$values = dot_array_search($field, $data);
			$values = is_array($values) ? $values : [$values];

			if ($values === [])
			{
				// We'll process the values right away if an empty array
				$this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data);
			}

			foreach ($values as $value)
			{
				// Otherwise, we'll let the loop do the job
				$this->processRules($field, $setup['label'] ?? $field, $value, $rules, $data);
			}
		}

		return $this->getErrors() === [];
	}

	/**
	 * Runs the validation process, returning true or false
	 * determining whether validation was successful or not.
	 *
	 * @param mixed    $value
	 * @param string   $rule
	 * @param string[] $errors
	 *
	 * @return boolean
	 */
	public function check($value, string $rule, array $errors = []): bool
	{
		$this->reset();

		return $this->setRule('check', null, $rule, $errors)->run(['check' => $value]);
	}

	/**
	 * Runs all of $rules against $field, until one fails, or
	 * all of them have been processed. If one fails, it adds
	 * the error to $this->errors and moves on to the next,
	 * so that we can collect all of the first errors.
	 *
	 * @param string       $field
	 * @param string|null  $label
	 * @param string|array $value
	 * @param array|null   $rules
	 * @param array        $data
	 *
	 * @return boolean
	 */
	protected function processRules(string $field, string $label = null, $value, $rules = null, array $data = null): bool
	{
		if (is_null($data))
		{
			throw new InvalidArgumentException('You must supply the parameter: data.');
		}

		if (in_array('if_exist', $rules, true))
		{
			$flattenedData = array_flatten_with_dots($data);
			$ifExistField  = $field;

			if (strpos($field, '.*') !== false)
			{
				// We'll change the dot notation into a PCRE pattern
				// that can be used later
				$ifExistField = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/'));

				$dataIsExisting = array_reduce(array_keys($flattenedData), static function ($carry, $item) use ($ifExistField) {
					$pattern = sprintf('/%s/u', $ifExistField);
					return $carry || preg_match($pattern, $item) === 1;
				}, false);
			}
			else
			{
				$dataIsExisting = array_key_exists($ifExistField, $flattenedData);
			}

			unset($ifExistField, $flattenedData);

			if (! $dataIsExisting)
			{
				// we return early if `if_exist` is not satisfied. we have nothing to do here.
				return true;
			}

			// Otherwise remove the if_exist rule and continue the process
			$rules = array_diff($rules, ['if_exist']);
		}

		if (in_array('permit_empty', $rules, true))
		{
			if (! in_array('required', $rules, true) && (is_array($value) ? empty($value) : (trim($value) === '')))
			{
				$passed = true;

				foreach ($rules as $rule)
				{
					if (preg_match('/(.*?)\[(.*)\]/', $rule, $match))
					{
						$rule  = $match[1];
						$param = $match[2];

						if (! in_array($rule, ['required_with', 'required_without'], true))
						{
							continue;
						}

						// Check in our rulesets
						foreach ($this->ruleSetInstances as $set)
						{
							if (! method_exists($set, $rule))
							{
								continue;
							}

							$passed = $passed && $set->$rule($value, $param, $data);
							break;
						}
					}
				}

				if ($passed === true)
				{
					return true;
				}
			}

			$rules = array_diff($rules, ['permit_empty']);
		}

		foreach ($rules as $rule)
		{
			$isCallable = is_callable($rule);

			$passed = false;
			$param  = false;

			if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match))
			{
				$rule  = $match[1];
				$param = $match[2];
			}

			// Placeholder for custom errors from the rules.
			$error = null;

			// If it's a callable, call and and get out of here.
			if ($isCallable)
			{
				$passed = $param === false ? $rule($value) : $rule($value, $param, $data);
			}
			else
			{
				$found = false;

				// Check in our rulesets
				foreach ($this->ruleSetInstances as $set)
				{
					if (! method_exists($set, $rule))
					{
						continue;
					}

					$found  = true;
					$passed = $param === false ? $set->$rule($value, $error) : $set->$rule($value, $param, $data, $error);

					break;
				}

				// If the rule wasn't found anywhere, we
				// should throw an exception so the developer can find it.
				if (! $found)
				{
					throw ValidationException::forRuleNotFound($rule);
				}
			}

			// Set the error message if we didn't survive.
			if ($passed === false)
			{
				// if the $value is an array, convert it to as string representation
				if (is_array($value))
				{
					$value = '[' . implode(', ', $value) . ']';
				}

				$this->errors[$field] = is_null($error)
					? $this->getErrorMessage($rule, $field, $label, $param, $value)
					: $error; // @phpstan-ignore-line

				return false;
			}
		}

		return true;
	}

	/**
	 * Takes a Request object and grabs the input data to use from its
	 * array values.
	 *
	 * @param RequestInterface|IncomingRequest $request
	 *
	 * @return ValidationInterface
	 */
	public function withRequest(RequestInterface $request): ValidationInterface
	{
		/** @var IncomingRequest $request */
		if (strpos($request->getHeaderLine('Content-Type'), 'application/json') !== false)
		{
			$this->data = $request->getJSON(true);
			return $this;
		}

		if (in_array($request->getMethod(), ['put', 'patch', 'delete'], true)
			&& strpos($request->getHeaderLine('Content-Type'), 'multipart/form-data') === false
		)
		{
			$this->data = $request->getRawInput();
		}
		else
		{
			$this->data = $request->getVar() ?? [];
		}

		return $this;
	}

	/**
	 * Sets an individual rule and custom error messages for a single field.
	 *
	 * The custom error message should be just the messages that apply to
	 * this field, like so:
	 *
	 *    [
	 *        'rule' => 'message',
	 *        'rule' => 'message'
	 *    ]
	 *
	 * @param string      $field
	 * @param string|null $label
	 * @param string      $rules
	 * @param array       $errors
	 *
	 * @return $this
	 */
	public function setRule(string $field, string $label = null, string $rules, array $errors = [])
	{
		$this->rules[$field] = [
			'label' => $label,
			'rules' => $rules,
		];

		$this->customErrors = array_merge($this->customErrors, [
			$field => $errors,
		]);

		return $this;
	}

	/**
	 * Stores the rules that should be used to validate the items.
	 * Rules should be an array formatted like:
	 *
	 *    [
	 *        'field' => 'rule1|rule2'
	 *    ]
	 *
	 * The $errors array should be formatted like:
	 *    [
	 *        'field' => [
	 *            'rule' => 'message',
	 *            'rule' => 'message
	 *        ],
	 *    ]
	 *
	 * @param array $rules
	 * @param array $errors // An array of custom error messages
	 *
	 * @return ValidationInterface
	 */
	public function setRules(array $rules, array $errors = []): ValidationInterface
	{
		$this->customErrors = $errors;

		foreach ($rules as $field => &$rule)
		{
			if (! is_array($rule))
			{
				continue;
			}

			if (! array_key_exists('errors', $rule))
			{
				continue;
			}

			$this->customErrors[$field] = $rule['errors'];
			unset($rule['errors']);
		}

		$this->rules = $rules;

		return $this;
	}

	/**
	 * Returns all of the rules currently defined.
	 *
	 * @return array
	 */
	public function getRules(): array
	{
		return $this->rules;
	}

	/**
	 * Checks to see if the rule for key $field has been set or not.
	 *
	 * @param string $field
	 *
	 * @return boolean
	 */
	public function hasRule(string $field): bool
	{
		return array_key_exists($field, $this->rules);
	}

	/**
	 * Get rule group.
	 *
	 * @param string $group Group.
	 *
	 * @return string[] Rule group.
	 *
	 * @throws InvalidArgumentException If group not found.
	 */
	public function getRuleGroup(string $group): array
	{
		if (! isset($this->config->$group))
		{
			throw ValidationException::forGroupNotFound($group);
		}

		if (! is_array($this->config->$group))
		{
			throw ValidationException::forGroupNotArray($group);
		}

		return $this->config->$group;
	}

	/**
	 * Set rule group.
	 *
	 * @param string $group Group.
	 *
	 * @throws InvalidArgumentException If group not found.
	 */
	public function setRuleGroup(string $group)
	{
		$rules = $this->getRuleGroup($group);
		$this->setRules($rules);

		$errorName = $group . '_errors';
		if (isset($this->config->$errorName))
		{
			$this->customErrors = $this->config->$errorName;
		}
	}

	/**
	 * Returns the rendered HTML of the errors as defined in $template.
	 *
	 * @param string $template
	 *
	 * @return string
	 */
	public function listErrors(string $template = 'list'): string
	{
		if (! array_key_exists($template, $this->config->templates))
		{
			throw ValidationException::forInvalidTemplate($template);
		}

		return $this->view
			->setVar('errors', $this->getErrors())
			->render($this->config->templates[$template]);
	}

	/**
	 * Displays a single error in formatted HTML as defined in the $template view.
	 *
	 * @param string $field
	 * @param string $template
	 *
	 * @return string
	 */
	public function showError(string $field, string $template = 'single'): string
	{
		if (! array_key_exists($field, $this->getErrors()))
		{
			return '';
		}

		if (! array_key_exists($template, $this->config->templates))
		{
			throw ValidationException::forInvalidTemplate($template);
		}

		return $this->view
			->setVar('error', $this->getError($field))
			->render($this->config->templates[$template]);
	}

	/**
	 * Loads all of the rulesets classes that have been defined in the
	 * Config\Validation and stores them locally so we can use them.
	 */
	protected function loadRuleSets()
	{
		if (empty($this->ruleSetFiles))
		{
			throw ValidationException::forNoRuleSets();
		}

		foreach ($this->ruleSetFiles as $file)
		{
			$this->ruleSetInstances[] = new $file();
		}
	}

	/**
	 * Loads custom rule groups (if set) into the current rules.
	 *
	 * Rules can be pre-defined in Config\Validation and can
	 * be any name, but must all still be an array of the
	 * same format used with setRules(). Additionally, check
	 * for {group}_errors for an array of custom error messages.
	 *
	 * @param string|null $group
	 *
	 * @return array|ValidationException|null
	 */
	public function loadRuleGroup(string $group = null)
	{
		if (empty($group))
		{
			return null;
		}

		if (! isset($this->config->$group))
		{
			throw ValidationException::forGroupNotFound($group);
		}

		if (! is_array($this->config->$group))
		{
			throw ValidationException::forGroupNotArray($group);
		}

		$this->setRules($this->config->$group);

		// If {group}_errors exists in the config file,
		// then override our custom errors with them.
		$errorName = $group . '_errors';

		if (isset($this->config->$errorName))
		{
			$this->customErrors = $this->config->$errorName;
		}

		return $this->rules;
	}

	/**
	 * Replace any placeholders within the rules with the values that
	 * match the 'key' of any properties being set. For example, if
	 * we had the following $data array:
	 *
	 * [ 'id' => 13 ]
	 *
	 * and the following rule:
	 *
	 *  'required|is_unique[users,email,id,{id}]'
	 *
	 * The value of {id} would be replaced with the actual id in the form data:
	 *
	 *  'required|is_unique[users,email,id,13]'
	 *
	 * @param array $rules
	 * @param array $data
	 *
	 * @return array
	 */
	protected function fillPlaceholders(array $rules, array $data): array
	{
		$replacements = [];

		foreach ($data as $key => $value)
		{
			$replacements["{{$key}}"] = $value;
		}

		if (! empty($replacements))
		{
			foreach ($rules as &$rule)
			{
				if (is_array($rule))
				{
					foreach ($rule as &$row)
					{
						// Should only be an `errors` array
						// which doesn't take placeholders.
						if (is_array($row))
						{
							continue;
						}

						$row = strtr($row, $replacements);
					}
					continue;
				}

				$rule = strtr($rule, $replacements);
			}
		}

		return $rules;
	}

	/**
	 * Checks to see if an error exists for the given field.
	 *
	 * @param string $field
	 *
	 * @return boolean
	 */
	public function hasError(string $field): bool
	{
		return array_key_exists($field, $this->getErrors());
	}

	/**
	 * Returns the error(s) for a specified $field (or empty string if not
	 * set).
	 *
	 * @param string $field Field.
	 *
	 * @return string Error(s).
	 */
	public function getError(string $field = null): string
	{
		if ($field === null && count($this->rules) === 1)
		{
			$field = array_key_first($this->rules);
		}

		return array_key_exists($field, $this->getErrors()) ? $this->errors[$field] : '';
	}

	/**
	 * Returns the array of errors that were encountered during
	 * a run() call. The array should be in the following format:
	 *
	 *    [
	 *        'field1' => 'error message',
	 *        'field2' => 'error message',
	 *    ]
	 *
	 * @return array<string,string>
	 *
	 * Excluded from code coverage because that it always run as cli
	 *
	 * @codeCoverageIgnore
	 */
	public function getErrors(): array
	{
		// If we already have errors, we'll use those.
		// If we don't, check the session to see if any were
		// passed along from a redirect_with_input request.
		if (empty($this->errors) && ! is_cli() && isset($_SESSION, $_SESSION['_ci_validation_errors']))
		{
			$this->errors = unserialize($_SESSION['_ci_validation_errors']);
		}

		return $this->errors ?? [];
	}

	/**
	 * Sets the error for a specific field. Used by custom validation methods.
	 *
	 * @param string $field
	 * @param string $error
	 *
	 * @return ValidationInterface
	 */
	public function setError(string $field, string $error): ValidationInterface
	{
		$this->errors[$field] = $error;

		return $this;
	}

	/**
	 * Attempts to find the appropriate error message
	 *
	 * @param string      $rule
	 * @param string      $field
	 * @param string|null $label
	 * @param string      $param
	 * @param string      $value The value that caused the validation to fail.
	 *
	 * @return string
	 */
	protected function getErrorMessage(string $rule, string $field, string $label = null, string $param = null, string $value = null): string
	{
		// Check if custom message has been defined by user
		if (isset($this->customErrors[$field][$rule]))
		{
			$message = lang($this->customErrors[$field][$rule]);
		}
		else
		{
			// Try to grab a localized version of the message...
			// lang() will return the rule name back if not found,
			// so there will always be a string being returned.
			$message = lang('Validation.' . $rule);
		}

		$message = str_replace('{field}', empty($label) ? $field : lang($label), $message);
		$message = str_replace('{param}', empty($this->rules[$param]['label']) ? $param : lang($this->rules[$param]['label']), $message);

		return str_replace('{value}', $value, $message);
	}

	/**
	 * Split rules string by pipe operator.
	 *
	 * @param string $rules
	 *
	 * @return array
	 */
	protected function splitRules(string $rules): array
	{
		$nonEscapeBracket = '((?<!\\\\)(?:\\\\\\\\)*[\[\]])';
		$pipeNotInBracket = sprintf(
			'/\|(?=(?:[^\[\]]*%s[^\[\]]*%s)*(?![^\[\]]*%s))/',
			$nonEscapeBracket,
			$nonEscapeBracket,
			$nonEscapeBracket
		);

		$_rules = preg_split($pipeNotInBracket, $rules);

		return array_unique($_rules);
	}

	// Misc
	/**
	 * Resets the class to a blank slate. Should be called whenever
	 * you need to process more than one array.
	 *
	 * @return ValidationInterface
	 */
	public function reset(): ValidationInterface
	{
		$this->data         = [];
		$this->rules        = [];
		$this->errors       = [];
		$this->customErrors = [];

		return $this;
	}
}
