<?php

namespace XF\Mvc\Entity;

use function array_key_exists, count, is_array, is_object, is_string;

class ArrayValidator implements \ArrayAccess
{
	/**
	 * @var array
	 */
	protected $columns;

	/**
	 * @var ValueFormatter
	 */
	protected $valueFormatter;

	/**
	 * @var array
	 */
	protected $existingValues;

	/**
	 * @var array
	 */
	protected $newValues = [];

	/**
	 * True if this is considered an update. In this case, requirements will only be checked for columns
	 * that have changed.
	 *
	 * @var bool
	 */
	protected $isUpdating;

	/**
	 * @var array
	 */
	protected $errors = [];

	/**
	 * @var bool
	 */
	protected $requirementsChecked = false;

	public function __construct(
		array $columns,
		ValueFormatter $valueFormatter,
		array $existingValues = [],
		bool $isUpdating = false
	)
	{
		$this->columns = $columns;
		$this->valueFormatter = $valueFormatter;
		$this->existingValues = $existingValues;
		$this->isUpdating = $isUpdating;
	}

	public function __isset($key): bool
	{
		return isset($this->columns[$key]);
	}

	public function offsetExists($offset): bool
	{
		return $this->__isset($offset);
	}

	public function get($key)
	{
		if (!isset($this->columns[$key]))
		{
			throw new \InvalidArgumentException("Unknown column $key");
		}

		if (array_key_exists($key, $this->newValues))
		{
			return $this->newValues[$key];
		}
		else if (array_key_exists($key, $this->existingValues))
		{
			return $this->existingValues[$key];
		}
		else
		{
			return null;
		}
	}

	public function __get($key)
	{
		return $this->get($key);
	}

	#[\ReturnTypeWillChange]
	public function offsetGet($offset)
	{
		return $this->get($offset);
	}

	public function set($key, $value, array $options = []): bool
	{
		if (!isset($this->columns[$key]))
		{
			throw new \InvalidArgumentException("Unknown column $key");
		}

		$column = $this->columns[$key];
		$type = $column['type'];

		if (!$this->verifyValueCustom($value, $key, $type, $column))
		{
			return false;
		}

		try
		{
			$value = $this->valueFormatter->castValueToType($value, $type, $column);
		}
		catch (\Exception $e)
		{
			throw new \InvalidArgumentException($e->getMessage() . " [$key]", $e->getCode(), $e);
		}

		if (!$this->valueFormatter->applyValueConstraints(
			$value,
			$type,
			$column,
			$constraintError,
			!empty($options['forceConstraint'])
		))
		{
			if ($constraintError)
			{
				$this->errors[$key] = $constraintError;
			}

			return false;
		}

		if (array_key_exists($key, $this->newValues))
		{
			$isDifferent = ($value !== $this->newValues[$key]);
		}
		else if (array_key_exists($key, $this->existingValues))
		{
			$isDifferent = ($value !== $this->existingValues[$key]);
		}
		else
		{
			$isDifferent = true;
		}

		if ($isDifferent)
		{
			$this->newValues[$key] = $value;
			$this->requirementsChecked = false;
		}

		return true;
	}

	protected function verifyValueCustom(&$value, $key, $type, array $columnOptions)
	{
		$success = true;

		if (!empty($columnOptions['verify']))
		{
			$verifier = $columnOptions['verify'];
			if (!($verifier instanceof \Closure))
			{
				throw new \LogicException("Verifier for $key must be closure");
			}

			$success = $verifier($value, $key, $type, $columnOptions, $this);
			if ($success !== true && $success !== false)
			{
				throw new \LogicException("Verification method of $key did not return a valid indicator (true/false)");
			}
		}

		return $success;
	}

	public function bulkSet(array $values, array $options = []): array
	{
		$results = [];
		foreach ($values AS $key => $value)
		{
			$results[$key] = $this->set($key, $value, $options);
		}

		return $results;
	}

	public function __set($key, $value)
	{
		$this->set($key, $value);
	}

	public function offsetSet($offset, $value): void
	{
		$this->set($offset, $value);
	}

	public function offsetUnset($offset): void
	{
		throw new \LogicException('Entity offsets may not be unset');
	}

	public function getValues(): array
	{
		if ($this->hasErrors())
		{
			throw new \LogicException("Can't get values while having errors; call getValuesForced if you want to ignore errors");
		}

		return array_replace($this->existingValues, $this->newValues);
	}

	public function getValuesForced(): array
	{
		return array_replace($this->existingValues, $this->newValues);
	}

	public function getExistingValues(): array
	{
		return $this->existingValues;
	}

	public function getNewValues(): array
	{
		return $this->newValues;
	}

	public function hasChanges(): bool
	{
		return count($this->newValues) > 0;
	}

	public function error($message, $key = null)
	{
		if ($key !== null)
		{
			$this->errors[$key] = $message;
		}
		else
		{
			$this->errors[] = $message;
		}
	}

	public function hasErrors($withRequirementsCheck = true): bool
	{
		if (!$this->requirementsChecked && $withRequirementsCheck)
		{
			$this->checkRequirements();
			$this->requirementsChecked = true;
		}

		return count($this->errors) > 0;
	}

	public function getErrors(): array
	{
		$this->hasErrors(); // to run the requirements check

		return $this->errors;
	}

	protected function checkRequirements()
	{
		foreach ($this->columns AS $key => $column)
		{
			if (empty($column['required']))
			{
				continue;
			}

			if (isset($this->errors[$key]))
			{
				// already have a more specific error
				continue;
			}

			if ($this->isUpdating && !array_key_exists($key, $this->newValues))
			{
				// for updates, ignore required fields that haven't been changed
				continue;
			}

			$value = $this->get($key);
			$exists = array_key_exists($key, $this->newValues) || array_key_exists($key, $this->existingValues);

			if (!$exists || $value === '' || $value === [] || $value === null)
			{
				if (is_string($column['required']))
				{
					$this->error(\XF::phrase($column['required']), $key);
				}
				else
				{
					$this->error(\XF::phrase('please_enter_value_for_required_field_x', ['field' => $key]), $key);
				}
			}
		}
	}

	public function appendErrors(&$target)
	{
		$errors = $this->getErrors();

		if (!is_array($target))
		{
			if (is_string($target) || is_object($target))
			{
				$target = [$target];
			}
			else
			{
				$target = [];
			}
		}

		if ($errors)
		{
			$target += $errors;
		}
	}
}
