<?php

declare(strict_types=1);

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

namespace OCA\Mail\IMAP;

use Horde_Imap_Client;
use Horde_Imap_Client_Base;
use Horde_Imap_Client_Data_Fetch;
use Horde_Imap_Client_Exception;
use Horde_Imap_Client_Exception_NoSupportExtension;
use Horde_Imap_Client_Fetch_Query;
use Horde_Imap_Client_Ids;
use Horde_Imap_Client_Search_Query;
use Horde_Imap_Client_Socket;
use Horde_Mime_Exception;
use Horde_Mime_Headers;
use Horde_Mime_Headers_ContentParam_ContentDisposition;
use Horde_Mime_Headers_ContentParam_ContentType;
use Horde_Mime_Headers_ContentTransferEncoding;
use Horde_Mime_Part;
use Html2Text\Html2Text;
use OCA\Mail\Attachment;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\Exception\SmimeDecryptException;
use OCA\Mail\Html\Parser;
use OCA\Mail\IMAP\Charset\Converter;
use OCA\Mail\Model\IMAPMessage;
use OCA\Mail\Service\SmimeService;
use OCA\Mail\Support\PerformanceLoggerTask;
use OCP\AppFramework\Db\DoesNotExistException;
use Psr\Log\LoggerInterface;
use function array_filter;
use function array_map;
use function count;
use function fclose;
use function in_array;
use function is_array;
use function iterator_to_array;
use function max;
use function min;
use function OCA\Mail\array_flat_map;
use function OCA\Mail\chunk_uid_sequence;
use function sprintf;

class MessageMapper {
	public function __construct(
		private LoggerInterface $logger,
		private SMimeService $smimeService,
		private ImapMessageFetcherFactory $imapMessageFactory,
		private Converter $converter,
	) {
	}

	/**
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Mime_Exception
	 * @throws ServiceException
	 * @throws SmimeDecryptException
	 */
	public function find(Horde_Imap_Client_Base $client,
		string $mailbox,
		int $id,
		string $userId,
		bool $loadBody = false): IMAPMessage {
		$result = $this->findByIds($client, $mailbox, new Horde_Imap_Client_Ids([$id]), $userId, $loadBody, true);

		if (count($result) === 0) {
			throw new DoesNotExistException('Message does not exist');
		}

		return $result[0];
	}

