<?php

namespace Kirby\Content;

use Kirby\Cms\Language;
use Kirby\Cms\Languages;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Fields;
use Kirby\Http\Uri;
use Kirby\Toolkit\BlockCollectionAccess;

/**
 * The Version class handles all actions for a single
 * version and is identified by a VersionId instance
 *
 * @package   Kirby Content
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 * @since     5.0.0
 * @unstable
 */
class Version
{
	public function __construct(
		protected ModelWithContent $model,
		protected VersionId $id
	) {
	}

	/**
	 * Returns a Content object for the given language
	 */
	public function content(Language|string $language = 'default'): Content
	{
		$language = Language::ensure($language);
		$fields   = $this->read($language) ?? [];

		// This is where we merge content from the default language
		// to provide a fallback for missing/untranslated fields.
		//
		// @todo This is the critical point that needs to be removed/refactored
		// in the future, to provide multi-language support with truly
		// individual versions of pages and no longer enforce the fallback.
		if ($language->isDefault() === false) {
			// merge the fields with the default language
			$fields = [
				...$this->read('default') ?? [],
				...$fields
			];
		}

		// remove fields that should not be used for the Content object
		unset($fields['lock']);

		return new Content(
			parent: $this->model,
			data:   $fields,
			normalize: false
		);
	}

	/**
	 * Provides simplified access to the absolute content file path.
	 * This should stay an internal method and be removed as soon as
	 * the dependency on file storage methods is resolved more clearly.
	 *
	 * @internal
	 */
	#[BlockCollectionAccess]
	public function contentFile(Language|string $language = 'default'): string
	{
		return $this->model->storage()->contentFile(
			$this->id,
			Language::ensure($language)
		);
	}

	/**
	 * Make sure that all field names are converted to lower
	 * case to be able to merge and filter them properly
	 */
	protected function convertFieldNamesToLowerCase(array $fields): array
	{
		return array_change_key_case($fields, CASE_LOWER);
	}

	/**
	 * Creates a new version for the given language
	 * @todo Convert to a static method that creates the version initially with all relevant languages
	 *
	 * @param array<string, string> $fields Content fields
	 */
	#[BlockCollectionAccess]
	public function create(
		array $fields,
		Language|string $language = 'default'
	): void {
		$language = Language::ensure($language);

		// check if creating is allowed
		VersionRules::create($this, $fields, $language);

		// track the changes
		if ($this->id->is('changes') === true) {
			(new Changes())->track($this->model);
		}

		$this->model->storage()->create(
			versionId: $this->id,
			language:  $language,
			fields:    $this->prepareFieldsBeforeWrite($fields, $language)
		);

		// make sure that an older version does not exist in the cache
		VersionCache::remove($this, $language);
	}

	/**
	 * Deletes a version for a specific language
	 */
	#[BlockCollectionAccess]
	public function delete(Language|string $language = 'default'): void
	{
		if ($language === '*') {
			foreach (Languages::ensure() as $language) {
				$this->delete($language);
			}

			return;
		}

		$language = Language::ensure($language);

		// check if deleting is allowed
		VersionRules::delete($this, $language);

		$this->model->storage()->delete($this->id, $language);

		// untrack the changes if the version does no longer exist
		// in any of the available languages
		if (
			$this->id->is('changes') === true &&
			$this->exists('*') === false
		) {
			(new Changes())->untrack($this->model);
		}

		// Remove the version from the cache
		VersionCache::remove($this, $language);
	}

	/**
	 * Returns all validation errors for the given language
	 */
	public function errors(Language|string $language = 'default'): array
	{
		$fields = Fields::for($this->model, $language);
		$fields->fill(
			input: $this->content($language)->toArray()
		);

		return $fields->errors();
	}

	/**
	 * Checks if a version exists for the given language
	 */
	public function exists(Language|string $language = 'default'): bool
	{
		// go through all possible languages to check if this
		// version exists in any language
		if ($language === '*') {
			foreach (Languages::ensure() as $language) {
				if ($this->exists($language) === true) {
					return true;
				}
			}

			return false;
		}

		return $this->model->storage()->exists(
			$this->id,
			Language::ensure($language)
		);
	}

	/**
	 * Returns the VersionId instance for this version
	 */
	public function id(): VersionId
	{
		return $this->id;
	}

