<?php

namespace XF\Repository;

use XF\Entity\Tag;
use XF\Entity\TagResultCache;
use XF\Finder\TagContentFinder;
use XF\Finder\TagFinder;
use XF\Finder\TagResultCacheFinder;
use XF\Mvc\Entity\Repository;
use XF\ResultSet;
use XF\ResultSetInterface;
use XF\Tag\AbstractHandler;
use XF\Tag\RenderWrapper;
use XF\Util\Arr;
use XF\Util\Str;

use function intval, is_array, is_string, strlen;

class TagRepository extends Repository implements ResultSetInterface
{
	public function splitTagList($tagList)
	{
		return Arr::stringToArray($tagList, '/\s*,\s*/');
	}

	public function normalizeTag($tag)
	{
		$tag = Str::strtolower($tag);

		try
		{
			// if this matches, then \v isn't known (appears to be PCRE < 7.2) so don't strip
			if (!preg_match('/\v/', 'v'))
			{
				$new = preg_replace('/\v+/u', ' ', $tag);
				if (is_string($new))
				{
					$tag = $new;
				}
			}
		}
		catch (\Exception $e)
		{
		}
		$tag = preg_replace('/\s+/u', ' ', $tag);

		$tag = preg_replace('/^[^\d\pL]+(.*)[^\d\pL]+$/siUu', '$1', $tag);
		$tag = trim($tag);

		return $tag;
	}

	public function isValidTag($tag)
	{
		$tag = $this->normalizeTag($tag);

		$length = Str::strlen($tag);
		$lengthLimits = $this->options()->tagLength;

		$minLength = max($lengthLimits['min'], 1);
		$maxLength = $lengthLimits['max'] <= 0 ? 100 : min($lengthLimits['max'], 100);

		if ($length < $minLength)
		{
			return false;
		}
		if ($length > $maxLength)
		{
			return false;
		}

		$validation = $this->options()->tagValidation;

		$disallowed = Arr::stringToArray($validation['disallowedWords'], '/\r?\n/');
		if ($disallowed)
		{
			foreach ($disallowed AS $disallowedCheck)
			{
				$disallowedCheck = trim($disallowedCheck);
				if ($disallowedCheck === '')
				{
					continue;
				}
				if (stripos($tag, $disallowedCheck) !== false)
				{
					return false;
				}
			}
		}

		if ($validation['matchRegex'] && !preg_match('/\W[\s\w]*e[\s\w]*$/', $validation['matchRegex']))
		{
			try
			{
				if (!preg_match($validation['matchRegex'], $tag))
				{
					return false;
				}
			}
			catch (\Exception $e)
			{
				$this->app()->logException($e, false, 'Error with tag validation regex: ');
			}
		}

		$censored = $this->app()->stringFormatter()->censorText($tag);
		if ($censored != $tag)
		{
			return false;
		}

		return true;
	}

	public function getTagAutoCompleteResults($search, $maxResults = 10)
	{
		$finder = $this->finder(TagFinder::class);
		$tags = $this->finder(TagFinder::class)
			->where('tag', 'like', $finder->escapeLike($search, '?%'))
			->whereOr(
				['use_count', '>', 0],
				['permanent', '=', 1]
			)
			->order('tag')
			->fetch($maxResults);

		if ($tags->count() < $maxResults)
		{
			$finder = $this->finder(TagFinder::class);
			$extraTags = $this->finder(TagFinder::class)
				->where('tag', 'like', $finder->escapeLike($search, '%?%'))
				->where('tag', 'not like', $finder->escapeLike($search, '?%'))
				->whereOr(
					['use_count', '>', 0],
					['permanent', '=', 1]
				)
				->order('tag')
				->fetch($maxResults - $tags->count());

			$tags = $tags->merge($extraTags);
		}

		return $tags;
	}

