<?php

namespace Kirby\Cms;

use Closure;
use Kirby\Content\Content;
use Kirby\Content\ContentStorage;
use Kirby\Content\ContentTranslation;
use Kirby\Content\PlainTextContentStorageHandler;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Form\Form;
use Kirby\Panel\Model;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Identifiable;
use Kirby\Uuid\Uuid;
use Kirby\Uuid\Uuids;
use Throwable;

/**
 * ModelWithContent
 *
 * @package   Kirby Cms
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
abstract class ModelWithContent implements Identifiable
{
	/**
	 * Each model must define a CLASS_ALIAS
	 * which will be used in template queries.
	 * The CLASS_ALIAS is a short human-readable
	 * version of the class name, i.e. page.
	 */
	public const CLASS_ALIAS = null;

	/**
	 * Cached array of valid blueprints
	 * that could be used for the model
	 */
	public array|null $blueprints = null;

	public Content|null $content;
	public static App $kirby;
	protected Site|null $site;
	protected ContentStorage $storage;
	public Collection|null $translations;

	/**
	 * Store values used to initilaize object
	 */
	protected array $propertyData = [];

	public function __construct(array $props = [])
	{
		$this->site = $props['site'] ?? null;

		$this->setContent($props['content'] ?? null);
		$this->setTranslations($props['translations'] ?? null);

		$this->propertyData = $props;
	}

	/**
	 * Returns the blueprint of the model
	 */
	abstract public function blueprint(): Blueprint;

	/**
	 * Returns an array with all blueprints that are available
	 */
	public function blueprints(string $inSection = null): array
	{
		// helper function
		$toBlueprints = function (array $sections): array {
			$blueprints = [];

			foreach ($sections as $section) {
				if ($section === null) {
					continue;
				}

				foreach ((array)$section->blueprints() as $blueprint) {
					$blueprints[$blueprint['name']] = $blueprint;
				}
			}

			return array_values($blueprints);
		};

		$blueprint = $this->blueprint();

		// no caching for when collecting for specific section
		if ($inSection !== null) {
			return $toBlueprints([$blueprint->section($inSection)]);
		}

		return $this->blueprints ??= $toBlueprints($blueprint->sections());
	}

	/**
	 * Creates a new instance with the same
	 * initial properties
	 *
	 * @todo eventually refactor without need of propertyData
	 */
	public function clone(array $props = []): static
	{
		return new static(array_replace_recursive($this->propertyData, $props));
	}

	/**
	 * Executes any given model action
	 */
	abstract protected function commit(
		string $action,
		array $arguments,
		Closure $callback
	): mixed;

	/**
	 * Returns the content
	 *
	 * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
	 */
	public function content(string|null $languageCode = null): Content
	{
		// single language support
		if ($this->kirby()->multilang() === false) {
			if ($this->content instanceof Content) {
				return $this->content;
			}

			// don't normalize field keys (already handled by the `Data` class)
			return $this->content = new Content($this->readContent(), $this, false);
		}

		// get the targeted language
		$language = $this->kirby()->language($languageCode);

		// stop if the language does not exist
		if ($language === null) {
			throw new InvalidArgumentException('Invalid language: ' . $languageCode);
		}

		// only fetch from cache for the current language
		if ($languageCode === null && $this->content instanceof Content) {
			return $this->content;
		}

		// get the translation by code
		$translation = $this->translation($language->code());

		// don't normalize field keys (already handled by the `ContentTranslation` class)
		$content = new Content($translation->content(), $this, false);

		// only store the content for the current language
		if ($languageCode === null) {
			$this->content = $content;
		}

		return $content;
	}

	/**
	 * Returns the absolute path to the content file;
	 * NOTE: only supports the published content file
	 * (use `$model->storage()->contentFile()` for other versions)
	 * @internal
	 * @deprecated 4.0.0
	 * @todo Remove in v5
	 * @codeCoverageIgnore
	 *
	 * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
	 */
	public function contentFile(
		string $languageCode = null,
		bool $force = false
	): string {
		Helpers::deprecated('The internal $model->contentFile() method has been deprecated. You can use $model->storage()->contentFile() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file');

		return $this->storage()->contentFile(
			$this->storage()->defaultVersion(),
			$languageCode,
			$force
		);
	}

	/**
	 * Returns an array with all content files;
	 * NOTE: only supports the published content file
	 * (use `$model->storage()->contentFiles()` for other versions)
	 * @deprecated 4.0.0
	 * @todo Remove in v5
	 * @codeCoverageIgnore
	 */
	public function contentFiles(): array
	{
		Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file');

		return $this->storage()->contentFiles(
			$this->storage()->defaultVersion()
		);
	}

	/**
	 * Prepares the content that should be written
	 * to the text file
	 * @internal
	 */
	public function contentFileData(
		array $data,
		string $languageCode = null
	): array {
		return $data;
	}

	/**
	 * Returns the absolute path to the
	 * folder in which the content file is
	 * located
	 * @internal
	 * @deprecated 4.0.0
	 * @todo Remove in v5
	 * @codeCoverageIgnore
	 */
	public function contentFileDirectory(): string|null
	{
		Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
		return $this->root();
	}

	/**
	 * Returns the extension of the content file
	 * @internal
	 * @deprecated 4.0.0
	 * @todo Remove in v5
	 * @codeCoverageIgnore
	 */
	public function contentFileExtension(): string
	{
		Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file');
		return $this->kirby()->contentExtension();
	}

	/**
	 * Needs to be declared by the final model
	 * @internal
	 * @deprecated 4.0.0
	 * @todo Remove in v5
	 * @codeCoverageIgnore
	 */
	abstract public function contentFileName(): string;

	/**
	 * Converts model to new blueprint
	 * incl. its content for all translations
	 */
	protected function convertTo(string $blueprint): static
	{
		// first close object with new blueprint as template
		$new = $this->clone(['template' => $blueprint]);

		// temporary compatibility change (TODO: also convert changes)
		$identifier = $this->storage()->defaultVersion();

		// for multilang, we go through all translations and
		// covnert the content for each of them, remove the content file
		// to rewrite it with converted content afterwards
		if ($this->kirby()->multilang() === true) {
			$translations = [];

			foreach ($this->kirby()->languages()->codes() as $code) {
				if ($this->translation($code)?->exists() === true) {
					$content = $this->content($code)->convertTo($blueprint);

					// delete the old text file
					$this->storage()->delete(
						$identifier,
						$code
					);

					// save to re-create the translation content file
					// with the converted/updated content
					$new->save($content, $code);
				}

				$translations[] = [
					'code'    => $code,
					'content' => $content ?? null
				];
			}

			// cloning the object with the new translations content ensures
			// that `propertyData` prop does not hold any old translations
			// content that could surface on subsequent cloning
			return $new->clone(['translations' => $translations]);
		}

		// for single language setups, we do the same,
		// just once for the main content
		$content = $this->content()->convertTo($blueprint);

		// delete the old text file
		$this->storage()->delete($identifier, 'default');

		return $new->save($content);
	}

	/**
	 * Decrement a given field value
	 */
	public function decrement(
		string $field,
		int $by = 1,
		int $min = 0
	): static {
		$value = (int)$this->content()->get($field)->value() - $by;

		if ($value < $min) {
			$value = $min;
		}

		return $this->update([$field => $value]);
	}

	/**
	 * Returns all content validation errors
	 */
	public function errors(): array
	{
		$errors = [];

		foreach ($this->blueprint()->sections() as $section) {
			$errors = array_merge($errors, $section->errors());
		}

		return $errors;
	}

	/**
	 * Creates a clone and fetches all
	 * lazy-loaded getters to get a full copy
	 */
	public function hardcopy(): static
	{
		$clone = $this->clone();

		foreach (get_object_vars($clone) as $name => $default) {
			if (method_exists($clone, $name) === true) {
				$clone->$name();
			}
		}

		return $clone;
	}

	/**
	 * Each model must return a unique id
	 */
	public function id(): string|null
	{
		return null;
	}

	/**
	 * Increment a given field value
	 */
	public function increment(
		string $field,
		int $by = 1,
		int $max = null
	): static {
		$value = (int)$this->content()->get($field)->value() + $by;

		if ($max && $value > $max) {
			$value = $max;
		}

		return $this->update([$field => $value]);
	}

	/**
	 * Checks if the model is locked for the current user
	 */
	public function isLocked(): bool
	{
		$lock = $this->lock();
		return $lock && $lock->isLocked() === true;
	}

	/**
	 * Checks if the data has any errors
	 */
	public function isValid(): bool
	{
		return Form::for($this)->isValid() === true;
	}

	/**
	 * Returns the parent Kirby instance
	 */
	public function kirby(): App
	{
		return static::$kirby ??= App::instance();
	}

	/**
	 * Returns the lock object for this model
	 *
	 * Only if a content directory exists,
	 * virtual pages will need to overwrite this method
	 */
	public function lock(): ContentLock|null
	{
		$dir = $this->root();

		if ($this::CLASS_ALIAS === 'file') {
			$dir = dirname($dir);
		}

		if (
			$this->kirby()->option('content.locking', true) &&
			is_string($dir) === true &&
			file_exists($dir) === true
		) {
			return new ContentLock($this);
		}

		return null;
	}

	/**
	 * Returns the panel info of the model
	 * @since 3.6.0
	 */
	abstract public function panel(): Model;

	/**
	 * Must return the permissions object for the model
	 */
	abstract public function permissions(): ModelPermissions;

	/**
	 * Clean internal caches
	 *
	 * @return $this
	 */
	public function purge(): static
	{
		$this->blueprints   = null;
		$this->content      = null;
		$this->translations = null;

		return $this;
	}

	/**
	 * Creates a string query, starting from the model
	 * @internal
	 */
	public function query(
		string $query = null,
		string $expect = null
	): mixed {
		if ($query === null) {
			return null;
		}

		try {
			$result = Str::query($query, [
				'kirby'             => $this->kirby(),
				'site'              => $this instanceof Site ? $this : $this->site(),
				'model'             => $this,
				static::CLASS_ALIAS => $this
			]);
		} catch (Throwable) {
			return null;
		}

		if ($expect !== null && $result instanceof $expect === false) {
			return null;
		}

		return $result;
	}

	/**
	 * Read the content from the content file
	 * @internal
	 */
	public function readContent(string $languageCode = null): array
	{
		try {
			return $this->storage()->read(
				$this->storage()->defaultVersion(),
				$languageCode
			);
		} catch (NotFoundException) {
			// only if the content file really does not exist, it's ok
			// to return empty content. Otherwise this could lead to
			// content loss in case of file reading issues
			return [];
		}
	}

	/**
	 * Returns the absolute path to the model
	 */
	abstract public function root(): string|null;

	/**
	 * Stores the content on disk
	 * @internal
	 */
	public function save(
		array|null $data = null,
		string|null $languageCode = null,
		bool $overwrite = false
	): static {
		if ($this->kirby()->multilang() === true) {
			return $this->saveTranslation($data, $languageCode, $overwrite);
		}

		return $this->saveContent($data, $overwrite);
	}

	/**
	 * Save the single language content
	 */
	protected function saveContent(
		array $data = null,
		bool $overwrite = false
	): static {
		// create a clone to avoid modifying the original
		$clone = $this->clone();

		// merge the new data with the existing content
		$clone->content()->update($data, $overwrite);

		// send the full content array to the writer
		$clone->writeContent($clone->content()->toArray());

		return $clone;
	}

	/**
	 * Save a translation
	 *
	 * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist
	 */
	protected function saveTranslation(
		array $data = null,
		string $languageCode = null,
		bool $overwrite = false
	): static {
		// create a clone to not touch the original
		$clone = $this->clone();

		// fetch the matching translation and update all the strings
		$translation = $clone->translation($languageCode);

		if ($translation === null) {
			throw new InvalidArgumentException('Invalid language: ' . $languageCode);
		}

		// get the content to store
		$content      = $translation->update($data, $overwrite)->content();
		$kirby        = $this->kirby();
		$languageCode = $kirby->languageCode($languageCode);

		// remove all untranslatable fields
		if ($languageCode !== $kirby->defaultLanguage()->code()) {
			foreach ($this->blueprint()->fields() as $field) {
				if (($field['translate'] ?? true) === false) {
					$content[strtolower($field['name'])] = null;
				}
			}

			// remove UUID for non-default languages
			if (Uuids::enabled() === true && isset($content['uuid']) === true) {
				$content['uuid'] = null;
			}

			// merge the translation with the new data
			$translation->update($content, true);
		}

		// send the full translation array to the writer
		$clone->writeContent($translation->content(), $languageCode);

		// reset the content object
		$clone->content = null;

		// return the updated model
		return $clone;
	}

	/**
	 * Sets the Content object
	 *
	 * @return $this
	 */
	protected function setContent(array $content = null): static
	{
		if ($content !== null) {
			$content = new Content($content, $this);
		}

		$this->content = $content;
		return $this;
	}

	/**
	 * Create the translations collection from an array
	 *
	 * @return $this
	 */
	protected function setTranslations(array $translations = null): static
	{
		if ($translations !== null) {
			$this->translations = new Collection();

			foreach ($translations as $props) {
				$props['parent'] = $this;
				$translation = new ContentTranslation($props);
				$this->translations->data[$translation->code()] = $translation;
			}
		} else {
			$this->translations = null;
		}

		return $this;
	}

	/**
	 * Returns the parent Site instance
	 */
	public function site(): Site
	{
		return $this->site ??= $this->kirby()->site();
	}

	/**
	 * Returns the content storage handler
	 * @internal
	 */
	public function storage(): ContentStorage
	{
		return $this->storage ??= new ContentStorage(
			model:   $this,
			handler: PlainTextContentStorageHandler::class
		);
	}

	/**
	 * Convert the model to a simple array
	 */
	public function toArray(): array
	{
		return [
			'content'      => $this->content()->toArray(),
			'translations' => $this->translations()->toArray()
		];
	}

	/**
	 * String template builder with automatic HTML escaping
	 * @since 3.6.0
	 *
	 * @param string|null $template Template string or `null` to use the model ID
	 * @param string|null $fallback Fallback for tokens in the template that cannot be replaced
	 *                              (`null` to keep the original token)
	 */
	public function toSafeString(
		string $template = null,
		array $data = [],
		string|null $fallback = ''
	): string {
		return $this->toString($template, $data, $fallback, 'safeTemplate');
	}

	/**
	 * String template builder
	 *
	 * @param string|null $template Template string or `null` to use the model ID
	 * @param string|null $fallback Fallback for tokens in the template that cannot be replaced
	 *                              (`null` to keep the original token)
	 * @param string $handler For internal use
	 */
	public function toString(
		string $template = null,
		array $data = [],
		string|null $fallback = '',
		string $handler = 'template'
	): string {
		if ($template === null) {
			return $this->id() ?? '';
		}

		if ($handler !== 'template' && $handler !== 'safeTemplate') {
			throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore
		}

		$result = Str::$handler($template, array_replace([
			'kirby'             => $this->kirby(),
			'site'              => $this instanceof Site ? $this : $this->site(),
			'model'             => $this,
			static::CLASS_ALIAS => $this,
		], $data), ['fallback' => $fallback]);

		return $result;
	}

	/**
	 * Makes it possible to convert the entire model
	 * to a string. Mostly useful for debugging
	 */
	public function __toString(): string
	{
		return $this->id();
	}

	/**
	 * Returns a single translation by language code
	 * If no code is specified the current translation is returned
	 */
	public function translation(
		string $languageCode = null
	): ContentTranslation|null {
		if ($language = $this->kirby()->language($languageCode)) {
			return $this->translations()->find($language->code());
		}

		return null;
	}

	/**
	 * Returns the translations collection
	 */
	public function translations(): Collection
	{
		if ($this->translations !== null) {
			return $this->translations;
		}

		$this->translations = new Collection();

		foreach ($this->kirby()->languages() as $language) {
			$translation = new ContentTranslation([
				'parent' => $this,
				'code'   => $language->code(),
			]);

			$this->translations->data[$translation->code()] = $translation;
		}

		return $this->translations;
	}

	/**
	 * Updates the model data
	 *
	 * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values
	 */
	public function update(
		array $input = null,
		string $languageCode = null,
		bool $validate = false
	): static {
		$form = Form::for($this, [
			'ignoreDisabled' => $validate === false,
			'input'          => $input,
			'language'       => $languageCode,
		]);

		// validate the input
		if ($validate === true && $form->isInvalid() === true) {
			throw new InvalidArgumentException([
				'fallback' => 'Invalid form with errors',
				'details'  => $form->errors()
			]);
		}

		$arguments = [static::CLASS_ALIAS => $this, 'values' => $form->data(), 'strings' => $form->strings(), 'languageCode' => $languageCode];
		return $this->commit('update', $arguments, function ($model, $values, $strings, $languageCode) {
			return $model->save($strings, $languageCode, true);
		});
	}

	/**
	 * Returns the model's UUID
	 * @since 3.8.0
	 */
	public function uuid(): Uuid|null
	{
		return Uuid::for($this);
	}

	/**
	 * Low level data writer method
	 * to store the given data on disk or anywhere else
	 * @internal
	 */
	public function writeContent(array $data, string $languageCode = null): bool
	{
		$data = $this->contentFileData($data, $languageCode);
		$id   = $this->storage()->defaultVersion();

		try {
			// we can only update if the version already exists
			$this->storage()->update($id, $languageCode, $data);
		} catch (NotFoundException) {
			// otherwise create a new version
			$this->storage()->create($id, $languageCode, $data);
		}

		return true;
	}
}
