<?php
namespace Melbahja\Seo\Sitemap;

use XMLWriter;
use Melbahja\Seo\{
	Utils\Utils,
	Exceptions\SitemapException,
	Interfaces\SitemapBuilderInterface,
	Interfaces\SitemapSetupableInterface
};

/**
 * @package Melbahja\Seo
 * @see https://git.io/phpseo
 * @license MIT
 * @copyright Mohamed Elbahja
 */
class LinksBuilder implements SitemapBuilderInterface
{
	/**
	 * Validations
	 */
	protected array $validation = [
		'video' => ['thumbnail_loc', 'title', 'description'],
		'freqs' => ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']
	];

	/**
	 * Current URL being built
	 */
	protected array $url = [];

	/**
	 * XMLWriter instance
	 */
	protected readonly XMLWriter $writer;

	/**
	 * the tmp file path in case of saving to a file or OutputMode is TEMP.
	 */
	protected readonly string $tempPath;

	/**
	 * Sitemap options
	 */
	protected array $defaultOptions = [
		'images'    => false,
		'videos'    => false,
		'news'      => false,
		'localized' => false,
		'cdata'     => [],   // fields to wrap in CDATA for eg: ['video:title', 'video:description', 'image:caption']
		'indent'    => null, // pretty print indent character
	];

	/**
	 * Initialize sitemap builder
	 */
	public function __construct(

		/**
		 * @param string $baseUrl  The base URL for sitemap urls.
		 */
		public readonly string $baseUrl,

		/**
		 * @param string|null $filePath  The sitemap file name.
		 */
		public readonly ?string $filePath = null,

		/**
		 * @param int Maximum urls in to generate in current sitemap
		 */
		public int $maxUrls = 25000,

		/**
		 * The output mode of generated sitemaps.
		 *
		 * @param OutputMode
		 */
		public readonly OutputMode $mode = OutputMode::TEMP,

		/**
		 * @param array Sitemap config options
		 */
		protected array $options = [],

		/**
		 * The resource in case of STREAM mode, defaults to stdout.
		 *
		 * @param resource
		 */
		private $stream = STDOUT,
	){

		// init xml writer
		switch ($mode)
		{
			case OutputMode::TEMP:

				if ($filePath === null) {
					throw new SitemapException("Output \$filePath can not be empty!");
				}

				$this->tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5(uniqid()) . '.xml';
				$this->writer   = new XMLWriter();
				$this->writer->openUri($this->tempPath);
				break;

			case OutputMode::FILE:

				if ($filePath === null) {
					throw new SitemapException("Output \$filePath can not be empty!");
				}

				$this->writer = new XMLWriter();
				$this->writer->openUri($filePath);
				break;

			case OutputMode::STREAM:

				if (method_exists(XMLWriter::class, 'toStream')) {

					$this->writer = XMLWriter::toStream($stream);

				} else { // php < 8.4 workaround

					$this->tempPath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5(uniqid()) . '.xml';
					$this->writer   = new XMLWriter();
					$this->writer->openUri($this->tempPath);
				}

				break;

			default:
				$this->writer = new XMLWriter();
				$this->writer->openMemory();
				break;
		}

		$this->options = array_merge($this->defaultOptions, $options);
		if ($this instanceof SitemapSetupableInterface) {
			$this->options = $this->preSetup($this->options);
		}

		// output fmt
		if ($this->options['indent'] !== null) {
			$this->writer->setIndent(true);
			$this->writer->setIndentString($this->options['indent']);
		}

		$this->writer->startDocument(version: '1.0', encoding: 'UTF-8');
		$this->writer->writeComment(' Generated by https://git.io/phpseo ');
		$this->writer->startElement('urlset');
		$this->writer->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');

		if ($this->options['images']) {
			$this->writer->writeAttribute('xmlns:image', static::IMAGE_NS);
		}

		if ($this->options['news']) {
			$this->writer->writeAttribute('xmlns:news', static::NEWS_NS);
		}

		if ($this->options['videos']) {
			$this->writer->writeAttribute('xmlns:video', static::VIDEO_NS);
		}

		if ($this->options['localized']) {
			$this->writer->writeAttribute('xmlns:xhtml', static::XHTML_NS);
		}
	}

	/**
	 * Loc will commit last url and start new one.
	 */
	public function loc(string $url): self
	{
		return $this->commit()->url($url);
	}

	/**
	 * Register new url
	 */
	public function url(string $url): self
	{
		if ($this->maxUrls <= 0) {
			throw new SitemapException("The maximum urls has been exhausted");
		}

		$this->url['loc'] = Utils::encodeSitemapUrl(Utils::resolveRelativeUrl($this->baseUrl, $url));
		return $this;
	}