	/**
	 * @return array{
	 *     messages: list<IMAPMessage>,
	 *     all: bool,
	 *     total: int,
	 * }
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_NoSupportExtension
	 * @throws Horde_Mime_Exception
	 * @throws ServiceException
	 */
	public function findAll(Horde_Imap_Client_Socket $client,
		string $mailbox,
		int $maxResults,
		int $highestKnownUid,
		LoggerInterface $logger,
		PerformanceLoggerTask $perf,
		string $userId): array {
		/**
		 * To prevent memory exhaustion, we don't want to just ask for a list of
		 * all UIDs and limit them client-side. Instead, we can (hopefully
		 * efficiently) query the min and max UID as well as the number of
		 * messages. Based on that we assume that UIDs are somewhat distributed
		 * equally and build a page to fetch.
		 *
		 * This logic might return fewer or more results than $maxResults
		 */

		$metaResults = $client->search(
			$mailbox,
			null,
			[
				'results' => [
					Horde_Imap_Client::SEARCH_RESULTS_MIN,
					Horde_Imap_Client::SEARCH_RESULTS_MAX,
					Horde_Imap_Client::SEARCH_RESULTS_COUNT,
				]
			]
		);
		$perf->step('mailbox meta search');
		$min = (int)$metaResults['min'];
		$total = (int)$metaResults['count'];

		if ($total === 0) {
			$perf->step('No data in mailbox');
			// Nothing to fetch for this mailbox
			return [
				'messages' => [],
				'all' => true,
				'total' => $total,
			];
		}

		// This can happen for iCloud
		if ($metaResults['max'] === null) {
			$uidnext = $client->status(
				$mailbox
			);
			$perf->step('mailbox meta search for UIDNEXT');
			$max = (int)$uidnext['uidnext'] - 1; // We need to subtract one for the last used UID
		} else {
			$max = ((int)$metaResults['max']);
		}
		unset($metaResults);

		// The inclusive range of UIDs
		$totalRange = $max - $min + 1;
		// Here we assume somewhat equally distributed UIDs
		// +1 is added to fetch all messages with the rare case of strictly
		// continuous UIDs and fractions
		$estimatedPageSize = (int)((float)($totalRange / $total) * (float)$maxResults) + 1;
		// Determine min UID to fetch, but don't exceed the known maximum
		$lower = max(
			$min,
			$highestKnownUid + 1
		);
		// Determine max UID to fetch, but don't exceed the known maximum
		$upper = min(
			$max,
			$lower + $estimatedPageSize,
			$lower + 1_000_000, // Somewhat sensible number of UIDs that fit into memory (Horde_Imap_ClientId bloat)
		);
		if ($lower > $upper) {
			$logger->debug("Range for findAll did not find any (not already known) messages and all messages of mailbox $mailbox have been fetched.");
			return [
				'messages' => [],
				'all' => true,
				'total' => 0,
			];
		}

		$idsToFetch = new Horde_Imap_Client_Ids("$lower:$upper");
		$actualPageSize = $this->getPageSize($client, $mailbox, $idsToFetch);
		$logger->debug("Built range for findAll: min=$min max=$max total=$total totalRange=$totalRange estimatedPageSize=$estimatedPageSize actualPageSize=$actualPageSize lower=$lower upper=$upper highestKnownUid=$highestKnownUid");
		while ($actualPageSize > $maxResults) {
			$logger->debug("Range for findAll matches too many messages: min=$min max=$max total=$total estimatedPageSize=$estimatedPageSize actualPageSize=$actualPageSize");

			$estimatedPageSize = (int)((float)$estimatedPageSize / 2.0);

			$upper = min(
				$max,
				$lower + $estimatedPageSize,
				$lower + 1_000_000, // Somewhat sensible number of UIDs that fit into memory (Horde_Imap_ClientId bloat)
			);
			$idsToFetch = new Horde_Imap_Client_Ids("$lower:$upper");
			$actualPageSize = $this->getPageSize($client, $mailbox, $idsToFetch);
		}

		$query = new Horde_Imap_Client_Fetch_Query();
		$query->uid();
		$fetchResult = $client->fetch(
			$mailbox,
			$query,
			[
				'ids' => $idsToFetch
			]
		);
		$perf->step('fetch UIDs');
		if (count($fetchResult) === 0) {
			/*
			 * There were no messages in this range.
			 * This means we should try again until there is a
			 * page that actually returns at least one message
			 *
			 * We take $upper as the lowest known UID as we just found out that
			 * there is nothing to fetch in $highestKnownUid:$upper
			 */
			$logger->debug('Range for findAll did not find any messages. Trying again with a succeeding range');
			// Clean up some unused variables before recursion
			unset($fetchResult, $idsToFetch, $query);
			$perf->step('free memory before recursion');
			return $this->findAll($client, $mailbox, $maxResults, $upper, $logger, $perf, $userId);
		}
		$uidCandidates = array_filter(
			array_map(
				static fn (Horde_Imap_Client_Data_Fetch $data) => $data->getUid(),
				iterator_to_array($fetchResult)
			),

			static fn (int $uid)
				// Don't load the ones we already know
				=> $uid > $highestKnownUid
		);
		$uidsToFetch = array_slice(
			$uidCandidates,
			0,
			$maxResults
		);
		$perf->step('calculate UIDs to fetch');
		$highestUidToFetch = $uidsToFetch[count($uidsToFetch) - 1];
		$logger->debug(sprintf("Range for findAll min=$min max=$max found %d messages, %d left after filtering. Highest UID to fetch is %d", count($uidCandidates), count($uidsToFetch), $highestUidToFetch));
		$fetchRange = min($uidsToFetch) . ':' . max($uidsToFetch);
		if ($highestUidToFetch === $max) {
			$logger->debug("All messages of mailbox $mailbox have been fetched");
		} else {
			$logger->debug("Mailbox $mailbox has more messages to fetch: $fetchRange");
		}
		$messages = $this->findByIds(
			$client,
			$mailbox,
			new Horde_Imap_Client_Ids($fetchRange),
			$userId,
		);
		$perf->step('find IMAP messages by UID');
		return [
			'messages' => $messages,
			'all' => $highestUidToFetch === $max,
			'total' => $total,
		];
	}

