<?php

namespace Kirby\Cms;

use Closure;
use Kirby\Content\ImmutableMemoryStorage;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Controller;
use Stringable;

/**
 * The Event object is created whenever the `$kirby->trigger()`
 * or `$kirby->apply()` methods are called. It collects all
 * event information and handles calling the individual hooks.
 * @since 3.4.0
 *
 * @package   Kirby Cms
 * @author    Lukas Bestle <lukas@getkirby.com>,
 *            Ahmet Bora
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
class Event implements Stringable
{
	/**
	 * The event type
	 * (e.g. `page` in `page.create:after`)
	 */
	protected string $type;

	/**
	 * The event action
	 * (e.g. `create` in `page.create:after`)
	 */
	protected string|null $action;

	/**
	 * The event state
	 * (e.g. `after` in `page.create:after`)
	 */
	protected string|null $state;

	/**
	 * Class constructor
	 *
	 * @param string $name Full event name (e.g. `page.create:after`)
	 * @param array $arguments Associative array of named event arguments
	 */
	public function __construct(
		protected string $name,
		protected array $arguments = []
	) {
		// split the event name into `$type.$action:$state`
		// $action and $state are optional;
		// if there is more than one dot, $type will be greedy
		$regex = '/^(?<type>.+?)(?:\.(?<action>[^.]*?))?(?:\:(?<state>.*))?$/';
		preg_match($regex, $name, $matches, PREG_UNMATCHED_AS_NULL);

		$this->name      = $name;
		$this->type      = $matches['type'];
		$this->action    = $matches['action'] ?? null;
		$this->state     = $matches['state'] ?? null;
		$this->arguments = $arguments;
	}

	/**
	 * Magic caller for event arguments
	 */
	public function __call(string $method, array $arguments = []): mixed
	{
		return $this->argument($method);
	}

	/**
	 * Improved `var_dump` output
	 * @codeCoverageIgnore
	 */
	public function __debugInfo(): array
	{
		return $this->toArray();
	}

	/**
	 * Makes it possible to simply echo
	 * or stringify the entire object
	 */
	public function __toString(): string
	{
		return $this->toString();
	}

	/**
	 * Returns the action of the event (e.g. `create`)
	 * or `null` if the event name does not include an action
	 */
	public function action(): string|null
	{
		return $this->action;
	}

	/**
	 * Returns a specific event argument
	 */
	public function argument(string $name): mixed
	{
		return $this->arguments[$name] ?? null;
	}

	/**
	 * Returns the arguments of the event
	 */
	public function arguments(): array
	{
		return $this->arguments;
	}

	/**
	 * Calls a hook with the event data and returns
	 * the hook's return value
	 *
	 * @param object|null $bind Optional object to bind to the hook function
	 */
	public function call(object|null $bind, Closure $hook): mixed
	{
		// collect the list of possible event arguments
		$data = [
			...$this->arguments(),
			'event' => $this
		];

		// magically call the hook with the arguments it requested
		$hook = new Controller($hook);
		return $hook->call($bind, $data);
	}

	/**
	 * Returns the full name of the event
	 */
	public function name(): string
	{
		return $this->name;
	}

	/**
	 * Returns the full list of possible wildcard
	 * event names based on the current event name
	 */
	public function nameWildcards(): array
	{
		// if the event is already a wildcard event,
		// no further variation is possible
		if (
			$this->type === '*' ||
			$this->action === '*' ||
			$this->state === '*'
		) {
			return [];
		}

		if ($this->action !== null && $this->state !== null) {
			// full $type.$action:$state event

			return [
				$this->type . '.*:' . $this->state,
				$this->type . '.' . $this->action . ':*',
				$this->type . '.*:*',
				'*.' . $this->action . ':' . $this->state,
				'*.' . $this->action . ':*',
				'*:' . $this->state,
				'*'
			];
		}

		if ($this->state !== null) {
			// event without action: $type:$state

			return [
				$this->type . ':*',
				'*:' . $this->state,
				'*'
			];
		}

		if ($this->action !== null) {
			// event without state: $type.$action

			return [
				$this->type . '.*',
				'*.' . $this->action,
				'*'
			];
		}

		// event with a simple name
		return ['*'];
	}

	/**
	 * Returns the state of the event (e.g. `after`)
	 */
	public function state(): string|null
	{
		return $this->state;
	}

	/**
	 * Returns the event data as array
	 */
	public function toArray(): array
	{
		return [
			'name'      => $this->name,
			'arguments' => $this->arguments
		];
	}

	/**
	 * Returns the event name as string
	 */
	public function toString(): string
	{
		return $this->name;
	}

	/**
	 * Returns the type of the event (e.g. `page`)
	 */
	public function type(): string
	{
		return $this->type;
	}

	/**
	 * Updates a given argument with a new value
	 *
	 * @unstable
	 * @throws \Kirby\Exception\InvalidArgumentException
	 */
	public function updateArgument(string $name, $value): void
	{
		if (array_key_exists($name, $this->arguments) !== true) {
			throw new InvalidArgumentException(
				message: 'The argument ' . $name . ' does not exist'
			);
		}

		// no new value has been supplied by the apply hook
		if ($value === null) {

			// To support legacy model modification
			// in hooks without return values, we need to
			// check the state of the updated argument.
			// If the argument is an instance of ModelWithContent
			// and the storage is an instance of ImmutableMemoryStorage,
			// we can replace the argument with its clone to achieve
			// the same effect as if the hook returned the modified model.
			$state = $this->arguments[$name];

			if ($state instanceof ModelWithContent) {
				$storage = $state->storage();

				if (
					$storage instanceof ImmutableMemoryStorage &&
					$storage->nextModel() !== null
				) {
					$this->arguments[$name] = $storage->nextModel();
				}
			}

			// Otherwise, there's no need to update the argument
			// if no new value is provided
			return;
		}

		$this->arguments[$name] = $value;
	}
}
