<?php

declare(strict_types=1);

/**
 * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

namespace OCA\Mail\IMAP\Sync;

use Horde_Imap_Client;
use Horde_Imap_Client_Base;
use Horde_Imap_Client_Exception;
use Horde_Imap_Client_Exception_Sync;
use Horde_Imap_Client_Ids;
use Horde_Imap_Client_Mailbox;
use OCA\Mail\Exception\MailboxDoesNotSupportModSequencesException;
use OCA\Mail\Exception\UidValidityChangedException;
use OCA\Mail\IMAP\MessageMapper;
use Psr\Log\LoggerInterface;
use function array_merge;
use function OCA\Mail\chunk_uid_sequence;

class Synchronizer {
	/**
	 * This determines how many UIDs we send to IMAP for a check of changed or
	 * vanished messages. The number needs a balance between good performance
	 * (few chunks) and staying below the IMAP command size limits. 15k has
	 * shown to cause IMAP errors for some accounts where the UID list can't be
	 * compressed much by Horde.
	 */
	private const UID_CHUNK_MAX_BYTES = 10000;

	private ?string $requestId = null;
	private ?Response $response = null;

	public function __construct(
		private MessageMapper $messageMapper,
	) {
	}

	/**
	 * @return Response
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_Sync
	 * @throws UidValidityChangedException
	 * @throws MailboxDoesNotSupportModSequencesException
	 */
	public function sync(Horde_Imap_Client_Base $imapClient,
		Request $request,
		string $userId,
		bool $hasQresync, // TODO: query client directly, but could be unsafe because login has to happen prior
		LoggerInterface $logger,
		int $criteria = Horde_Imap_Client::SYNC_NEWMSGSUIDS | Horde_Imap_Client::SYNC_FLAGSUIDS | Horde_Imap_Client::SYNC_VANISHEDUIDS): Response {
		// Return cached response from last full sync when QRESYNC is enabled
		if ($hasQresync && $this->response !== null && $request->getId() === $this->requestId) {
			$logger->debug('Reusing cached sync response');
			return $this->response;
		}

		$mailbox = new Horde_Imap_Client_Mailbox($request->getMailbox());
		try {
			// Do a full sync and cache the response when QRESYNC is enabled
			[$newUids, $changedUids, $vanishedUids] = match ($hasQresync) {
				true => $this->doCombinedSync($imapClient, $mailbox, $request, $logger),
				false => $this->doSplitSync($imapClient, $mailbox, $request, $criteria, $logger),
			};
		} catch (Horde_Imap_Client_Exception_Sync $e) {
			$logger->info('UID validity changed');
			if ($e->getCode() === Horde_Imap_Client_Exception_Sync::UIDVALIDITY_CHANGED) {
				throw new UidValidityChangedException();
			}
			throw $e;
		} catch (Horde_Imap_Client_Exception $e) {
			if ($e->getCode() === Horde_Imap_Client_Exception::MBOXNOMODSEQ) {
				throw new MailboxDoesNotSupportModSequencesException($e->getMessage(), $e->getCode(), $e);
			}
			throw $e;
		}

		$logger->debug('Fetching new and changed messages after sync');
		$newMessages = $this->messageMapper->findByIds($imapClient, $request->getMailbox(), $newUids, $userId);
		$nNew = count($newMessages);
		$logger->debug("Found {$nNew} new messages");
		$changedMessages = $this->messageMapper->findByIds($imapClient, $request->getMailbox(), $changedUids, $userId);
		$nChanged = count($changedMessages);
		$logger->debug("Found {$nChanged} changed messages");
		$vanishedMessageUids = $vanishedUids;

		$this->requestId = $request->getId();
		$this->response = new Response($newMessages, $changedMessages, $vanishedMessageUids, null);
		return $this->response;
	}

	/**
	 * @psalm-return list{Horde_Imap_Client_Ids, Horde_Imap_Client_Ids, int[]} [$newUids, $changedUids, $vanishedUids]
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_Sync
	 */
	private function doCombinedSync(Horde_Imap_Client_Base $imapClient,
		Horde_Imap_Client_Mailbox $mailbox,
		Request $request,
		LoggerInterface $logger): array {
		$logger->debug('Performing a combined sync');

		$syncData = $imapClient->sync($mailbox, $request->getToken(), [
			'criteria' => Horde_Imap_Client::SYNC_ALL,
		]);

		$logger->debug('Combined sync finished');

		return [
			$syncData->newmsgsuids,
			$syncData->flagsuids,
			$syncData->vanisheduids->ids,
		];
	}

	/**
	 * @psalm-return list{Horde_Imap_Client_Ids|array, Horde_Imap_Client_Ids|array, int[]} [$newUids, $changedUids, $vanishedUids]
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_Sync
	 */
	private function doSplitSync(Horde_Imap_Client_Base $imapClient,
		Horde_Imap_Client_Mailbox $mailbox,
		Request $request,
		int $criteria,
		LoggerInterface $logger): array {
		$logger->debug('Performing a split sync');

		if ($criteria & Horde_Imap_Client::SYNC_NEWMSGSUIDS) {
			$newUids = $this->getNewMessageUids($imapClient, $mailbox, $request);
		} else {
			$newUids = [];
		}
		if ($criteria & Horde_Imap_Client::SYNC_FLAGSUIDS) {
			$changedUids = $this->getChangedMessageUids($imapClient, $mailbox, $request);
		} else {
			$changedUids = [];
		}
		if ($criteria & Horde_Imap_Client::SYNC_VANISHEDUIDS) {
			$vanishedUids = $this->getVanishedMessageUids($imapClient, $mailbox, $request);
		} else {
			$vanishedUids = [];
		}

		return [$newUids, $changedUids, $vanishedUids];
	}

	/**
	 * @param Horde_Imap_Client_Base $imapClient
	 * @param Horde_Imap_Client_Mailbox $mailbox
	 * @param Request $request
	 *
	 * @return Horde_Imap_Client_Ids
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_Sync
	 */
	private function getNewMessageUids(Horde_Imap_Client_Base $imapClient, Horde_Imap_Client_Mailbox $mailbox, Request $request): Horde_Imap_Client_Ids {
		return $imapClient->sync($mailbox, $request->getToken(), [
			'criteria' => Horde_Imap_Client::SYNC_NEWMSGSUIDS,
		])->newmsgsuids;
	}

	/**
	 * @param Horde_Imap_Client_Base $imapClient
	 * @param Horde_Imap_Client_Mailbox $mailbox
	 * @param Request $request
	 *
	 * @return Horde_Imap_Client_Ids
	 */
	private function getChangedMessageUids(Horde_Imap_Client_Base $imapClient, Horde_Imap_Client_Mailbox $mailbox, Request $request): Horde_Imap_Client_Ids {
		// Without QRESYNC we need to specify the known ids and in order to avoid
		// overly long IMAP commands they have to be chunked.
		$combined = new Horde_Imap_Client_Ids();
		foreach (chunk_uid_sequence($request->getUids(), self::UID_CHUNK_MAX_BYTES) as $chunk) {
			$syncResult = $imapClient->sync($mailbox, $request->getToken(), [
				'criteria' => Horde_Imap_Client::SYNC_FLAGSUIDS,
				'ids' => $chunk,
			]);
			$combined->add($syncResult->flagsuids);
		}
		return $combined;
	}

	/**
	 * @param Horde_Imap_Client_Base $imapClient
	 * @param Horde_Imap_Client_Mailbox $mailbox
	 * @param Request $request
	 *
	 * @return array
	 */
	private function getVanishedMessageUids(Horde_Imap_Client_Base $imapClient, Horde_Imap_Client_Mailbox $mailbox, Request $request): array {
		// Without QRESYNC we need to specify the known ids and in order to avoid
		// overly long IMAP commands they have to be chunked.
		return array_merge(
			[], // for php<7.4 https://www.php.net/manual/en/function.array-merge.php
			...array_map(
				static fn (Horde_Imap_Client_Ids $uids) => $imapClient->sync($mailbox, $request->getToken(), [
					'criteria' => Horde_Imap_Client::SYNC_VANISHEDUIDS,
					'ids' => $uids,
				])->vanisheduids->ids,
				chunk_uid_sequence($request->getUids(), self::UID_CHUNK_MAX_BYTES)
			)
		);
	}
}