	/**
	 * Set alternative language url for multi lang support.
	 *
	 * @param  string $url
	 * @param  string $lang   ISO 639-1 or ISO 3166-1 alpha-2
	 */
	public function alternate(string $url, string $lang): self
	{
		$this->url['alternate'][] = [$url, $lang]; // encoded and escaped in commit
		return $this;
	}

	/**
	 * Add a SitemapUrl object to the sitemap
	 *
	 * @param SitemapUrl $url
	 * @return self
	 */
	public function addItem(SitemapUrl $url): self
	{
		$this->loc($url->url);

		if ($url->lastmod !== null) {
			$this->lastMod($url->lastmod);
		}

		if ($url->changefreq !== null) {
			$this->changeFreq($url->changefreq);
		}

		if ($url->priority !== null) {
			$this->priority($url->priority);
		}

		// I assume you're using a news builder!
		if ($url->news !== null) {
			$this->news($url->news);
		}

		foreach ($url->images as $img)
		{
			$this->image($img['loc'], $img);
		}

		foreach ($url->videos as $vid)
		{
			$this->video($vid['title'], $vid);
		}

		foreach ($url->alternates as $alt)
		{
			$this->alternate($alt['href'], $alt['lang']);
		}

		return $this;
	}

	/**
	 * Commit the pending url to sitemap
	 */
	public function commit(): self
	{
		if (empty($this->url)) {
			return $this;
		}

		$this->writer->startElement('url');

		foreach ($this->url as $name => $value)
		{
			// images handler
			if ($name === 'image') {

				foreach ($value as $img)
				{
					$this->writer->startElementNs('image', 'image', null);
					foreach ($img as $key => $val)
					{
						$this->writeElement('image', $key, $val);
					}
					$this->writer->endElement();
				}
				continue;
			}

			// videos handler (supports multiple videos per URL)
			if ($name === 'video') {

				foreach ($value as $vid)
				{
					$this->writer->startElementNs('video', 'video', null);

					foreach ($vid as $key => $val)
					{
						// multiple video nodes
						// like multi <restriction /><restriction />
						if (is_array($val) && array_is_list($val)) {

							foreach ($val as $item)
							{
								$item = is_array($item) ? $item : ['value' => $item];

								$this->writer->startElementNs('video', $key, null);
								foreach (($item['attrs'] ?? []) as $attr => $aVal)
								{
									$this->writer->writeAttribute($attr, $aVal);
								}
								$this->writeText($item['value'], 'video:' . $key);
								$this->writer->endElement();
							}
							continue;
						}

						$val = is_array($val) ? $val : ['value' => $val];

						$this->writer->startElementNs('video', $key, null);

						foreach (($val['attrs'] ?? []) as $attr => $aVal)
						{
							$this->writer->writeAttribute($attr, $aVal);
						}

						$this->writeText($val['value'], 'video:'. $key);
						$this->writer->endElement();

						// $this->writeCDataElement('video', $key, $val);
					}

					$this->writer->endElement();
				}
				continue;
			}

			// news
			if ($name === 'news') {

				$this->writer->startElementNs('news', 'news', null);
				$this->writer->startElementNs('news', 'publication', null);
				$this->writeElement('news', 'name', $value['name']);
				$this->writer->writeElementNs('news', 'language', null, $value['language']);
				$this->writer->endElement();

				foreach ($value as $key => $val)
				{
					if ($key !== 'name' && $key !== 'language') {
						$this->writeElement('news', $key, $val);
					}
				}

				$this->writer->endElement();
				continue;
			}

			// alternate hreflangs
			if ($name === 'alternate') {

				foreach ($value as $alt)
				{
					$this->writer->startElementNs('xhtml', 'link', null);
					$this->writer->writeAttribute('rel', 'alternate');
					$this->writer->writeAttribute('href', Utils::encodeSitemapUrl(Utils::resolveRelativeUrl($this->baseUrl, $alt[0])));
					$this->writer->writeAttribute('hreflang', $alt[1]);
					$this->writer->endElement();
				}
				continue;
			}

			// std elements loc, lastmod, changefreq, priority
			$this->writer->writeElement($name, (string) $value);
		}

		// End url
		$this->writer->endElement();

		$this->maxUrls--;
		$this->url = [];

		return $this;
	}

	/**
	 * Last modification date
	 */
	public function lastMod(string|int $date): self
	{
		$this->url['lastmod'] = Utils::formatDate($date);
		return $this;
	}

	/**
	 * Set a image
	 */
	public function image(string $imageUrl, array $options = []): self
	{
		if ($this->options['images'] === false) {
			throw new SitemapException("Before set a image, enable images option");
		}

		$this->url['image'][] = array_merge($options, [
			'loc' => Utils::resolveRelativeUrl($this->baseUrl, $imageUrl),
		]);

		return $this;
	}

