<?php
declare(strict_types=1);

class FreshRSS_Category extends Minz_Model {
	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;

	/**
	 * Normal
	 */
	public const KIND_NORMAL = 0;

	/**
	 * Category tracking a third-party Dynamic OPML
	 */
	public const KIND_DYNAMIC_OPML = 2;

	private int $id = 0;
	private int $kind = 0;
	private string $name;
	private int $nbFeeds = -1;
	/** Number of unread articles in feeds with visibility FreshRSS_Feed::PRIORITY_FEED */
	private int $nbNotRead = -1;
	/** @var array<int,FreshRSS_Feed>|null where the key is the feed ID */
	private ?array $feeds = null;
	private bool|int $hasFeedsWithError = false;
	private int $lastUpdate = 0;
	private bool $error = false;

	/**
	 * @param array<FreshRSS_Feed>|null $feeds
	 */
	public function __construct(string $name = '', int $id = 0, ?array $feeds = null) {
		$this->_id($id);
		$this->_name($name);
		if ($feeds !== null) {
			$this->_feeds($feeds);
			$this->nbFeeds = 0;
			$this->nbNotRead = 0;
			foreach ($feeds as $feed) {
				$feed->_category($this);
				$this->nbFeeds++;
				if ($feed->priority() > FreshRSS_Feed::PRIORITY_HIDDEN) {
					$this->nbNotRead += $feed->nbNotRead();
					$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
				}
			}
		}
	}

	public function id(): int {
		return $this->id;
	}
	public function kind(): int {
		return $this->kind;
	}
	/** @return string HTML-encoded name of the category */
	public function name(): string {
		return $this->name;
	}
	public function lastUpdate(): int {
		return $this->lastUpdate;
	}

	/**
	 * @param int|numeric-string $value
	 * 32-bit systems provide a string and will fail in year 2038
	 */
	public function _lastUpdate(int|string $value): void {
		$this->lastUpdate = (int)$value;
	}

	public function inError(): bool {
		return $this->error;
	}

	public function _error(bool|int $value): void {
		$this->error = (bool)$value;
	}
	public function isDefault(): bool {
		return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID;
	}
	public function nbFeeds(): int {
		if ($this->nbFeeds < 0) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$this->nbFeeds = $catDAO->countFeed($this->id());
		}