	/**
	 * @param int[]|Horde_Imap_Client_Ids $ids
	 * @return list<IMAPMessage>
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Mime_Exception
	 * @throws ServiceException
	 * @throws SmimeDecryptException
	 */
	public function findByIds(Horde_Imap_Client_Base $client,
		string $mailbox,
		array|Horde_Imap_Client_Ids $ids,
		string $userId,
		bool $loadBody = false,
		bool $runPhishingCheck = false): array {
		$query = new Horde_Imap_Client_Fetch_Query();
		$query->envelope();
		$query->flags();
		$query->uid();
		$query->imapDate();
		$query->headerText(
			[
				'cache' => true,
				'peek' => true,
			]
		);

		if (is_array($ids)) {
			// Chunk to prevent overly long IMAP commands
			/** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */
			$fetchResults = array_flat_map(fn ($ids) => iterator_to_array($client->fetch($mailbox, $query, [
				'ids' => $ids,
			]), false), chunk_uid_sequence($ids, 10000));
		} else {
			/** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */
			$fetchResults = iterator_to_array($client->fetch($mailbox, $query, [
				'ids' => $ids,
			]), false);
		}

		$fetchResults = array_values(array_filter($fetchResults, static fn (Horde_Imap_Client_Data_Fetch $fetchResult) => $fetchResult->exists(Horde_Imap_Client::FETCH_ENVELOPE) && $fetchResult->getUid() !== null));

		if ($fetchResults === []) {
			$nIds = count($ids);
			$this->logger->debug("findByIds in $mailbox got $nIds UIDs but found none");
		} else {
			$minFetched = $fetchResults[0]->getUid();
			$maxFetched = $fetchResults[count($fetchResults) - 1]->getUid();
			if ($ids instanceof Horde_Imap_Client_Ids) {
				$range = $ids->range_string;
			} else {
				$range = 'literals';
			}
			$nIds = count($ids);
			$nFetched = count($fetchResults);
			$this->logger->debug("findByIds in $mailbox got $nIds UIDs ($range) and found $nFetched. minFetched=$minFetched maxFetched=$maxFetched");
		}

		return array_map(fn (Horde_Imap_Client_Data_Fetch $fetchResult) => $this->imapMessageFactory
			->build(
				$fetchResult->getUid(),
				$mailbox,
				$client,
				$userId,
			)
			->withBody($loadBody)
			->withPhishingCheck($runPhishingCheck)
			->fetchMessage($fetchResult), $fetchResults);
	}

	/**
	 * @param Horde_Imap_Client_Base $client
	 * @param string $sourceFolderId
	 * @param int $messageId
	 * @param string $destFolderId
	 * @return ?int the new UID (or null if couldn't be determined)
	 */
	public function move(Horde_Imap_Client_Base $client,
		string $sourceFolderId,
		int $messageId,
		string $destFolderId): ?int {
		try {
			$mapping = $client->copy($sourceFolderId, $destFolderId,
				[
					'ids' => new Horde_Imap_Client_Ids($messageId),
					'move' => true,
					'force_map' => true,
				]);

			// There won't be a mapping if the IMAP server does not support UIDPLUS and the message
			// has no Message-ID header. This is extremely rare, but this case needs to be handled.
			return $mapping[$messageId] ?? null;
		} catch (Horde_Imap_Client_Exception $e) {
			$this->logger->debug($e->getMessage(),
				[
					'exception' => $e,
				]
			);

			throw new ServiceException(
				"Could not move message $$messageId from $sourceFolderId to $destFolderId",
				0,
				$e
			);
		}
	}

	public function markAllRead(Horde_Imap_Client_Base $client,
		string $mailbox): void {
		$client->store($mailbox, [
			'add' => [
				[Horde_Imap_Client::FLAG_SEEN],
			],
		]);
	}

	/**
	 * @throws ServiceException
	 */
	public function expunge(Horde_Imap_Client_Base $client,
		string $mailbox,
		int $id): void {
		try {
			$client->expunge(
				$mailbox,
				[
					'ids' => new Horde_Imap_Client_Ids([$id]),
					'delete' => true,
				]);
		} catch (Horde_Imap_Client_Exception $e) {
			$this->logger->debug($e->getMessage(),
				[
					'exception' => $e,
				]
			);

			throw new ServiceException("Could not expunge message $id", 0, $e);
		}

		$this->logger->info("Message expunged: $id from mailbox $mailbox");
	}

