// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import * as Errors from '../../types/errors';
import { getSendOptions } from '../../util/getSendOptions';
import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation';
import { SignalService as Proto } from '../../protobuf';
import {
  handleMultipleSendErrors,
  maybeExpandErrors,
} from './handleMultipleSendErrors';
import { ourProfileKeyService } from '../../services/ourProfileKey';
import { DataWriter } from '../../sql/Client';

import type { ConversationModel } from '../../models/conversations';
import type {
  ConversationQueueJobBundle,
  DeleteStoryForEveryoneJobData,
} from '../conversationJobQueue';
import { getUntrustedConversationServiceIds } from './getUntrustedConversationServiceIds';
import { handleMessageSend } from '../../util/handleMessageSend';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { isNotNil } from '../../util/isNotNil';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
import { SendMessageProtoError } from '../../textsecure/Errors';
import { strictAssert } from '../../util/assert';
import type { LoggerType } from '../../types/Logging';
import { isStory } from '../../messages/helpers';

export async function sendDeleteStoryForEveryone(
  ourConversation: ConversationModel,
  {
    isFinalAttempt,
    messaging,
    shouldContinue,
    timestamp,
    timeRemaining,
    log,
  }: ConversationQueueJobBundle,
  data: DeleteStoryForEveryoneJobData
): Promise<void> {
  const { storyId, targetTimestamp, updatedStoryRecipients } = data;

  const logId = `sendDeleteStoryForEveryone(${storyId})`;

  const message = await __DEPRECATED$getMessageById(storyId);
  if (!message) {
    log.error(`${logId}: Failed to fetch message. Failing job.`);
    return;
  }

  if (!shouldContinue) {
    log.info(`${logId}: Ran out of time. Giving up on sending`);
    void updateMessageWithFailure(
      message,
      [new Error('Ran out of time!')],
      log
    );
    return;
  }

  strictAssert(
    isMe(ourConversation.attributes),
    'Story DOE must be sent on our conversaton'
  );
  strictAssert(isStory(message.attributes), 'Story message must be a story');

  const sendType = 'deleteForEveryone';
  const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
  const contentHint = ContentHint.RESENDABLE;

  const deletedForEveryoneSendStatus = message.get(
    'deletedForEveryoneSendStatus'
  );
  strictAssert(
    deletedForEveryoneSendStatus,
    `${logId}: message does not have deletedForEveryoneSendStatus`
  );
  const recipientIds = Object.entries(deletedForEveryoneSendStatus)
    .filter(([_, isSent]) => !isSent)
    .map(([conversationId]) => conversationId);

  const untrustedServiceIds = getUntrustedConversationServiceIds(recipientIds);
  if (untrustedServiceIds.length) {
    window.reduxActions.conversations.conversationStoppedByMissingVerification({
      conversationId: ourConversation.id,
      untrustedServiceIds,
    });
    throw new Error(
      `Delete for everyone blocked because ${untrustedServiceIds.length} ` +
        'conversation(s) were untrusted. Failing this attempt.'
    );
  }

  const recipientConversations = recipientIds
    .map(conversationId => {
      const conversation = window.ConversationController.get(conversationId);
      if (!conversation) {
        log.error(`${logId}: conversation not found for ${conversationId}`);
        return undefined;
      }
      if (!isDirectConversation(conversation.attributes)) {
        log.error(`${logId}: conversation ${conversationId} is not direct`);
        return undefined;
      }

      if (!isConversationAccepted(conversation.attributes)) {
        log.info(
          `${logId}: conversation ${conversation.idForLogging()} ` +
            'is not accepted; refusing to send'
        );
        void updateMessageWithFailure(
          message,
          [new Error('Message request was not accepted')],
          log
        );
        return undefined;
      }
      if (isConversationUnregistered(conversation.attributes)) {
        log.info(
          `${logId}: conversation ${conversation.idForLogging()} ` +
            'is unregistered; refusing to send'
        );
        void updateMessageWithFailure(
          message,
          [new Error('Contact no longer has a Signal account')],
          log
        );
        return undefined;
      }
      if (conversation.isBlocked()) {
        log.info(
          `${logId}: conversation ${conversation.idForLogging()} ` +
            'is blocked; refusing to send'
        );
        void updateMessageWithFailure(
          message,
          [new Error('Contact is blocked')],
          log
        );
        return undefined;
      }

      return conversation;
    })
    .filter(isNotNil);

  const hadSuccessfulSends = doesMessageHaveSuccessfulSends(message);
  let didSuccessfullySendOne = false;

  // Special case - we have no one to send it to so just send the sync message.
  if (recipientConversations.length === 0) {
    didSuccessfullySendOne = true;
  }

  const profileKey = await ourProfileKeyService.get();

  await Promise.all(
    recipientConversations.map(conversation => {
      return conversation.queueJob(
        'conversationQueue/sendStoryDeleteForEveryone',
        async () => {
          log.info(
            `${logId}: Sending deleteStoryForEveryone with timestamp ${timestamp}`
          );

          const sendOptions = await getSendOptions(conversation.attributes, {
            story: true,
          });

          try {
            const serviceId = conversation.getSendTarget();
            strictAssert(serviceId, 'conversation has no service id');

            await handleMessageSend(
              messaging.sendMessageToServiceId({
                serviceId,
                messageText: undefined,
                attachments: [],
                deletedForEveryoneTimestamp: targetTimestamp,
                timestamp,
                expireTimer: undefined,
                contentHint,
                groupId: undefined,
                profileKey: conversation.get('profileSharing')
                  ? profileKey
                  : undefined,
                options: sendOptions,
                urgent: true,
                story: true,
              }),
              {
                messageIds: [storyId],
                sendType,
              }
            );

            didSuccessfullySendOne = true;

            await updateMessageWithSuccessfulSends(message, {
              dataMessage: undefined,
              editMessage: undefined,
              successfulServiceIds: [serviceId],
            });
          } catch (error: unknown) {
            if (error instanceof SendMessageProtoError) {
              await updateMessageWithSuccessfulSends(message, error);
            }

            const errors = maybeExpandErrors(error);
            await handleMultipleSendErrors({
              errors,
              isFinalAttempt,
              log,
              markFailed: () => updateMessageWithFailure(message, errors, log),
              timeRemaining,
              toThrow: error,
            });
          }
        }
      );
    })
  );

  // Send sync message exactly once per job. If any of the sends are successful
  // and we didn't send the DOE itself before - it is a good time to send the
  // sync message.
  if (!hadSuccessfulSends && didSuccessfullySendOne) {
    log.info(`${logId}: Sending sync message`);
    const options = await getSendOptions(ourConversation.attributes, {
      syncMessage: true,
    });

    const destinationServiceId = ourConversation.getCheckedServiceId(
      'deleteStoryForEveryone'
    );

    // Sync message for other devices
    await handleMessageSend(
      messaging.sendSyncMessage({
        destination: undefined,
        destinationServiceId,
        storyMessageRecipients: updatedStoryRecipients?.map(
          ({ destinationServiceId: legacyDestinationUuid, ...rest }) => {
            return {
              // The field was renamed.
              legacyDestinationUuid,
              ...rest,
            };
          }
        ),
        expirationStartTimestamp: null,
        isUpdate: true,
        options,
        timestamp: message.get('timestamp'),
        urgent: false,
      }),
      { messageIds: [storyId], sendType }
    );
  }
}

