<?php

declare(strict_types=1);

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

namespace OCA\Mail\Service;

use Horde_Exception;
use Horde_Imap_Client;
use Horde_Imap_Client_Data_Fetch;
use Horde_Imap_Client_Fetch_Query;
use Horde_Imap_Client_Ids;
use Horde_Mail_Rfc822_List;
use Horde_Mail_Transport_Null;
use Horde_Mail_Transport_Smtphorde;
use Horde_Mime_Exception;
use Horde_Mime_Headers;
use Horde_Mime_Headers_Addresses;
use Horde_Mime_Headers_Date;
use Horde_Mime_Headers_MessageId;
use Horde_Mime_Headers_Subject;
use Horde_Mime_Mail;
use Horde_Mime_Mdn;
use Horde_Mime_Part;
use Horde_Smtp_Exception;
use OCA\Mail\Account;
use OCA\Mail\Address;
use OCA\Mail\AddressList;
use OCA\Mail\Contracts\IMailManager;
use OCA\Mail\Contracts\IMailTransmission;
use OCA\Mail\Db\LocalMessage;
use OCA\Mail\Db\Mailbox;
use OCA\Mail\Db\MailboxMapper;
use OCA\Mail\Db\Message;
use OCA\Mail\Db\Recipient;
use OCA\Mail\Events\DraftSavedEvent;
use OCA\Mail\Events\MessageSentEvent;
use OCA\Mail\Events\SaveDraftEvent;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\IMAP\MessageMapper;
use OCA\Mail\Model\NewMessageData;
use OCA\Mail\Service\DataUri\DataUriParser;
use OCA\Mail\SMTP\SmtpClientFactory;
use OCA\Mail\Support\PerformanceLogger;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\EventDispatcher\IEventDispatcher;
use Psr\Log\LoggerInterface;
use Throwable;

class MailTransmission implements IMailTransmission {
	private const RETRIABLE_CODES = [
		Horde_Smtp_Exception::INSUFFICIENT_STORAGE,
		Horde_Smtp_Exception::OVERQUOTA,
		Horde_Smtp_Exception::LOGIN_REQUIREAUTHENTICATION,
	];

	public function __construct(
		private IMAPClientFactory $imapClientFactory,
		private SmtpClientFactory $smtpClientFactory,
		private IEventDispatcher $eventDispatcher,
		private MailboxMapper $mailboxMapper,
		private MessageMapper $messageMapper,
		private LoggerInterface $logger,
		private PerformanceLogger $performanceLogger,
		private AliasesService $aliasesService,
		private TransmissionService $transmissionService,
		private IMailManager $mailManager,
	) {
	}