	/**
	 * @throws Horde_Imap_Client_Exception
	 */
	public function save(Horde_Imap_Client_Socket $client,
		Mailbox $mailbox,
		string $mail,
		array $flags = []): int {
		$flags = array_merge([
			Horde_Imap_Client::FLAG_SEEN,
		], $flags);

		$uids = $client->append(
			$mailbox->getName(),
			[
				[
					'data' => $mail,
					'flags' => $flags,
				]
			]
		);

		return (int)$uids->current();
	}

	/**
	 * @throws Horde_Imap_Client_Exception
	 */
	public function addFlag(Horde_Imap_Client_Socket $client,
		Mailbox $mailbox,
		array $uids,
		string $flag): void {
		$client->store(
			$mailbox->getName(),
			[
				'ids' => new Horde_Imap_Client_Ids($uids),
				'add' => [$flag],
			]
		);
	}

	/**
	 * @throws Horde_Imap_Client_Exception
	 */
	public function removeFlag(Horde_Imap_Client_Socket $client,
		Mailbox $mailbox,
		array $uids,
		string $flag): void {
		$client->store(
			$mailbox->getName(),
			[
				'ids' => new Horde_Imap_Client_Ids($uids),
				'remove' => [$flag],
			]
		);
	}

	/**
	 * @param Horde_Imap_Client_Socket $client
	 * @param Mailbox $mailbox
	 * @param string $flag
	 * @return int[]
	 *
	 * @throws Horde_Imap_Client_Exception
	 */
	public function getFlagged(Horde_Imap_Client_Socket $client,
		Mailbox $mailbox,
		string $flag): array {
		$query = new Horde_Imap_Client_Search_Query();
		$query->flag($flag, true);
		$messages = $client->search($mailbox->getName(), $query);
		return $messages['match']->ids ?? [];
	}

	/**
	 * @param Horde_Imap_Client_Socket $client
	 * @param string $mailbox
	 * @param int $uid
	 * @param string $userId
	 * @param bool $decrypt
	 * @return string|null
	 *
	 * @throws ServiceException
	 */
	public function getFullText(Horde_Imap_Client_Socket $client,
		string $mailbox,
		int $uid,
		string $userId,
		bool $decrypt = true): ?string {
		$query = new Horde_Imap_Client_Fetch_Query();

		if ($decrypt) {
			$this->smimeService->addDecryptQueries($query);
		} else {
			$query->fullText([ 'peek' => true ]);
		}

		try {
			$result = $client->fetch($mailbox, $query, [
				'ids' => new Horde_Imap_Client_Ids($uid),
			]);
		} catch (Horde_Imap_Client_Exception $e) {
			throw new ServiceException(
				'Could not fetch message source: ' . $e->getMessage(),
				$e->getCode(),
				$e
			);
		}

		if (($message = $result->first()) === null) {
			return null;
		}

		if ($decrypt) {
			return $this->smimeService->decryptDataFetch($message, $userId)->getDecryptedMessage();
		}

		return $message->getFullMsg();
	}

	/**
	 * @param Horde_Imap_Client_Socket $client
	 * @param string $mailbox
	 * @param int $uid
	 * @param string $userId
	 * @return string|null
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_NoSupportExtension
	 * @throws Horde_Mime_Exception
	 * @throws ServiceException
	 */
	public function getHtmlBody(Horde_Imap_Client_Socket $client,
		string $mailbox,
		int $uid,
		string $userId): ?string {
		$messageQuery = new Horde_Imap_Client_Fetch_Query();
		$messageQuery->envelope();
		$messageQuery->structure();
		$this->smimeService->addEncryptionCheckQueries($messageQuery, true);

		$result = $client->fetch($mailbox, $messageQuery, [
			'ids' => new Horde_Imap_Client_Ids([$uid]),
		]);

		if (($message = $result->first()) === null) {
			throw new DoesNotExistException('Message does not exist');
		}

		$structure = $message->getStructure();

		// Handle S/MIME encrypted message
		if ($this->smimeService->isEncrypted($message)) {
			// Encrypted messages have to be fully fetched in order to analyze the structure because
			// it is hidden (obviously).
			$fullText = $this->getFullText($client, $mailbox, $uid, $userId);

			// Force mime parsing as decrypted S/MIME payload doesn't have to contain a MIME header
			$mimePart = Horde_Mime_Part::parseMessage($fullText, ['forcemime' => true ]);
			$htmlPartId = $mimePart->findBody('html');
			if (!isset($mimePart[$htmlPartId])) {
				return null;
			}

			return $mimePart[$htmlPartId];
		}

		$htmlPartId = $structure->findBody('html');
		if ($htmlPartId === null) {
			// No HTML part
			return null;
		}
		$partsQuery = $this->buildAttachmentsPartsQuery($structure, [$htmlPartId]);

		$parts = $client->fetch($mailbox, $partsQuery, [
			'ids' => new Horde_Imap_Client_Ids([$uid]),
		]);

		foreach ($parts as $part) {
			/** @var Horde_Imap_Client_Data_Fetch $part */
			$body = $part->getBodyPart($htmlPartId);
			if ($body !== null) {
				/** @var Horde_Mime_Headers $mimeHeaders */
				$mimeHeaders = $part->getMimeHeader($htmlPartId, Horde_Imap_Client_Data_Fetch::HEADER_PARSE);
				if ($enc = $mimeHeaders['content-transfer-encoding']?->value_single) {
					$structure->setTransferEncoding($enc);
				}
				$structure->setContents($body);
				return $structure->getContents();
			}
		}

		return null;
	}

