<?php

/**
 * This file is part of the Nette Framework (https://nette.org)
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
 */

declare(strict_types=1);

namespace Nette\Caching;

use Nette;


/**
 * Implements the cache for a application.
 */
class Cache
{
	/** dependency */
	public const
		Priority = 'priority',
		Expire = 'expire',
		Sliding = 'sliding',
		Tags = 'tags',
		Files = 'files',
		Items = 'items',
		Constants = 'consts',
		Callbacks = 'callbacks',
		Namespaces = 'namespaces',
		All = 'all';

	/** @deprecated use Cache::Priority */
	public const PRIORITY = self::Priority;

	/** @deprecated use Cache::Expire */
	public const EXPIRATION = self::Expire;

	/** @deprecated use Cache::Expire */
	public const EXPIRE = self::Expire;

	/** @deprecated use Cache::Sliding */
	public const SLIDING = self::Sliding;

	/** @deprecated use Cache::Tags */
	public const TAGS = self::Tags;

	/** @deprecated use Cache::Files */
	public const FILES = self::Files;

	/** @deprecated use Cache::Items */
	public const ITEMS = self::Items;

	/** @deprecated use Cache::Constants */
	public const CONSTS = self::Constants;

	/** @deprecated use Cache::Callbacks */
	public const CALLBACKS = self::Callbacks;

	/** @deprecated use Cache::Namespaces */
	public const NAMESPACES = self::Namespaces;

	/** @deprecated use Cache::All */
	public const ALL = self::All;

	/** @internal */
	public const
		NamespaceSeparator = "\x00",
		NAMESPACE_SEPARATOR = self::NamespaceSeparator;

	private Storage $storage;
	private string $namespace;


	public function __construct(Storage $storage, ?string $namespace = null)
	{
		$this->storage = $storage;
		$this->namespace = $namespace . self::NamespaceSeparator;
	}


	/**
	 * Returns cache storage.
	 */
	final public function getStorage(): Storage
	{
		return $this->storage;
	}


	/**
	 * Returns cache namespace.
	 */
	final public function getNamespace(): string
	{
		return substr($this->namespace, 0, -1);
	}


	/**
	 * Returns new nested cache object.
	 */
	public function derive(string $namespace): static
	{
		return new static($this->storage, $this->namespace . $namespace);
	}


	/**
	 * Reads the specified item from the cache or generate it.
	 */
	public function load(mixed $key, ?callable $generator = null, ?array $dependencies = null): mixed
	{
		$storageKey = $this->generateKey($key);
		$data = $this->storage->read($storageKey);
		if ($data === null && $generator) {
			$this->storage->lock($storageKey);
			try {
				$data = $generator(...[&$dependencies]);
			} catch (\Throwable $e) {
				$this->storage->remove($storageKey);
				throw $e;
			}

			$this->save($key, $data, $dependencies);
		}

		return $data;
	}


	/**
	 * Reads multiple items from the cache.
	 */
	public function bulkLoad(array $keys, ?callable $generator = null): array
	{
		if (count($keys) === 0) {
			return [];
		}

		foreach ($keys as $key) {
			if (!is_scalar($key)) {
				throw new Nette\InvalidArgumentException('Only scalar keys are allowed in bulkLoad()');
			}
		}

		$result = [];
		if (!$this->storage instanceof BulkReader) {
			foreach ($keys as $key) {
				$result[$key] = $this->load(
					$key,
					$generator
						? fn(&$dependencies) => $generator(...[$key, &$dependencies])
						: null,
				);
			}

			return $result;
		}

		$storageKeys = array_map([$this, 'generateKey'], $keys);
		$cacheData = $this->storage->bulkRead($storageKeys);
		foreach ($keys as $i => $key) {
			$storageKey = $storageKeys[$i];
			if (isset($cacheData[$storageKey])) {
				$result[$key] = $cacheData[$storageKey];
			} elseif ($generator) {
				$result[$key] = $this->load($key, fn(&$dependencies) => $generator(...[$key, &$dependencies]));
			} else {
				$result[$key] = null;
			}
		}

		return $result;
	}