		return $this->nbFeeds;
	}

	/**
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function nbNotRead(int $minPriority = FreshRSS_Feed::PRIORITY_FEED): int {
		if ($this->nbNotRead > 0 && $minPriority === FreshRSS_Feed::PRIORITY_FEED) {
			return $this->nbNotRead;
		}
		if ($this->feeds === null) {
			$catDAO = FreshRSS_Factory::createCategoryDao();
			$nb = $catDAO->countNotRead($this->id(), $minPriority);
			if ($minPriority === FreshRSS_Feed::PRIORITY_FEED) {
				$this->nbNotRead = $nb;
			}
			return $nb;
		}
		$nb = 0;
		foreach ($this->feeds as $feed) {
			if ($feed->priority() >= $minPriority) {
				$nb += $feed->nbNotRead();
			}
		}
		return $nb;
	}

	/** @return array<int,mixed> */
	public function curlOptions(): array {
		return [];	// TODO (e.g., credentials for Dynamic OPML)
	}

	public function showUnreadCount(): bool {
		return $this->attributeBoolean('show_unread_count') ??
			(FreshRSS_Context::userConf()->show_unread_count === 'all');
	}

	/**
	 * @return array<int,FreshRSS_Feed> where the key is the feed ID
	 * @throws Minz_ConfigurationNamespaceException
	 * @throws Minz_PDOConnectionException
	 */
	public function feeds(): array {
		if ($this->feeds === null) {
			$feedDAO = FreshRSS_Factory::createFeedDao();
			$this->feeds = $feedDAO->listByCategory($this->id());
			$this->nbFeeds = 0;
			$this->nbNotRead = 0;
			foreach ($this->feeds as $feed) {
				$this->nbFeeds++;
				if ($feed->priority() > FreshRSS_Feed::PRIORITY_HIDDEN) {
					$this->nbNotRead += $feed->nbNotRead();
					$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
				}
			}
			$this->sortFeeds();
		}
		return $this->feeds ?? [];
	}

	public function hasFeedsWithError(): bool {
		return (bool)($this->hasFeedsWithError);
	}

	public function _id(int $id): void {
		$this->id = $id;
		if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
			$this->name = _t('gen.short.default_category');
		}
	}

	public function _kind(int $kind): void {
		$this->kind = $kind;
	}

	public function _name(string $value): void {
		if ($this->id !== FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
			$this->name = mb_strcut(trim($value), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
		}
	}

	/** @param array<FreshRSS_Feed>|FreshRSS_Feed $values */
	public function _feeds(array|FreshRSS_Feed $values): void {
		if (!is_array($values)) {
			$values = [$values];
		}
		$this->feeds = array_values($values);
		$this->sortFeeds();
	}

	public function defaultSort(): ?string {
		return $this->attributeString('defaultSort');
	}
	public function defaultOrder(): ?string {
		return $this->attributeString('defaultOrder');
	}

	/**
	 * To manually add feeds to this category (not committing to database).
	 */
	public function addFeed(FreshRSS_Feed $feed): void {
		if ($this->feeds === null) {
			$this->feeds = [];
		}
		$feed->_category($this);
		if ($feed->id() === 0) {
			// Feeds created on a dry run do not have an ID
			$this->feeds[] = $feed;
		} else {
			$this->feeds[$feed->id()] = $feed;
		}
		$this->sortFeeds();
	}

	/**
	 * @throws FreshRSS_Context_Exception
	 */
	public function cacheFilename(string $url): string {
		$simplePie = new FreshRSS_SimplePieCustom($this->attributes(), $this->curlOptions());
		$filename = $simplePie->get_cache_filename($url);
		return CACHE_PATH . '/' . $filename . '.opml.xml';
	}

	public function refreshDynamicOpml(): bool {
		$url = $this->attributeString('opml_url');
		if ($url == null) {
			return false;
		}
		$ok = true;
		$cachePath = $this->cacheFilename($url);
		$opml = FreshRSS_http_Util::httpGet($url, $cachePath, 'opml', $this->attributes(), $this->curlOptions())['body'];
		if ($opml == '') {
			Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' .
				\SimplePie\Misc::url_remove_credentials($url));
			$ok = false;
		} else {
			$dryRunCategory = new FreshRSS_Category();
			$importService = new FreshRSS_Import_Service();
			$importService->importOpml($opml, $dryRunCategory, true);
			if ($importService->lastStatus()) {
				$feedDAO = FreshRSS_Factory::createFeedDao();
				$limits = FreshRSS_Context::systemConf()->limits;
				$maxFeeds = (int)($limits['max_feeds'] ?? 0);
				$nbFeeds = $maxFeeds > 0 ? $feedDAO->count() : 0;

				/** @var array<string,FreshRSS_Feed> */
				$dryRunFeeds = [];
				foreach ($dryRunCategory->feeds() as $dryRunFeed) {
					$dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed;
				}

				/** @var array<string,FreshRSS_Feed> */
				$existingFeeds = [];
				foreach ($this->feeds() as $existingFeed) {
					$existingFeeds[$existingFeed->url()] = $existingFeed;
					if (empty($dryRunFeeds[$existingFeed->url()])) {
						// The feed does not exist in the new dynamic OPML, so mute (disable) that feed
						$existingFeed->_mute(true);
						$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
							'ttl' => $existingFeed->ttl(true),
						]) !== false);
					}
				}

				foreach ($dryRunCategory->feeds() as $dryRunFeed) {
					if (empty($existingFeeds[$dryRunFeed->url()])) {
						// The feed does not exist in the current category, so add that feed
						if ($maxFeeds > 0 && $nbFeeds >= $maxFeeds) {
							// Respect the per-user maximum number of feeds
							Minz_Log::warning(_t('feedback.sub.feed.over_max', $maxFeeds) .
								' (dynamic OPML category ' . $this->id() . ')');
							$ok = false;
							break;
						}
						$dryRunFeed->_category($this);
						if ($feedDAO->addFeedObject($dryRunFeed) === false) {
							$ok = false;
						} else {
							$nbFeeds++;
						}
						$existingFeeds[$dryRunFeed->url()] = $dryRunFeed;
					} else {
						$existingFeed = $existingFeeds[$dryRunFeed->url()];
						if ($existingFeed->mute()) {
							// The feed already exists in the current category but was muted (disabled), so unmute (enable) again
							$existingFeed->_mute(false);
							$ok &= ($feedDAO->updateFeed($existingFeed->id(), [
								'ttl' => $existingFeed->ttl(true),
							]) !== false);
						}
					}
				}
			} else {
				$ok = false;
				Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' .
					\SimplePie\Misc::url_remove_credentials($url));
			}
		}

		$catDAO = FreshRSS_Factory::createCategoryDao();
		if ($ok) {
			$catDAO->updateLastUpdate($this->id());
		} else {
			$catDAO->updateLastError($this->id());
		}

		return (bool)$ok;
	}

	private function sortFeeds(): void {
		if ($this->feeds === null) {
			return;
		}
		uasort($this->feeds, static fn(FreshRSS_Feed $a, FreshRSS_Feed $b): int => FreshRSS_Context::localeCompare($a->name(), $b->name()));
	}

	/**
	 * Access cached feed
	 * @param array<FreshRSS_Category> $categories
	 */
	public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
		foreach ($categories as $category) {
			foreach ($category->feeds() as $feed) {
				if ($feed->id() === $feed_id) {
					$feed->_category($category);	// Should already be done; just to be safe
					return $feed;
				}
			}
		}
		return null;
	}

	/**
	 * Access cached feeds
	 * @param array<FreshRSS_Category> $categories
	 * @return array<int,FreshRSS_Feed> where the key is the feed ID
	 */
	public static function findFeeds(array $categories): array {
		$result = [];
		foreach ($categories as $category) {
			foreach ($category->feeds() as $feed) {
				$result[$feed->id()] = $feed;
			}
		}
		return $result;
	}

	/**
	 * @param array<FreshRSS_Category> $categories
	 */
	public static function countUnread(array $categories, int $minPriority = FreshRSS_Feed::PRIORITY_FEED): int {
		$n = 0;
		foreach ($categories as $category) {
			$n += $category->nbNotRead($minPriority);
		}
		return $n;
	}
}