	/**
	 * @deprecated Use getAttachments() instead
	 *
	 * @param Horde_Imap_Client_Socket $client
	 * @param string $mailbox
	 * @param int $uid
	 * @param string $userId
	 * @param array|null $attachmentIds
	 * @return array
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_NoSupportExtension
	 * @throws Horde_Mime_Exception
	 * @throws ServiceException
	 */
	public function getRawAttachments(Horde_Imap_Client_Socket $client,
		string $mailbox,
		int $uid,
		string $userId,
		?array $attachmentIds = []): array {
		$attachments = $this->getAttachments($client, $mailbox, $uid, $userId, $attachmentIds);
		return array_map(static fn (Attachment $attachment) => $attachment->getContent(), $attachments);
	}

	/**
	 * Get Attachments with size, content and name properties
	 *
	 * @param Horde_Imap_Client_Socket $client
	 * @param string $mailbox
	 * @param integer $uid
	 * @param string $userId
	 * @param array|null $attachmentIds
	 * @return Attachment[]
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws Horde_Imap_Client_Exception_NoSupportExtension
	 * @throws ServiceException
	 * @throws Horde_Mime_Exception
	 */
	public function getAttachments(Horde_Imap_Client_Socket $client,
		string $mailbox,
		int $uid,
		string $userId,
		?array $attachmentIds = []): array {
		$uids = new Horde_Imap_Client_Ids([$uid]);

		$messageQuery = new Horde_Imap_Client_Fetch_Query();
		$messageQuery->structure();
		$this->smimeService->addEncryptionCheckQueries($messageQuery);

		$result = $client->fetch($mailbox, $messageQuery, ['ids' => $uids ]);

		if (($structureResult = $result->first()) === null) {
			throw new DoesNotExistException('Message does not exist');
		}

		$structure = $structureResult->getStructure();
		$messageData = null;

		$isEncrypted = $this->smimeService->isEncrypted($structureResult);
		if ($isEncrypted) {
			$fullTextQuery = new Horde_Imap_Client_Fetch_Query();
			$this->smimeService->addDecryptQueries($fullTextQuery);

			$fullTextParts = $client->fetch($mailbox, $fullTextQuery, ['ids' => $uids ]);
			if (($fullTextResult = $fullTextParts->first()) === null) {
				throw new DoesNotExistException('Message does not exist');
			}
			$decryptedText = $this->smimeService
				->decryptDataFetch($fullTextResult, $userId)
				->getDecryptedMessage();

			// Replace opaque structure with decrypted structure
			$structure = Horde_Mime_Part::parseMessage($decryptedText, [ 'forcemime' => true ]);
		} else {
			$partsQuery = $this->buildAttachmentsPartsQuery($structure, $attachmentIds);
			$parts = $client->fetch($mailbox, $partsQuery, ['ids' => $uids ]);
			if (($messageData = $parts->first()) === null) {
				throw new DoesNotExistException('Message does not exist');
			}
		}

		/** @var Attachment[] $attachments */
		$attachments = [];
		foreach ($structure->partIterator() as $key => $part) {
			/** @var Horde_Mime_Part $part */

			if (!$part->isAttachment()) {
				continue;
			}

			if (!empty($attachmentIds) && !in_array($part->getMimeId(), $attachmentIds, true)) {
				// We are looking for specific parts only and this is not one of them
				continue;
			}

			// Encrypted parts were already decoded and their content can be used directly
			if (!$isEncrypted) {
				$stream = $messageData->getBodyPart($key, true);
				/** @var Horde_Mime_Headers $mimeHeaders */
				$mimeHeaders = $messageData->getMimeHeader($key, Horde_Imap_Client_Data_Fetch::HEADER_PARSE);
				if ($enc = $mimeHeaders['content-transfer-encoding']?->value_single) {
					$part->setTransferEncoding($enc);
				}

				/*
				 * usestream depends on transfer encoding
				 *
				 * Case 1: base64 encoded file
				 * Base64 stream is copied to a new stream and decoded using a convert.base64-decode stream filter.
				 *
				 * Case 2: text file (no transfer encoding)
				 * Existing stream is reused in Horde_Mime_Part.
				 *
				 * To handle both cases:
				 *
				 * 1) Horde_Mime_Part.clearContents to close the internal stream (Horde_Mime_Part._contents)
				 * 2) If $stream is still open, the data was transfer encoded, close it.
				 *
				 * Attachment.fromMimePart uses Horde_Mime_Part.getContents and Horde_Mime_Part.getBytes
				 * and therefore needs an open input stream.
				 */
				$part->setContents($stream, [
					'usestream' => true
				]);
				$attachments[] = Attachment::fromMimePart($part);
				$part->clearContents();
				if (is_resource($stream)) {
					fclose($stream);
				}
			} else {
				$attachments[] = Attachment::fromMimePart($part);
				$part->clearContents();
			}
		}
		return $attachments;
	}