	/**
	 * Writes item into the cache.
	 * Dependencies are:
	 * - Cache::Priority => (int) priority
	 * - Cache::Expire => (timestamp) expiration, infinite if null
	 * - Cache::Sliding => (bool) use sliding expiration?
	 * - Cache::Tags => (array) tags
	 * - Cache::Files => (array|string) file names
	 * - Cache::Items => (array|string) cache items
	 * - Cache::Constants => (array|string) cache items
	 * @return mixed  value itself
	 * @throws Nette\InvalidArgumentException
	 */
	public function save(mixed $key, mixed $data, ?array $dependencies = null): mixed
	{
		$key = $this->generateKey($key);

		if ($data instanceof \Closure) {
			$this->storage->lock($key);
			try {
				$data = $data(...[&$dependencies]);
			} catch (\Throwable $e) {
				$this->storage->remove($key);
				throw $e;
			}
		}

		if ($data === null) {
			$this->storage->remove($key);
			return null;
		} else {
			$dependencies = $this->completeDependencies($dependencies);
			if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) {
				$this->storage->remove($key);
			} else {
				$this->storage->write($key, $data, $dependencies);
			}

			return $data;
		}
	}


	/**
	 * Writes multiple items into cache
	 */
	public function bulkSave(array $items, ?array $dependencies = null): void
	{
		$write = $remove = [];

		if (!$this->storage instanceof BulkWriter) {
			foreach ($items as $key => $data) {
				$this->save($key, $data, $dependencies);
			}
			return;
		}

		$dependencies = $this->completeDependencies($dependencies);
		if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) {
			$this->storage->bulkRemove(array_map(fn($key) => $this->generateKey($key), array_keys($items)));
			return;
		}

		foreach ($items as $key => $data) {
			$key = $this->generateKey($key);
			if ($data === null) {
				$remove[] = $key;
			} else {
				$write[$key] = $data;
			}
		}

		if ($remove) {
			$this->storage->bulkRemove($remove);
		}

		if ($write) {
			$this->storage->bulkWrite($write, $dependencies);
		}
	}


	private function completeDependencies(?array $dp): array
	{
		// convert expire into relative amount of seconds
		if (isset($dp[self::Expire])) {
			$dp[self::Expire] = Nette\Utils\DateTime::from($dp[self::Expire])->format('U') - time();
		}

		// make list from TAGS
		if (isset($dp[self::Tags])) {
			$dp[self::Tags] = array_values((array) $dp[self::Tags]);
		}

		// make list from NAMESPACES
		if (isset($dp[self::Namespaces])) {
			$dp[self::Namespaces] = array_values((array) $dp[self::Namespaces]);
		}

		// convert FILES into CALLBACKS
		if (isset($dp[self::Files])) {
			foreach (array_unique((array) $dp[self::Files]) as $item) {
				$dp[self::Callbacks][] = [[self::class, 'checkFile'], $item, @filemtime($item) ?: null]; // @ - stat may fail
			}

			unset($dp[self::Files]);
		}

		// add namespaces to items
		if (isset($dp[self::Items])) {
			$dp[self::Items] = array_unique(array_map([$this, 'generateKey'], (array) $dp[self::Items]));
		}

		// convert CONSTS into CALLBACKS
		if (isset($dp[self::Constants])) {
			foreach (array_unique((array) $dp[self::Constants]) as $item) {
				$dp[self::Callbacks][] = [[self::class, 'checkConst'], $item, constant($item)];
			}

			unset($dp[self::Constants]);
		}

		if (!is_array($dp)) {
			$dp = [];
		}

		return $dp;
	}


	/**
	 * Removes item from the cache.
	 */
	public function remove(mixed $key): void
	{
		$this->save($key, null);
	}


	/**
	 * Removes items from the cache by conditions.
	 * Conditions are:
	 * - Cache::Priority => (int) priority
	 * - Cache::Tags => (array) tags
	 * - Cache::All => true
	 */
	public function clean(?array $conditions = null): void
	{
		$conditions = (array) $conditions;
		if (isset($conditions[self::Tags])) {
			$conditions[self::Tags] = array_values((array) $conditions[self::Tags]);
		}

		$this->storage->clean($conditions);
	}


	/**
	 * Caches results of function/method calls.
	 */
	public function call(callable $function): mixed
	{
		$key = func_get_args();
		if (is_array($function) && is_object($function[0])) {
			$key[0][0] = get_class($function[0]);
		}

		return $this->load($key, fn() => $function(...array_slice($key, 1)));
	}


	/**
	 * Caches results of function/method calls.
	 */
	public function wrap(callable $function, ?array $dependencies = null): \Closure
	{
		return function () use ($function, $dependencies) {
			$key = [$function, $args = func_get_args()];
			if (is_array($function) && is_object($function[0])) {
				$key[0][0] = get_class($function[0]);
			}

			return $this->load($key, function (&$deps) use ($function, $args, $dependencies) {
				$deps = $dependencies;
				return $function(...$args);
			});
		};
	}


	/**
	 * Starts the output cache.
	 */
	public function capture(mixed $key): ?OutputHelper
	{
		$data = $this->load($key);
		if ($data === null) {
			return new OutputHelper($this, $key);
		}

		echo $data;
		return null;
	}


	/**
	 * @deprecated  use capture()
	 */
	public function start($key): ?OutputHelper
	{
		return $this->capture($key);
	}


	/**
	 * Generates internal cache key.
	 */
	protected function generateKey($key): string
	{
		return $this->namespace . md5(is_scalar($key) ? (string) $key : serialize($key));
	}


	/********************* dependency checkers ****************d*g**/


	/**
	 * Checks CALLBACKS dependencies.
	 */
	public static function checkCallbacks(array $callbacks): bool
	{
		foreach ($callbacks as $callback) {
			if (!array_shift($callback)(...$callback)) {
				return false;
			}
		}

		return true;
	}


	/**
	 * Checks CONSTS dependency.
	 */
	private static function checkConst(string $const, $value): bool
	{
		return defined($const) && constant($const) === $value;
	}


	/**
	 * Checks FILES dependency.
	 */
	private static function checkFile(string $file, ?int $time): bool
	{
		return @filemtime($file) == $time; // @ - stat may fail
	}
}