	#[\Override]
	public function sendMessage(Account $account, LocalMessage $localMessage): void {
		$to = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_TO);
		$cc = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_CC);
		$bcc = $this->transmissionService->getAddressList($localMessage, Recipient::TYPE_BCC);
		$attachments = $this->transmissionService->getAttachments($localMessage);

		$name = $account->getName();
		$emailAddress = $account->getEMailAddress();

		if ($localMessage->getAliasId() !== null) {
			try {
				$alias = $this->aliasesService->find($localMessage->getAliasId(), $account->getUserId());
				$name = ($alias->getName() ?? $name);
				$emailAddress = $alias->getAlias();
			} catch (DoesNotExistException) {
				$this->logger->debug('The assigned alias no longer exists. Falling back to the default name and email address. It is likely that the alias was deleted or deprovisioned in the meantime.', [
					'aliasId' => $localMessage->getAliasId(),
					'accountId' => $account->getId(),
				]);
			}
		}

		$from = Address::fromRaw($name, $emailAddress);

		$attachmentParts = [];
		foreach ($attachments as $attachment) {
			$part = $this->transmissionService->handleAttachment($account, $attachment);
			if ($part !== null) {
				$attachmentParts[] = $part;
			}
		}

		$transport = $this->smtpClientFactory->create($account);

		// Build full headers for the Sent-folder copy (FCC), including Bcc so the
		// sender can see who was blind-copied when reviewing sent mail — the same
		// approach used by Horde IMP and other clients (Evolution, Thunderbird).
		$fccHeaders = new Horde_Mime_Headers();
		$fccHeaders->addHeaderOb(Horde_Mime_Headers_Date::create());
		$fccHeaders->addHeaderOb(Horde_Mime_Headers_MessageId::create());
		$fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $from->toHorde()));
		$fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $to->toHorde()));
		if (count($cc) > 0) {
			$fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Cc', $cc->toHorde()));
		}
		if (count($bcc) > 0) {
			$fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses('Bcc', $bcc->toHorde()));
		}
		if ($localMessage->getSubject() !== null) {
			$fccHeaders->addHeader('Subject', $localMessage->getSubject());
		}
		// The table (oc_local_messages) currently only allows for a single reply to message id
		// but we already set the 'references' header for an email so we could support multiple references
		// Get the previous message and then concatenate all its "References" message ids with this one
		if (($inReplyTo = $localMessage->getInReplyToMessageId()) !== null) {
			$fccHeaders->addHeader('References', $inReplyTo);
			$fccHeaders->addHeader('In-Reply-To', $inReplyTo);
		}
		if ($localMessage->getRequestMdn()) {
			$fccHeaders->addHeaderOb(new Horde_Mime_Headers_Addresses(Horde_Mime_Mdn::MDN_HEADER, $from->toHorde()));
		}

		// For SMTP delivery: strip Bcc so it never appears in the transmitted
		// message (RFC 5321). All three recipient lists are passed as SMTP
		// envelope recipients so every addressee still receives the mail.
		$sendHeaders = clone $fccHeaders;
		$sendHeaders->removeHeader('Bcc');

		$smtpRecipients = new Horde_Mail_Rfc822_List();
		$smtpRecipients->add($to->toHorde());
		$smtpRecipients->add($cc->toHorde());
		$smtpRecipients->add($bcc->toHorde());
		$smtpRecipients->unique();

		$mimeMessage = new MimeMessage(
			new DataUriParser()
		);
		$mimePart = $mimeMessage->build(
			$localMessage->getBodyPlain(),
			$localMessage->getBodyHtml(),
			$localMessage->isPgpMime() === true,
			$attachmentParts,
		);

		// TODO: add smimeEncrypt check if implemented
		try {
			$mimePart = $this->transmissionService->getSignMimePart($localMessage, $account, $mimePart);
			$mimePart = $this->transmissionService->getEncryptMimePart($localMessage, $to, $cc, $bcc, $account, $mimePart);
		} catch (ServiceException $e) {
			$this->logger->error($e->getMessage(), ['exception' => $e]);
			return;
		}

		// Send the message
		try {
			$mimePart->send($smtpRecipients->writeAddress(), $sendHeaders, $transport);
			$localMessage->setRaw($mimePart->toString([
				'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY,
				'headers' => $fccHeaders,
				'stream' => false,
			]));
			$localMessage->setStatus(LocalMessage::STATUS_RAW);
		} catch (Horde_Mime_Exception $e) {
			if ($e->getPrevious() instanceof Horde_Smtp_Exception) {
				/** @var Horde_Smtp_Exception $previousException */
				$previousException = $e->getPrevious();
				$this->logger->error('SMTP error: ' . $e->getMessage(), [
					'exception' => $e,
					'smtpErrorCode' => $previousException->getSmtpCode(),
				]);
			} else {
				$this->logger->error($e->getMessage(), ['exception' => $e]);
			}
			if (in_array($e->getCode(), self::RETRIABLE_CODES, true)) {
				$localMessage->setStatus(LocalMessage::STATUS_SMPT_SEND_FAIL);
				return;
			}

			try {
				$localMessage->setRaw($mimePart->toString([
					'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY,
					'headers' => $fccHeaders,
					'stream' => false,
				]));
			} catch (Throwable) {
				// Having the raw message is nice for troubleshooting, but should not fail hard.
			}
			$localMessage->setStatus(LocalMessage::STATUS_ERROR);
			return;
		} finally {
			if ($transport instanceof Horde_Mail_Transport_Smtphorde) {
				try {
					$transport->getSMTPObject()->logout();
				} catch (Throwable) {
					// Handle silently as this is a resource usage optimization
				}
			}
		}

		$this->eventDispatcher->dispatchTyped(
			new MessageSentEvent($account, $localMessage)
		);
	}

	#[\Override]
	public function saveLocalDraft(Account $account, LocalMessage $message): void {
		$to = $this->transmissionService->getAddressList($message, Recipient::TYPE_TO);
		$cc = $this->transmissionService->getAddressList($message, Recipient::TYPE_CC);
		$bcc = $this->transmissionService->getAddressList($message, Recipient::TYPE_BCC);
		$attachments = $this->transmissionService->getAttachments($message);

		$perfLogger = $this->performanceLogger->start('save local draft');

		$from = Address::fromRaw($account->getName(), $account->getEMailAddress());

		foreach ($attachments as $attachment) {
			$this->transmissionService->handleAttachment($account, $attachment);
		}

		$draftHeaders = $this->buildMimeHeaders($from, $to, $cc, $bcc, $message->getSubject());

		$mail = new Horde_Mime_Mail();
		if ($message->isHtml()) {
			$mail->setHtmlBody($message->getBodyHtml());
		} else {
			$mail->setBody($message->getBodyPlain());
		}
		$perfLogger->step('build local draft message');

		// Use a null transport to trigger MIME body encoding without sending
		$client = $this->imapClientFactory->getClient($account);
		try {
			$mail->send(new Horde_Mail_Transport_Null(), false, false);
			$perfLogger->step('create IMAP draft message');
			$draftsMailbox = $this->findOrCreateDraftsMailbox($account);
			$this->messageMapper->save(
				$client,
				$draftsMailbox,
				$mail->getBasePart()->toString([
					'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY,
					'headers' => $draftHeaders,
					'stream' => false,
				]),
				[Horde_Imap_Client::FLAG_DRAFT]
			);
			$perfLogger->step('save local draft message on IMAP');
		} catch (DoesNotExistException $e) {
			throw new ServiceException('Drafts mailbox does not exist', 0, $e);
		} catch (Horde_Exception $e) {
			throw new ServiceException('Could not save draft message', 0, $e);
		} finally {
			$client->logout();
		}

		$this->eventDispatcher->dispatchTyped(new DraftSavedEvent($account, null));
		$perfLogger->step('emit post local draft save event');

		$perfLogger->end();
	}

	private function findOrCreateDraftsMailbox(Account $account): Mailbox {
		$draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId();

		if ($draftsMailboxId === null) {
			return $this->mailManager->createMailbox(
				$account,
				'Drafts',
				[Horde_Imap_Client::SPECIALUSE_DRAFTS]
			);
		}

		return $this->mailboxMapper->findById($draftsMailboxId);
	}

	/**
	 * @param NewMessageData $message
	 * @param Message|null $previousDraft
	 *
	 * @return array
	 *
	 * @throws ClientException
	 * @throws ServiceException
	 */
	#[\Override]
	public function saveDraft(NewMessageData $message, ?Message $previousDraft = null): array {
		$perfLogger = $this->performanceLogger->start('save draft');
		$this->eventDispatcher->dispatch(
			SaveDraftEvent::class,
			new SaveDraftEvent($message->getAccount(), $message, $previousDraft)
		);
		$perfLogger->step('emit pre event');

		$account = $message->getAccount();
		$from = Address::fromRaw($account->getName(), $account->getEMailAddress());

		$draftHeaders = $this->buildMimeHeaders(
			$from,
			$message->getTo(),
			$message->getCc(),
			$message->getBcc(),
			$message->getSubject()
		);

		$mail = new Horde_Mime_Mail();
		if ($message->isHtml()) {
			$mail->setHtmlBody($message->getBody());
		} else {
			$mail->setBody($message->getBody());
		}
		$perfLogger->step('build draft message');

		// Use a null transport to trigger MIME body encoding without sending
		$client = $this->imapClientFactory->getClient($account);
		try {
			$mail->send(new Horde_Mail_Transport_Null(), false, false);
			$perfLogger->step('create IMAP message');
			// save the message in the drafts mailbox
			$draftsMailboxId = $account->getMailAccount()->getDraftsMailboxId();
			if ($draftsMailboxId === null) {
				throw new ClientException('No drafts mailbox configured');
			}
			$draftsMailbox = $this->mailboxMapper->findById($draftsMailboxId);
			$newUid = $this->messageMapper->save(
				$client,
				$draftsMailbox,
				$mail->getBasePart()->toString([
					'encode' => Horde_Mime_Part::ENCODE_7BIT | Horde_Mime_Part::ENCODE_8BIT | Horde_Mime_Part::ENCODE_BINARY,
					'headers' => $draftHeaders,
					'stream' => false,
				]),
				[Horde_Imap_Client::FLAG_DRAFT]
			);
			$perfLogger->step('save message on IMAP');
		} catch (DoesNotExistException $e) {
			throw new ServiceException('Drafts mailbox does not exist', 0, $e);
		} catch (Horde_Exception $e) {
			throw new ServiceException('Could not save draft message', 0, $e);
		} finally {
			$client->logout();
		}

		$this->eventDispatcher->dispatch(
			DraftSavedEvent::class,
			new DraftSavedEvent($account, $message, $previousDraft)
		);
		$perfLogger->step('emit post event');

		$perfLogger->end();
		return [$account, $draftsMailbox, $newUid];
	}

	private function buildMimeHeaders(Address $from, AddressList $to, AddressList $cc, AddressList $bcc, ?string $subject): Horde_Mime_Headers {
		$headers = new Horde_Mime_Headers();
		$headers->addHeaderOb(Horde_Mime_Headers_Date::create());
		$headers->addHeaderOb(Horde_Mime_Headers_MessageId::create());
		$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $from->toHorde()));
		$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $to->toHorde()));
		if (count($cc) > 0) {
			$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('Cc', $cc->toHorde()));
		}
		if (count($bcc) > 0) {
			$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('Bcc', $bcc->toHorde()));
		}
		if ($subject !== null) {
			$headers->addHeader('Subject', $subject);
		}
		return $headers;
	}

	#[\Override]
	public function sendMdn(Account $account, Mailbox $mailbox, Message $message): void {
		$query = new Horde_Imap_Client_Fetch_Query();
		$query->flags();
		$query->uid();
		$query->imapDate();
		$query->headerText([
			'cache' => true,
			'peek' => true,
		]);

		$imapClient = $this->imapClientFactory->getClient($account);
		try {
			/** @var Horde_Imap_Client_Data_Fetch[] $fetchResults */
			$fetchResults = iterator_to_array($imapClient->fetch($mailbox->getName(), $query, [
				'ids' => new Horde_Imap_Client_Ids([$message->getUid()]),
			]), false);
		} finally {
			$imapClient->logout();
		}

		if (count($fetchResults) < 1) {
			throw new ServiceException("Message \"{$message->getId()}\" not found.");
		}

		$imapDate = $fetchResults[0]->getImapDate();
		/** @var Horde_Mime_Headers $headers */
		$mdnHeaders = $fetchResults[0]->getHeaderText('0', Horde_Imap_Client_Data_Fetch::HEADER_PARSE);
		/** @var Horde_Mime_Headers_Addresses|null $dispositionNotificationTo */
		$dispositionNotificationTo = $mdnHeaders->getHeader('disposition-notification-to');
		/** @var Horde_Mime_Headers_Addresses|null $originalRecipient */
		$originalRecipient = $mdnHeaders->getHeader('original-recipient');

		if ($dispositionNotificationTo === null) {
			throw new ServiceException("Message \"{$message->getId()}\" has no disposition-notification-to header.");
		}

		$headers = new Horde_Mime_Headers();
		$headers->addHeaderOb($dispositionNotificationTo);

		if ($originalRecipient instanceof Horde_Mime_Headers_Addresses) {
			$headers->addHeaderOb($originalRecipient);
		}

		$headers->addHeaderOb(new Horde_Mime_Headers_Subject(null, $message->getSubject()));
		$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('From', $message->getFrom()->toHorde()));
		$headers->addHeaderOb(new Horde_Mime_Headers_Addresses('To', $message->getTo()->toHorde()));
		$headers->addHeaderOb(new Horde_Mime_Headers_MessageId(null, $message->getMessageId()));
		$headers->addHeaderOb(new Horde_Mime_Headers_Date(null, $imapDate->format('r')));

		$smtpClient = $this->smtpClientFactory->create($account);

		$mdn = new Horde_Mime_Mdn($headers);
		try {
			$mdn->generate(
				true,
				true,
				'displayed',
				$account->getMailAccount()->getOutboundHost(),
				$smtpClient,
				[
					'from_addr' => $account->getEMailAddress(),
					'charset' => 'UTF-8',
				]
			);
		} catch (Horde_Mime_Exception $e) {
			throw new ServiceException("Unable to send mdn for message \"{$message->getId()}\" caused by: {$e->getMessage()}", 0, $e);
		}
	}

}