	public function createTag($tagName)
	{
		$tag = $this->em->create(Tag::class);
		$tag->tag = $tagName;
		$tag->preSave();

		if ($tag->hasErrors())
		{
			return $this->finder(TagFinder::class)->where('tag', $tagName)->fetchOne();
		}

		$tag->save();

		return $tag;
	}

	public function generateTagUrlVersion($tag)
	{
		$urlVersion = preg_replace('/[^a-zA-Z0-9_ -]/', '', Str::transliterate($tag));
		$urlVersion = preg_replace('/[ -]+/', '-', $urlVersion);

		$db = $this->db();

		if (!strlen($urlVersion))
		{
			$urlVersion = 1 + intval($db->fetchOne("
				SELECT MAX(tag_id)
				FROM xf_tag
			"));
		}
		else
		{
			$existing = $db->fetchRow("
				SELECT *
				FROM xf_tag
				WHERE tag_url = ?
					OR (tag_url LIKE ? AND tag_url REGEXP ?)
				ORDER BY tag_id DESC
				LIMIT 1
			", [$urlVersion, "$urlVersion-%", "^{$urlVersion}-[0-9]+\$"]);
			if ($existing)
			{
				$counter = 1;
				if ($existing['tag_url'] != $urlVersion && preg_match('/-(\d+)$/', $existing['tag_url'], $match))
				{
					$counter = $match[1];
				}

				$testExists = true;
				while ($testExists)
				{
					$counter++;
					$testExists = $db->fetchOne("
						SELECT tag_id
						FROM xf_tag
						WHERE tag_url = ?
					", "$urlVersion-$counter");
				}

				$urlVersion .= "-$counter";
			}
		}

		return $urlVersion;
	}

	/**
	 * @return array<string, Tag>
	 */
	public function getTags(array $tags, &$notFound = [])
	{
		$notFound = [];

		$normalized = [];
		foreach ($tags AS $k => $tag)
		{
			$tag = $this->normalizeTag($tag);
			if (strlen($tag))
			{
				$normalKey = Str::normalize($tag);
				$normalized[$normalKey] = $tag;
			}
		}

		if (!$normalized)
		{
			return [];
		}

		$normalized = array_values($normalized);

		$tags = $this->finder(TagFinder::class)->where('tag', $normalized)->fetch();

		return $this->getNamedTagsInList($normalized, $tags->toArray(), $notFound);
	}

	public function getNamedTagsInList(array $named, array $list, &$notFound = [])
	{
		$found = [];
		$notFound = [];

		foreach ($named AS $tagName)
		{
			$tagName = $this->normalizeTag($tagName);
			$tagCompare = Str::strtolower(Str::normalize($tagName));
			$foundKey = null;

			foreach ($list AS $key => $tag)
			{
				$listTagCompare = Str::strtolower(Str::normalize($tag->tag));
				if ($tagCompare == $listTagCompare)
				{
					$foundKey = $key;
					break;
				}
			}

			if ($foundKey === null)
			{
				$notFound[$tagCompare] = $tagName;
			}
			else
			{
				$found[$foundKey] = $list[$foundKey];
			}
		}

		$notFound = array_values($notFound); // prevent the same tag potentially being not found multiple times

		return $found;
	}

	public function getTagsForCloud($limit, $minUses = 1)
	{
		$db = $this->db();
		$ids = $db->fetchAllColumn($db->limit("
			SELECT tag_id
			FROM xf_tag
			WHERE use_count >= ?
			ORDER BY use_count DESC
		", $limit), $minUses);
		if (!$ids)
		{
			return [];
		}

		return $this->finder(TagFinder::class)->where('tag_id', $ids)->order('tag')->fetch()->toArray();
	}

	public function getTagCloud(array $tags, $levels = 7)
	{
		if (!$tags)
		{
			return [];
		}

		$min = PHP_INT_MAX;
		$max = 0;

		foreach ($tags AS $tag)
		{
			$uses = $tag->use_count;
			if ($uses < $min)
			{
				$min = $uses;
			}
			if ($uses > $max)
			{
				$max = $uses;
			}
		}

		$levelSize = ($max - $min) / $levels;
		$output = [];

		if ($min == $max)
		{
			$middle = ceil($levels / 2);
			foreach ($tags AS $key => $tag)
			{
				$output[$key] = [
					'tag' => $tag,
					'level' => $middle,
				];
			}
		}
		else
		{
			foreach ($tags AS $key => $tag)
			{
				$diffFromMin = $tag->use_count - $min;
				if (!$diffFromMin)
				{
					$level = 1;
				}
				else
				{
					$level = min($levels, ceil($diffFromMin / $levelSize));
				}

				$output[$key] = [
					'tag' => $tag,
					'level' => $level,
				];
			}
		}

		return $output;
	}

	/**
	 * @param string $contentType
	 * @param int $contentId
	 *
	 * @return TagContentFinder
	 */
	public function findContentTags($contentType, $contentId)
	{
		$finder = $this->finder(TagContentFinder::class)
			->where([
				'content_type' => $contentType,
				'content_id' => $contentId,
			])
			->with('Tag', true)
			->order('Tag.tag');

		return $finder;
	}

	public function modifyContentTags($contentType, $contentId, array $addIds, array $removeIds, $userId = null)
	{
		$handler = $this->getTagHandler($contentType, true);
		$content = $handler->getContent($contentId);
		if (!$content)
		{
			return null;
		}

		if ($userId === null)
		{
			$userId = \XF::visitor()->user_id;
		}

		$db = $this->db();
		$db->beginTransaction();

		if ($removeIds)
		{
			$this->removeTagIdsFromContent($removeIds, $contentType, $contentId);
		}

		if ($addIds)
		{
			$contentDate = $handler->getContentDate($content);
			$contentVisible = $handler->getContentVisibility($content);

			$this->addTagIdsToContent($addIds, $contentType, $contentId, $contentDate, $contentVisible, $userId);
		}

		$cache = $this->getContentTagCache($contentType, $contentId);
		$handler->updateContentTagCache($content, $cache);

		$db->commit();

		return $cache;
	}

	protected function removeTagIdsFromContent(array $tagIds, $contentType, $contentId)
	{
		if ($tagIds)
		{
			$db = $this->db();
			$db->query("
				DELETE FROM xf_tag_content
				WHERE tag_id IN (" . $db->quote($tagIds) . ")
					AND content_type = ?
					AND content_id = ?
			", [$contentType, $contentId]);
			$this->recalculateTagUsageCache($tagIds);
		}
	}

	protected function addTagIdsToContent(array $tagIds, $contentType, $contentId, $contentDate, $contentVisible, $addUserId)
	{
		$db = $this->db();

		$visibleSql = $contentVisible ? 1 : 0;

		$insertedIds = [];

		foreach ($tagIds AS $addId)
		{
			$inserted = $db->insert('xf_tag_content', [
				'content_type' => $contentType,
				'content_id' => $contentId,
				'tag_id' => $addId,
				'add_user_id' => $addUserId,
				'add_date' => \XF::$time,
				'content_date' => $contentDate,
				'visible' => $visibleSql,
			], false, false, 'IGNORE');
			$contentTagId = $db->lastInsertId();

			if ($inserted && $contentVisible)
			{
				$db->query("
					UPDATE xf_tag
					SET use_count = use_count + 1,
						last_use_date = ?
					WHERE tag_id = ?
				", [\XF::$time, $addId]);
			}
			if ($inserted)
			{
				$insertedIds[$contentTagId] = $addId;
			}
		}

		return $insertedIds;
	}

	public function removeContentTags($contentType, $contentId)
	{
		$db = $this->db();
		$tagIds = $db->fetchPairs("
			SELECT tag_id, visible
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", [$contentType, $contentId]);
		if (!$tagIds)
		{
			return;
		}

		$recalc = [];
		foreach ($tagIds AS $id => $visible)
		{
			if ($visible)
			{
				$recalc[] = $id;
			}
		}

		$db->beginTransaction();

		$db->query("
			DELETE FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", [$contentType, $contentId]);

		$this->recalculateTagUsageCache($recalc);

		$db->commit();
	}

	public function getContentTagCache($contentType, $contentId)
	{
		$tags = $this->db()->fetchAll("
			SELECT t.*
			FROM xf_tag_content AS tc
			INNER JOIN xf_tag AS t ON (tc.tag_id = t.tag_id)
			WHERE tc.content_type = ?
				AND tc.content_id = ?
			ORDER BY t.tag
		", [$contentType, $contentId]);
		$cache = [];
		foreach ($tags AS $tag)
		{
			$cache[$tag['tag_id']] = [
				'tag' => $tag['tag'],
				'tag_url' => $tag['tag_url'],
			];
		}

		return $cache;
	}

	public function rebuildContentTagCache($contentType, $contentId)
	{
		$handler = $this->getTagHandler($contentType, false);
		if (!$handler)
		{
			return false;
		}

		$content = $handler->getContent($contentId);
		if (!$content)
		{
			return false;
		}

		$cache = $this->getContentTagCache($contentType, $contentId);
		$handler->updateContentTagCache($content, $cache);

		return true;
	}

	public function updateContentVisibility($contentType, $contentId, $visibility)
	{
		$db = $this->db();
		$tagIds = $db->fetchAll("
			SELECT tag_id, tag_content_id, visible
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", [$contentType, $contentId]);
		if (!$tagIds)
		{
			return;
		}

		$newVisibleSql = $visibility ? 1 : 0;
		$update = [];
		$recalc = [];
		foreach ($tagIds AS $tag)
		{
			if ($newVisibleSql != $tag['visible'])
			{
				$update[] = $tag['tag_content_id'];
				$recalc[] = $tag['tag_id'];
			}
		}
		if (!$update)
		{
			return;
		}

		$db->beginTransaction();

		$db->update(
			'xf_tag_content',
			['visible' => $newVisibleSql],
			'tag_content_id IN (' . $db->quote($update) . ')'
		);
		$this->recalculateTagUsageCache($recalc);

		$db->commit();
	}

	public function recalculateTagUsageCache($tagIds)
	{
		if (!$tagIds)
		{
			return;
		}

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

		$db = $this->db();

		$tags = $db->fetchPairs("
			SELECT tag_id, permanent
			FROM xf_tag
			WHERE tag_id IN (" . $db->quote($tagIds) . ")
		");
		$results = $db->fetchAllKeyed("
			SELECT tag_id,
				COUNT(IF(visible, 1, NULL)) AS use_count,
				COUNT(*) AS raw_use_count,
				MAX(IF(visible, add_date, 0)) AS last_use_date
			FROM xf_tag_content
			WHERE tag_id IN (" . $db->quote($tagIds) . ")
			GROUP BY tag_id
		", 'tag_id');

		$db->beginTransaction();

		foreach ($tags AS $tagId => $permanent)
		{
			$delete = false;

			if (isset($results[$tagId]))
			{
				$result = $results[$tagId];
				if (!$result['use_count'] && !$result['raw_use_count'])
				{
					// this shouldn't actually happen since there shouldn't be a row
					$delete = true;
				}
				else
				{
					$db->update('xf_tag', [
						'use_count' => $result['use_count'],
						'last_use_date' => $result['last_use_date'],
					], 'tag_id = ?', $tagId);
				}
			}
			else
			{
				$delete = true;
			}

			if ($delete)
			{
				if ($permanent)
				{
					$db->update('xf_tag', [
						'use_count' => 0,
						'last_use_date' => 0,
					], 'tag_id = ?', $tagId);
				}
				else
				{
					$db->delete('xf_tag', 'tag_id = ?', $tagId);
				}
			}
		}

		$db->commit();
	}

	public function recalculateTagUsageCacheByContent($contentType, $contentId)
	{
		$tagIds = $this->db()->fetchAllColumn("
			SELECT tag_id
			FROM xf_tag_content
			WHERE content_type = ?
				AND content_id = ?
		", [$contentType, $contentId]);
		$this->recalculateTagUsageCache($tagIds);
	}

	public function getTagSearchResults($tagId, $limit, $visibleOnly = true)
	{
		$limit = max(1, intval($limit));

		$results = $this->db()->query("
			SELECT content_type, content_id
			FROM xf_tag_content
			WHERE tag_id = ?
				" . ($visibleOnly ? "AND visible = 1" : '') . "
			ORDER BY content_date DESC
			LIMIT {$limit}
		", $tagId);
		$output = [];
		while ($result = $results->fetch())
		{
			$type = $result['content_type'];
			$id = $result['content_id'];
			$output["{$type}-{$id}"] = [$type, $id];
		}

		return $output;
	}

	/**
	 * @param int $tagId
	 * @param null|int $userId
	 *
	 * @return TagResultCache
	 */
	public function getTagResultCache($tagId, $userId = null)
	{
		if ($userId === null)
		{
			$userId = \XF::visitor()->user_id;
		}

		$cache = $this->finder(TagResultCacheFinder::class)->where([
			'tag_id' => $tagId,
			'user_id' => $userId,
		])->fetchOne();
		if (!$cache)
		{
			$cache = $this->em->create(TagResultCache::class);
			$cache->tag_id = $tagId;
			$cache->user_id = $userId;
		}

		return $cache;
	}

	public function pruneTagResultsCache($cutOff = null)
	{
		if ($cutOff === null)
		{
			$cutOff = \XF::$time;
		}

		$this->db()->delete('xf_tag_result_cache', 'expiry_date <= ?', $cutOff);
	}

	public function getTagResultSet(array $results)
	{
		return new ResultSet($this, $results);
	}

	public function getResultSetData($type, array $ids, $filterViewable = true, ?array $results = null)
	{
		$handler = $this->getTagHandler($type, false);
		if (!$handler)
		{
			return [];
		}

		$entities = $handler->getContent($ids, true);

		if ($filterViewable)
		{
			$entities = $entities->filter(function ($entity) use ($handler)
			{
				return $handler->canViewContent($entity);
			});
		}

		return $entities;
	}

	/**
	 * @param ResultSet $resultSet
	 * @param array $options
	 *
	 * @return RenderWrapper[]
	 */
	public function wrapResultsForRender(ResultSet $resultSet, array $options = [])
	{
		return $resultSet->getResultsDataCallback(function ($result, $type, $id) use ($options)
		{
			return new RenderWrapper($this->getTagHandler($type), $result, $options);
		});
	}

	/**
	 * @return AbstractHandler[]
	 */
	public function getTagHandlers()
	{
		$handlers = [];

		foreach (\XF::app()->getContentTypeField('tag_handler_class') AS $contentType => $handlerClass)
		{
			if (class_exists($handlerClass))
			{
				$handlerClass = \XF::extendClass($handlerClass);
				$handlers[$contentType] = new $handlerClass($contentType);
			}
		}

		return $handlers;
	}

	/**
	 * @param string $type
	 * @param bool $throw
	 *
	 * @return AbstractHandler|null
	 */
	public function getTagHandler($type, $throw = false)
	{
		$handlerClass = \XF::app()->getContentTypeFieldValue($type, 'tag_handler_class');
		if (!$handlerClass)
		{
			if ($throw)
			{
				throw new \InvalidArgumentException("No tag handler for '$type'");
			}
			return null;
		}

		if (!class_exists($handlerClass))
		{
			if ($throw)
			{
				throw new \InvalidArgumentException("Tag handler for '$type' does not exist: $handlerClass");
			}
			return null;
		}

		$handlerClass = \XF::extendClass($handlerClass);
		return new $handlerClass($type);
	}
}