	/**
	 * Returns whether the content of both versions
	 * is identical
	 */
	public function isIdentical(
		Version|VersionId|string $version,
		Language|string $language = 'default'
	): bool {
		if (is_string($version) === true) {
			$version = VersionId::from($version);
		}

		if ($version instanceof VersionId) {
			$version = $this->sibling($version);
		}

		if ($version->id()->is($this->id) === true) {
			return true;
		}

		$language = Language::ensure($language);
		$fields   = Fields::for($this->model, $language);

		// read fields low-level from storage
		$a = $this->read($language) ?? [];
		$b = $version->read($language) ?? [];

		// remove fields that should not be
		// considered in the comparison
		unset(
			$a['lock'],
			$b['lock'],
			$a['uuid'],
			$b['uuid']
		);

		$a = $fields->reset()->fill(input: $a)->toFormValues();
		$b = $fields->reset()->fill(input: $b)->toFormValues();

		ksort($a);
		ksort($b);

		return $a === $b;
	}

	/**
	 * Checks if the version is the latest version
	 */
	public function isLatest(): bool
	{
		return $this->id->is('latest');
	}

	/**
	 * Checks if the version is locked for the current user
	 */
	public function isLocked(Language|string $language = 'default'): bool
	{
		return $this->lock($language)->isLocked();
	}

	/**
	 * Checks if there are any validation errors for the given language
	 */
	public function isValid(Language|string $language = 'default'): bool
	{
		return $this->errors($language) === [];
	}

	/**
	 * Returns the lock object for the version
	 */
	#[BlockCollectionAccess]
	public function lock(Language|string $language = 'default'): Lock
	{
		return Lock::for($this, $language);
	}

	/**
	 * Returns the parent model
	 */
	public function model(): ModelWithContent
	{
		return $this->model;
	}

	/**
	 * Returns the modification timestamp of a version
	 * if it exists
	 */
	public function modified(
		Language|string $language = 'default'
	): int|null {
		if ($this->exists($language) === true) {
			return $this->model->storage()->modified(
				$this->id,
				Language::ensure($language)
			);
		}

		return null;
	}

	/**
	 * Moves the version to a new language and/or version
	 *
	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
	 */
	#[BlockCollectionAccess]
	public function move(
		Language|string $fromLanguage,
		VersionId|null $toVersionId = null,
		Language|string|null $toLanguage = null,
		Storage|null $toStorage = null
	): void {
		$fromVersion  = $this;
		$fromLanguage = Language::ensure($fromLanguage);
		$toLanguage   = Language::ensure($toLanguage ?? $fromLanguage);
		$toVersion    = $this->sibling($toVersionId ?? $this->id);

		// check if moving is allowed
		VersionRules::move(
			fromVersion: $fromVersion,
			fromLanguage: $fromLanguage,
			toVersion: $toVersion,
			toLanguage: $toLanguage
		);

		$this->model->storage()->move(
			fromVersionId: $fromVersion->id(),
			fromLanguage: $fromLanguage,
			toVersionId: $toVersion->id(),
			toLanguage: $toLanguage,
			toStorage: $toStorage
		);

		// remove both versions from the cache
		VersionCache::remove($fromVersion, $fromLanguage);
		VersionCache::remove($toVersion, $toLanguage);
	}

	/**
	 * Prepare fields to be written by removing unwanted fields
	 * depending on the language or model and by cleaning the field names
	 */
	protected function prepareFieldsBeforeWrite(
		array $fields,
		Language $language
	): array {
		// convert all field names to lower case
		$fields = $this->convertFieldNamesToLowerCase($fields);

		// make sure to store the right fields for the model
		$fields = $this->model->contentFileData($fields, $language);

		// add the editing user
		if (
			Lock::isEnabled() === true &&
			$this->id->is('changes') === true
		) {
			$fields['lock'] = $this->model->kirby()->user()?->id();

		// remove the lock field for any other version or
		// if locking is disabled
		} else {
			unset($fields['lock']);
		}

		// the default language stores all fields
		if ($language->isDefault() === true) {
			return $fields;
		}

		// remove all untranslatable fields
		foreach ($this->model->blueprint()->fields() as $field) {
			if (($field['translate'] ?? true) === false) {
				unset($fields[strtolower($field['name'])]);
			}
		}

		// remove UUID for non-default languages
		unset($fields['uuid']);

		return $fields;
	}