	/**
	 * @param Horde_Imap_Client_Base $client
	 * @param string $mailbox
	 * @param int $messageUid
	 * @param string $attachmentId
	 * @param string $userId
	 * @return Attachment
	 *
	 * @throws DoesNotExistException
	 * @throws Horde_Imap_Client_Exception
	 * @throws ServiceException
	 * @throws Horde_Mime_Exception
	 */
	public function getAttachment(Horde_Imap_Client_Base $client,
		string $mailbox,
		int $messageUid,
		string $attachmentId,
		string $userId): Attachment {
		// TODO: compare logic and merge with getAttachments()

		$query = new Horde_Imap_Client_Fetch_Query();
		$query->bodyPart($attachmentId, [
			'peek' => true,
		]);
		$query->mimeHeader($attachmentId, [
			'peek' => true,
		]);
		$this->smimeService->addEncryptionCheckQueries($query);

		$uids = new Horde_Imap_Client_Ids($messageUid);
		$headers = $client->fetch($mailbox, $query, ['ids' => $uids]);
		if (!isset($headers[$messageUid])) {
			throw new DoesNotExistException('Unable to load the attachment.');
		}

		/** @var Horde_Imap_Client_Data_Fetch $fetch */
		$fetch = $headers[$messageUid];

		/** @var Horde_Mime_Headers $mimeHeaders */
		$mimeHeaders = $fetch->getMimeHeader($attachmentId, Horde_Imap_Client_Data_Fetch::HEADER_PARSE);

		$body = $fetch->getBodyPart($attachmentId);

		$isEncrypted = $this->smimeService->isEncrypted($fetch);
		if ($isEncrypted) {
			$fullTextQuery = new Horde_Imap_Client_Fetch_Query();
			$this->smimeService->addDecryptQueries($fullTextQuery);

			$result = $client->fetch($mailbox, $fullTextQuery, ['ids' => $uids]);
			if (!isset($result[$messageUid])) {
				throw new DoesNotExistException('Unable to load the attachment.');
			}
			$fullTextResult = $result[$messageUid];

			$decryptedText = $this->smimeService
				->decryptDataFetch($fullTextResult, $userId)
				->getDecryptedMessage();
			$decryptedPart = Horde_Mime_Part::parseMessage($decryptedText, [ 'forcemime' => true ]);
			if (!isset($decryptedPart[$attachmentId])) {
				throw new DoesNotExistException('Unable to load the attachment.');
			}

			$attachmentPart = $decryptedPart[$attachmentId];
			$body = $attachmentPart->getContents();
			$mimeHeaders = $attachmentPart->addMimeHeaders();
		}

		$mimePart = new Horde_Mime_Part();

		$contentId = $mimeHeaders['content-id']?->value_single;
		if ($contentId !== null) {
			$mimePart->setContentId($contentId);
		}

		$contentDisposition = $mimeHeaders['content-disposition'];
		if ($contentDisposition instanceof Horde_Mime_Headers_ContentParam_ContentDisposition) {
			$mimePart->setDisposition($contentDisposition->value_single);
			$mimePart->setDispositionParameter('filename', $contentDisposition['filename'] ?? null);
		}

		// Content transfer encoding
		// Decrypted parts are already decoded because they went through the MIME parser
		if (!$isEncrypted && $tmp = $mimeHeaders['content-transfer-encoding']?->value_single) {
			$mimePart->setTransferEncoding($tmp);
		}

		$contentType = $mimeHeaders['content-type'];
		if ($contentType instanceof Horde_Mime_Headers_ContentParam_ContentType) {
			if (str_contains($contentType->value_single, 'text/calendar')) {
				$mimePart->setType('text/calendar');
				$mimePart->setContentTypeParameter('name', $contentType['name'] ?? 'calendar.ics');
			} else {
				$mimePart->setType($contentType->value_single);
				$mimePart->setContentTypeParameter('name', $contentType['name'] ?? null);
			}
		}

		$mimePart->setContents($body);
		return Attachment::fromMimePart($mimePart);
	}