function doesMessageHaveSuccessfulSends(message: MessageModel): boolean {
  const map = message.get('deletedForEveryoneSendStatus') ?? {};

  return Object.values(map).some(value => value === true);
}

async function updateMessageWithSuccessfulSends(
  message: MessageModel,
  result?: CallbackResultType | SendMessageProtoError
): Promise<void> {
  if (!result) {
    message.set({
      deletedForEveryoneSendStatus: {},
      deletedForEveryoneFailed: undefined,
    });
    await DataWriter.saveMessage(message.attributes, {
      ourAci: window.textsecure.storage.user.getCheckedAci(),
    });

    return;
  }

  const deletedForEveryoneSendStatus = {
    ...message.get('deletedForEveryoneSendStatus'),
  };

  result.successfulServiceIds?.forEach(serviceId => {
    const conversation = window.ConversationController.get(serviceId);
    if (!conversation) {
      return;
    }
    deletedForEveryoneSendStatus[conversation.id] = true;
  });

  message.set({
    deletedForEveryoneSendStatus,
    deletedForEveryoneFailed: undefined,
  });
  await DataWriter.saveMessage(message.attributes, {
    ourAci: window.textsecure.storage.user.getCheckedAci(),
  });
}

async function updateMessageWithFailure(
  message: MessageModel,
  errors: ReadonlyArray<unknown>,
  log: LoggerType
): Promise<void> {
  log.error(
    'updateMessageWithFailure: Setting this set of errors',
    errors.map(Errors.toLogFormat)
  );

  message.set({ deletedForEveryoneFailed: true });
  await DataWriter.saveMessage(message.attributes, {
    ourAci: window.textsecure.storage.user.getCheckedAci(),
  });
}