	/**
	 * Make sure that reading from storage will always
	 * return a usable set of fields with clean field names
	 */
	protected function prepareFieldsAfterRead(array $fields, Language $language): array
	{
		$fields = $this->convertFieldNamesToLowerCase($fields);

		// ignore all fields with null values
		return array_filter($fields, fn ($field) => $field !== null);
	}

	/**
	 * Returns a verification token for the authentication
	 * of draft and version previews
	 * @unstable
	 */
	#[BlockCollectionAccess]
	public function previewToken(): string
	{
		if ($this->model instanceof Site) {
			// the site itself does not render; its preview is the home page
			$homePage = $this->model->homePage();

			if ($homePage === null) {
				throw new NotFoundException('The home page does not exist');
			}

			return $homePage->version($this->id)->previewToken();
		}

		if (($this->model instanceof Page) === false) {
			throw new LogicException('Invalid model type');
		}

		// bind the token to the custom preview URL so it still matches
		// when the version is rendered, otherwise use the page URL
		$preview = $this->model->blueprint()->preview();
		$url     = is_string($preview) === true ? $preview : $this->model->url();

		return $this->previewTokenFromUrl($url);
	}

	/**
	 * Returns a verification token for the authentication
	 * of draft and version previews from a raw URL
	 */
	protected function previewTokenFromUrl(string $url): string
	{
		// get rid of all modifiers after the path
		$uri = new Uri($url);
		$uri->setFragment(null);
		$uri->setParams(null);
		$uri->setQuery(null);

		$data = [
			'url'       => $uri->toString(),
			'versionId' => $this->id->value()
		];

		$token = $this->model->kirby()->contentToken(
			null,
			json_encode($data, JSON_UNESCAPED_SLASHES)
		);

		return substr($token, 0, 10);
	}

	/**
	 * This method can only be applied to the "changes" version.
	 * It will copy all fields over to the "latest" version and delete
	 * this version afterwards.
	 */
	#[BlockCollectionAccess]
	public function publish(Language|string $language = 'default'): void
	{
		$language = Language::ensure($language);

		// check if publishing is allowed
		VersionRules::publish($this, $language);

		$latest  = $this->sibling('latest')->read($language) ?? [];
		$changes = $this->read($language) ?? [];

		// overwrite all fields that are not in the `changes` version
		// with a null value. The ModelWithContent::update method will merge
		// the input with the existing content fields and setting null values
		// for removed fields will take care of not inheriting old values.
		foreach ($latest as $key => $value) {
			if (isset($changes[$key]) === false) {
				$changes[$key] = null;
			}
		}

		// update the latest version
		$this->model = $this->model->update(
			input: $changes,
			languageCode: $language->code(),
			validate: true
		);

		// delete the changes
		$this->delete($language);
	}

	/**
	 * Returns the stored content fields
	 *
	 * @return array<string, string>|null
	 */
	#[BlockCollectionAccess]
	public function read(Language|string $language = 'default'): array|null
	{
		$language = Language::ensure($language);

		try {
			// make sure that the version exists
			VersionRules::read($this, $language);

			$fields = VersionCache::get($this, $language);

			if ($fields === null) {
				$fields = $this->model->storage()->read($this->id, $language);
				$fields = $this->prepareFieldsAfterRead($fields, $language);

				if ($fields !== null) {
					VersionCache::set($this, $language, $fields);
				}
			}

			return $fields;
		} catch (NotFoundException) {
			return null;
		}
	}

	/**
	 * Replaces the content of the current version with the given fields
	 *
	 * @param array<string, string> $fields Content fields
	 *
	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
	 */
	#[BlockCollectionAccess]
	public function replace(
		array $fields,
		Language|string $language = 'default'
	): void {
		$language = Language::ensure($language);

		// check if replacing is allowed
		VersionRules::replace($this, $fields, $language);

		$this->model->storage()->update(
			versionId: $this->id,
			language: $language,
			fields: $this->prepareFieldsBeforeWrite($fields, $language)
		);

		// remove the version from the cache to read
		// a fresh version next time
		VersionCache::remove($this, $language);
	}