	/**
	 * Build the parts query for attachments
	 *
	 * @param Horde_Mime_Part $structure
	 * @param array $attachmentIds
	 * @return Horde_Imap_Client_Fetch_Query
	 */
	private function buildAttachmentsPartsQuery(Horde_Mime_Part $structure, array $attachmentIds) : Horde_Imap_Client_Fetch_Query {
		$partsQuery = new Horde_Imap_Client_Fetch_Query();
		$partsQuery->fullText(['peek' => true]);
		foreach ($structure->partIterator() as $part) {
			/** @var Horde_Mime_Part $part */
			if ($part->getMimeId() === '0') {
				// Ignore message header
				continue;
			}

			if ($attachmentIds !== [] && !in_array($part->getMimeId(), $attachmentIds, true)) {
				// We are looking for specific parts only and this is not one of them
				continue;
			}

			$partsQuery->bodyPart($part->getMimeId(), [
				'peek' => true,
			]);
			$partsQuery->mimeHeader($part->getMimeId(), [
				'peek' => true
			]);
			$partsQuery->bodyPartSize($part->getMimeId());
		}
		return $partsQuery;
	}

	/**
	 * @param Horde_Imap_Client_Socket $client
	 * @param int[] $uids
	 *
	 * @return MessageStructureData[]
	 * @throws Horde_Imap_Client_Exception
	 */
	public function getBodyStructureData(Horde_Imap_Client_Socket $client,
		string $mailbox,
		array $uids,
		string $emailAddress): array {
		$structureQuery = new Horde_Imap_Client_Fetch_Query();
		$structureQuery->structure();
		$structureQuery->headerText([
			'peek' => true,
		]);
		$this->smimeService->addEncryptionCheckQueries($structureQuery);

		$structures = $client->fetch($mailbox, $structureQuery, [
			'ids' => new Horde_Imap_Client_Ids($uids),
		]);
		return array_map(function (Horde_Imap_Client_Data_Fetch $fetchData) use ($mailbox, $client, $emailAddress) {
			$hasAttachments = false;
			$text = '';
			$isImipMessage = false;
			$isEncrypted = false;

			if ($this->smimeService->isEncrypted($fetchData)) {
				$isEncrypted = true;
			}

			$structure = $fetchData->getStructure();

			/** @var Horde_Mime_Part $part */
			foreach ($structure->partIterator() as $part) {
				if ($part->isAttachment()) {
					$hasAttachments = true;
				}

				if ($part->getType() === 'text/calendar') {
					if ($part->getContentTypeParameter('method') !== null) {
						$isImipMessage = true;
					}
				}
			}

			$textBodyId = $structure->findBody() ?? $structure->findBody('text');
			$htmlBodyId = $structure->findBody('html');
			if ($textBodyId === null && $htmlBodyId === null) {
				return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false);
			}
			$partsQuery = new Horde_Imap_Client_Fetch_Query();
			if ($htmlBodyId !== null) {
				$partsQuery->bodyPart($htmlBodyId, [
					'peek' => true,
				]);
				$partsQuery->mimeHeader($htmlBodyId, [
					'peek' => true
				]);
			}
			if ($textBodyId !== null) {
				$partsQuery->bodyPart($textBodyId, [
					'peek' => true,
				]);
				$partsQuery->mimeHeader($textBodyId, [
					'peek' => true
				]);
			}
			$parts = $client->fetch($mailbox, $partsQuery, [
				'ids' => new Horde_Imap_Client_Ids([$fetchData->getUid()]),
			]);
			/** @var Horde_Imap_Client_Data_Fetch $part */
			$part = $parts[$fetchData->getUid()];
			// This is sus - why does this even happen? A delete / move in the middle of this processing?
			if ($part === null) {
				return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false);
			}

			/** Convert a given binary body to utf-8 according to the applicable
			 * transfer encoding and content type headers. */
			$convertBody = function (string $bodyId, string $bodyContent) use ($structure, $part, $fetchData): string {
				/** @var Horde_Mime_Headers $mimeHeaders */
				$mimeHeaders = $part->getMimeHeader($bodyId, Horde_Imap_Client_Data_Fetch::HEADER_PARSE);

				/** @var Horde_Mime_Headers $messageHeaders */
				$messageHeaders = $fetchData->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE);

				/** @var Horde_Mime_Headers_ContentParam_ContentType $contentType */
				$contentType
					= $mimeHeaders->getHeader('content-type')
					?? $messageHeaders->getHeader('content-type');

				/** @var Horde_Mime_Headers_ContentTransferEncoding $transferEncoding */
				$transferEncoding
					= $mimeHeaders->getHeader('content-transfer-encoding')
					?? $messageHeaders->getHeader('content-transfer-encoding');

				if (!$contentType && !$transferEncoding) {
					// Nothing to convert here ...
					return $bodyContent;
				}

				if ($transferEncoding) {
					$structure->setTransferEncoding($transferEncoding->value_single);
				}

				if ($contentType) {
					$structure->setType($contentType->value_single);
					if (isset($contentType['charset'])) {
						$structure->setCharset($contentType['charset']);
					}
				}

				$structure->setContents($bodyContent);
				return $this->converter->convert($structure);
			};