	/**
	 * Add a video to current url.
	 *
	 * @param string $title  The title of the video.
	 * @param array $options Video options.
	 * For elem with attributes use: ['player_loc' => ['value' => 'url', 'attrs' => ['allow_embed' => 'yes']]]
	 */
	public function video(string $title, array $options = []): self
	{
		if ($this->options['videos'] === false) {
			throw new SitemapException("Before set a video, enable videos option first");
		}

		$options['title'] = $title;

		// resolve alias
		if (isset($options['thumbnail'])) {
			$options['thumbnail_loc'] = $options['thumbnail'];
			unset($options['thumbnail']);
		}

		foreach ($this->validation['video'] as $v)
		{
			if (!isset($options[$v])) {
				throw new SitemapException("video {$v} option is required");
			}
		}

		if (!isset($options['content_loc']) && !isset($options['player_loc'])) {
			throw new SitemapException("Raw video url content_loc or player_loc embed is required");
		}

		$options['thumbnail_loc'] = Utils::resolveRelativeUrl($this->baseUrl, $options['thumbnail_loc']);
		$this->url['video'][] = $options;

		return $this;
	}

	/**
	 * Set change frequency
	 */
	public function changeFreq(string $freq): self
	{
		if (!in_array($freq, $this->validation['freqs'], true)) {
			throw new SitemapException("changefreq value not valid");
		}

		$this->url['changefreq'] = $freq;
		return $this;
	}

	/**
	 * Url priority
	 */
	public function priority(float|int|string $priority): self
	{
		// priority is 0.0 and 1.0
		$priority = (float) $priority;
		if ($priority < 0.0 || $priority > 1.0) {
			throw new SitemapException("Priority must be between 0.0 and 1.0");
		}

		$this->url['priority'] = number_format($priority, 1, '.', '');
		return $this;
	}

	/**
	 * Save generated sitemap as file
	 *
	 * @param  string|null $uriPath can only be passed on OutputMode::MEMORY, if null will just follow current mode.
	 * @return bool|string bool in case of mode is not memory, and string if it a writing to memory.
	 */
	public function render(?string $uriPath = null): bool|string
	{
		$this->commit();
		$this->writer->endElement();
		$this->writer->endDocument();

		if ($this->mode === OutputMode::MEMORY) {

			if ($uriPath !== null) {
				return file_put_contents($uriPath, $this->writer->outputMemory()) !== false;
			}

			return $this->writer->outputMemory();
		}

		$this->writer->flush();
		if ($this->mode === OutputMode::TEMP) {
			return rename($this->tempPath, $this->filePath);
		}

		// php < 8.4 workaround
		if ($this->mode === OutputMode::STREAM && method_exists(XMLWriter::class, 'toStream') === false) {

			$tempFd = fopen($this->tempPath, 'r');
			$stcopy = stream_copy_to_stream($tempFd, $this->stream);
			fclose($tempFd);

			return (bool) $stcopy;
		}

		return true;
	}


	/**
	 * Get XML as string in case of memory mode, or write to target.
	 */
	public function __toString(): string
	{
		return $this->render();
	}

	/**
	 * Write element with optional CDATA wrapping
	 *
	 * @param string $namespace namespace prefix (e.g., 'video', 'image', 'news')
	 * @param string $key       element name
	 * @param mixed  $value     element value
	 */
	protected function writeElement(string $namespace, string $key, mixed $value): bool
	{

		if ($this->shouldUseCData("{$namespace}:{$key}", $value)) {

			$this->writer->startElementNs($namespace, $key, null);
			$this->writer->writeCData((string) $value);
			return $this->writer->endElement();
		}

		return $this->writer->writeElementNs($namespace, $key, null, (string) $value);
	}

	/**
	 * Write text/CDATA auto detceted or based on manully set fields
	 *
	 * @param mixed  $value
	 * @param string $context Optional context for CDATA detection
	 */
	protected function writeText(mixed $value, string $field = ''): bool
	{
		if ($field !== '' && $this->shouldUseCData($field, $value)) {
			return $this->writer->writeCData((string) $value);
		}

		return $this->writer->text((string) $value);
	}

	/**
	 * Check if field should use CDATA
	 */
	protected function shouldUseCData(string $field, mixed $value = null): bool
	{
		if (in_array($field, $this->options['cdata'], true)) {
			return true;
		}

		if ($value === null || ($value = (string) $value) === '') {
			return false;
		}

		return str_contains($value, '<') || str_contains($value, '>') || str_contains($value, '&');
	}

	/**
	 * Cleanup
	 */
	public function __destruct()
	{
		if (isset($this->tempPath) && file_exists($this->tempPath)) {
			@unlink($this->tempPath);
		}
	}
}