	/**
	 * Convenience wrapper around ::create, ::replace and ::update.
	 */
	#[BlockCollectionAccess]
	public function save(
		array $fields,
		Language|string $language = 'default',
		bool $overwrite = false
	): void {
		if ($this->exists($language) === false) {
			$this->create($fields, $language);
			return;
		}

		if ($overwrite === true) {
			$this->replace($fields, $language);
			return;
		}

		$this->update($fields, $language);
	}

	/**
	 * Returns a sibling version for the same model
	 */
	public function sibling(VersionId|string $id): Version
	{
		return new Version(
			model: $this->model,
			id: VersionId::from($id)
		);
	}

	/**
	 * Updates the modification timestamp of an existing version
	 *
	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
	 */
	#[BlockCollectionAccess]
	public function touch(Language|string $language = 'default'): void
	{
		$language = Language::ensure($language);

		VersionRules::touch($this, $language);

		$this->model->storage()->touch($this->id, $language);
	}

	/**
	 * Removes the lock from the changes version without discarding changes
	 */
	public function unlock(Language|string $language = 'default'): void
	{
		$language = Language::ensure($language);

		if ($this->exists($language) === false) {
			return;
		}

		$fields = $this->read($language);

		// only remove the lock if the current user holds it
		if (($fields['lock'] ?? null) !== $this->model->kirby()->user()?->id()) {
			return;
		}

		unset($fields['lock']);

		$this->model->storage()->update(
			versionId: $this->id,
			language:  $language,
			fields:    $fields
		);

		VersionCache::remove($this, $language);
	}

	/**
	 * Updates the content fields of an existing version
	 *
	 * @param array<string, string> $fields Content fields
	 *
	 * @throws \Kirby\Exception\NotFoundException If the version does not exist
	 */
	#[BlockCollectionAccess]
	public function update(
		array $fields,
		Language|string $language = 'default'
	): void {
		$language = Language::ensure($language);

		// check if updating is allowed
		VersionRules::update($this, $fields, $language);

		// merge the previous state with the new state to always
		// update to a complete version
		$fields = [
			...$this->read($language),
			...$fields
		];

		$this->model->storage()->update(
			versionId: $this->id,
			language: $language,
			fields: $this->prepareFieldsBeforeWrite($fields, $language)
		);

		// remove the version from the cache to read
		// a fresh version next time
		VersionCache::remove($this, $language);
	}

	/**
	 * Returns the preview URL with authentication for drafts and versions
	 * @unstable
	 */
	#[BlockCollectionAccess]
	public function url(): string|null
	{
		if (
			($this->model instanceof Page || $this->model instanceof Site) === false
		) {
			throw new LogicException('Only pages and the site have a content preview URL');
		}

		$url = $this->model->blueprint()->preview();

		// preview was disabled
		if ($url === false) {
			return null;
		}

		// we only need to add a token for draft and changes previews
		if (
			($this->model instanceof Site || $this->model->isDraft() === false) &&
			$this->id->is('changes') === false
		) {
			return match (true) {
				is_string($url) => $url,
				default         => $this->model->url()
			};
		}

		// check if the URL was customized
		if (is_string($url) === true) {
			return $this->urlFromOption($url);
		}

		// it wasn't, use the safer/more reliable model-based preview token
		return $this->urlWithQueryParams($this->model->url(), $this->previewToken());
	}

	/**
	 * Returns the preview URL based on an arbitrary URL from
	 * the blueprint option
	 */
	protected function urlFromOption(string $url): string
	{
		// try to determine a token for a local preview
		// (we cannot determine the token for external previews)
		if ($token = $this->previewTokenFromUrl($url)) {
			return $this->urlWithQueryParams($url, $token);
		}

		// fall back to the URL as defined in the blueprint
		return $url;
	}

	/**
	 * Assembles the preview URL with the added `_token` and `_version`
	 * query params, no matter if the base URL already contains query params
	 */
	protected function urlWithQueryParams(string $baseUrl, string $token): string
	{
		$uri = new Uri($baseUrl);
		$uri->query()->_token = $token;

		if ($this->id->is('changes') === true) {
			$uri->query()->_version = 'changes';
		}

		return $uri->toString();
	}
}