			$htmlBody = ($htmlBodyId !== null) ? $part->getBodyPart($htmlBodyId) : null;
			if (!empty($htmlBody)) {
				$htmlBody = $convertBody($htmlBodyId, $htmlBody);
				$mentionsUser = $this->checkLinks($htmlBody, $emailAddress);
				$html = new Html2Text($htmlBody, ['do_links' => 'none','alt_image' => 'hide']);
				return new MessageStructureData(
					$hasAttachments,
					trim($html->getText()),
					$isImipMessage,
					$isEncrypted,
					$mentionsUser,
				);
			}

			$textBody = $part->getBodyPart($textBodyId);
			if (!empty($textBody)) {
				$textBody = $convertBody($textBodyId, $textBody);
				return new MessageStructureData(
					$hasAttachments,
					$textBody,
					$isImipMessage,
					$isEncrypted,
					false,
				);
			}
			return new MessageStructureData($hasAttachments, $text, $isImipMessage, $isEncrypted, false);
		}, iterator_to_array($structures->getIterator()));
	}
	private function checkLinks(string $body, string $mailAddress) : bool {
		if (empty($body)) {
			return false;
		}
		$dom = Parser::parseToDomDocument($body);
		$anchors = $dom->getElementsByTagName('a');
		foreach ($anchors as $anchor) {
			$href = $anchor->getAttribute('href');
			if ($href === 'mailto:' . $mailAddress) {
				return true;
			}
		}
		return false;
	}

	private function getPageSize(Horde_Imap_Client_Socket $client,
		string $mailbox,
		Horde_Imap_Client_Ids $idsToFetch): int {
		$rangeSearchQuery = new Horde_Imap_Client_Search_Query();
		$rangeSearchQuery->ids($idsToFetch);
		$rangeSearchResult = $client->search(
			$mailbox,
			$rangeSearchQuery,
			[
				'results' => [
					Horde_Imap_Client::SEARCH_RESULTS_COUNT,
				],
			]
		);
		return (int)$rangeSearchResult['count'];
	}
}
