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

import type { AttachmentType } from '../types/Attachment';
import type { MessageAttributesType } from '../model-types.d';
import type {
  SendState,
  SendStateByConversationId,
} from '../messages/MessageSendState';
import type { UUIDStringType } from '../types/UUID';
import * as log from '../logging/log';
import dataInterface from '../sql/Client';
import { MY_STORY_ID, StorySendMode } from '../types/Stories';
import { getStoriesBlocked } from './stories';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
import { SendStatus } from '../messages/MessageSendState';
import { UUID } from '../types/UUID';
import {
  conversationJobQueue,
  conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { getRecipients } from './getRecipients';
import { getSignalConnections } from './getSignalConnections';
import { incrementMessageCounter } from './incrementMessageCounter';
import { isGroupV2 } from './whatTypeOfConversation';
import { isNotNil } from './isNotNil';
import { collect } from './iterables';
import { DurationInSeconds } from './durations';
import { sanitizeLinkPreview } from '../services/LinkPreview';

export async function sendStoryMessage(
  listIds: Array<string>,
  conversationIds: Array<string>,
  attachment: AttachmentType
): Promise<void> {
  if (getStoriesBlocked()) {
    log.warn('stories.sendStoryMessage: stories disabled, returning early');
    return;
  }

  const { messaging } = window.textsecure;

  if (!messaging) {
    log.warn(
      'stories.sendStoryMessage: messaging not available, returning early'
    );
    return;
  }

  const distributionLists = (
    await Promise.all(
      listIds.map(listId =>
        dataInterface.getStoryDistributionWithMembers(listId)
      )
    )
  ).filter(isNotNil);

  if (!distributionLists.length && !conversationIds.length) {
    log.warn(
      'stories.sendStoryMessage: Dropping send. no conversations to send to and no distribution lists found for',
      listIds
    );
    return;
  }

  const ourConversation =
    window.ConversationController.getOurConversationOrThrow();

  const timestamp = Date.now();

  const sendStateByListId = new Map<
    UUIDStringType,
    SendStateByConversationId
  >();

  const recipientsAlreadySentTo = new Map<UUIDStringType, boolean>();

  // * Create the custom sendStateByConversationId for each distribution list
  // * De-dupe members to make sure they're only sent to once
  // * Figure out who can reply/who can't
  distributionLists
    .sort(list => (list.allowsReplies ? -1 : 1))
    .forEach(distributionList => {
      const sendStateByConversationId: SendStateByConversationId = {};

      let distributionListMembers: Array<UUIDStringType> = [];

      if (distributionList.id === MY_STORY_ID && distributionList.isBlockList) {
        const inBlockList = new Set<UUIDStringType>(distributionList.members);
        distributionListMembers = getSignalConnections().reduce(
          (acc, convo) => {
            const id = convo.get('uuid');
            if (!id) {
              return acc;
            }

            const uuid = UUID.cast(id);
            if (inBlockList.has(uuid)) {
              return acc;
            }

            if (convo.isEverUnregistered()) {
              return acc;
            }

            acc.push(uuid);
            return acc;
          },
          [] as Array<UUIDStringType>
        );
      } else {
        distributionListMembers = distributionList.members;
      }

      distributionListMembers.forEach(destinationUuid => {
        const conversation = window.ConversationController.get(destinationUuid);
        if (!conversation) {
          return;
        }
        sendStateByConversationId[conversation.id] = {
          isAllowedToReplyToStory:
            recipientsAlreadySentTo.get(destinationUuid) ||
            distributionList.allowsReplies,
          isAlreadyIncludedInAnotherDistributionList:
            recipientsAlreadySentTo.has(destinationUuid),
          status: SendStatus.Pending,
          updatedAt: timestamp,
        };

        if (!recipientsAlreadySentTo.has(destinationUuid)) {
          recipientsAlreadySentTo.set(
            destinationUuid,
            distributionList.allowsReplies
          );
        }
      });

      sendStateByListId.set(distributionList.id, sendStateByConversationId);
    });

  const attachments: Array<AttachmentType> = [attachment];

  const linkPreview = attachment?.textAttachment?.preview;
  const sanitizedLinkPreview = linkPreview
    ? sanitizeLinkPreview(linkPreview)
    : undefined;
  // If a text attachment has a link preview we remove it from the
  // textAttachment data structure and instead process the preview and add
  // it as a "preview" property for the message attributes.
  const preview = sanitizedLinkPreview ? [sanitizedLinkPreview] : undefined;

  // * Gather all the job data we'll be sending to the sendStory job
  // * Create the message for each distribution list
  const distributionListMessages: Array<MessageAttributesType> =
    await Promise.all(
      distributionLists.map(async distributionList => {
        const sendStateByConversationId = sendStateByListId.get(
          distributionList.id
        );

        if (!sendStateByConversationId) {
          log.warn(
            'stories.sendStoryMessage: No sendStateByConversationId for distribution list',
            distributionList.id
          );
        }

        // Note: we use the same sent_at for these messages because we want de-duplication
        //   on the receiver side.
        return window.Signal.Migrations.upgradeMessageSchema({
          attachments,
          conversationId: ourConversation.id,
          expireTimer: DurationInSeconds.DAY,
          expirationStartTimestamp: Date.now(),
          id: UUID.generate().toString(),
          preview,
          readStatus: ReadStatus.Read,
          received_at: incrementMessageCounter(),
          received_at_ms: timestamp,
          seenStatus: SeenStatus.NotApplicable,
          sendStateByConversationId,
          sent_at: timestamp,
          source: window.textsecure.storage.user.getNumber(),
          sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
          sourceDevice: window.textsecure.storage.user.getDeviceId(),
          storyDistributionListId: distributionList.id,
          timestamp,
          type: 'story',
        });
      })
    );

  const groupV2MessagesByConversationId = new Map<
    string,
    MessageAttributesType
  >();

  const groupsToSendTo = Array.from(
    collect(conversationIds, conversationId => {
      const group = window.ConversationController.get(conversationId);

      if (!group) {
        log.warn(
          'stories.sendStoryMessage: No group found for id',
          conversationId
        );
        return;
      }

      if (!isGroupV2(group.attributes)) {
        log.warn(
          'stories.sendStoryMessage: Conversation we tried to send to is not a groupV2',
          conversationId
        );
        return;
      }

      if (group.get('announcementsOnly') && !group.areWeAdmin()) {
        log.warn(
          'stories.sendStoryMessage: cannot send to an announcement only group as a non-admin',
          conversationId
        );
        return;
      }

      return group;
    })
  );

  // sending a story to a group marks it as one we want to always
  // include on the send-story-to list
  const groupsToUpdate = Array.from(groupsToSendTo).filter(
    group => group.getStorySendMode() !== StorySendMode.Always
  );
  for (const group of groupsToUpdate) {
    group.set('storySendMode', StorySendMode.Always);
  }
  void window.Signal.Data.updateConversations(
    groupsToUpdate.map(group => group.attributes)
  );
  for (const group of groupsToUpdate) {
    group.captureChange('storySendMode');
  }

  await Promise.all(
    groupsToSendTo.map(async (group, index) => {
      // We want all of these timestamps to be different from the My Story timestamp.
      const groupTimestamp = timestamp + index + 1;

      const myId = window.ConversationController.getOurConversationIdOrThrow();
      const sendState: SendState = {
        status: SendStatus.Pending,
        updatedAt: groupTimestamp,
        isAllowedToReplyToStory: true,
      };

      const sendStateByConversationId: SendStateByConversationId =
        getRecipients(group.attributes).reduce(
          (acc, id) => {
            const conversation = window.ConversationController.get(id);
            if (!conversation) {
              return acc;
            }

            return {
              ...acc,
              [conversation.id]: sendState,
            };
          },
          {
            [myId]: sendState,
          }
        );

      const messageAttributes =
        await window.Signal.Migrations.upgradeMessageSchema({
          attachments,
          canReplyToStory: true,
          conversationId: group.id,
          expireTimer: DurationInSeconds.DAY,
          expirationStartTimestamp: Date.now(),
          id: UUID.generate().toString(),
          readStatus: ReadStatus.Read,
          received_at: incrementMessageCounter(),
          received_at_ms: groupTimestamp,
          seenStatus: SeenStatus.NotApplicable,
          sendStateByConversationId,
          sent_at: groupTimestamp,
          source: window.textsecure.storage.user.getNumber(),
          sourceUuid: window.textsecure.storage.user.getUuid()?.toString(),
          sourceDevice: window.textsecure.storage.user.getDeviceId(),
          timestamp: groupTimestamp,
          type: 'story',
        });

      groupV2MessagesByConversationId.set(group.id, messageAttributes);
    })
  );

  // For distribution lists:
  // * Save the message model
  // * Add the message to the conversation
  await Promise.all(
    distributionListMessages.map(messageAttributes => {
      const model = new window.Whisper.Message(messageAttributes);
      const message = window.MessageController.register(model.id, model);

      void ourConversation.addSingleMessage(model, { isJustSent: true });

      log.info(
        `stories.sendStoryMessage: saving message ${messageAttributes.timestamp}`
      );
      return dataInterface.saveMessage(message.attributes, {
        forceSave: true,
        ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
      });
    })
  );

  // * Send to the distribution lists
  // * Place into job queue
  // * Save the job
  await conversationJobQueue.add({
    type: conversationQueueJobEnum.enum.Story,
    conversationId: ourConversation.id,
    messageIds: distributionListMessages.map(m => m.id),
    timestamp,
  });

  // * Send to groups
  // * Save the message models
  // * Add message to group conversation
  await Promise.all(
    conversationIds.map(conversationId => {
      const messageAttributes =
        groupV2MessagesByConversationId.get(conversationId);

      if (!messageAttributes) {
        log.warn(
          'stories.sendStoryMessage: Trying to send a group story but it did not exist? This is unexpected. Not sending.',
          conversationId
        );
        return;
      }

      return conversationJobQueue.add(
        {
          type: conversationQueueJobEnum.enum.Story,
          conversationId,
          messageIds: [messageAttributes.id],
          // using the group timestamp, which will differ from the 1:1 timestamp
          timestamp: messageAttributes.timestamp,
        },
        async jobToInsert => {
          const model = new window.Whisper.Message(messageAttributes);
          const message = window.MessageController.register(model.id, model);

          const conversation = message.getConversation();
          void conversation?.addSingleMessage(model, { isJustSent: true });

          log.info(
            `stories.sendStoryMessage: saving message ${messageAttributes.timestamp}`
          );
          await dataInterface.saveMessage(message.attributes, {
            forceSave: true,
            jobToInsert,
            ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
          });
        }
      );
    })
  );
}
