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

import { z } from 'zod';
import PQueue from 'p-queue';
import pMap from 'p-map';
import type { PlaintextContent } from '@signalapp/libsignal-client';
import {
  ContentHint,
  ProtocolAddress,
  SenderKeyDistributionMessage,
} from '@signalapp/libsignal-client';
import type { RequireExactlyOne } from 'type-fest';

import {
  GLOBAL_ZONE,
  signalProtocolStore,
} from '../SignalProtocolStore.preload.ts';
import { DataWriter } from '../sql/Client.preload.ts';
import type { ConversationModel } from '../models/conversations.preload.ts';
import { assertDev, strictAssert } from '../util/assert.std.ts';
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.ts';
import { uuidToBytes } from '../util/uuidToBytes.std.ts';
import { Address } from '../types/Address.std.ts';
import { QualifiedAddress } from '../types/QualifiedAddress.std.ts';
import type { StoryMessageRecipientsType } from '../types/Stories.std.ts';
import { SenderKeys } from '../LibSignalStores.node.ts';
import type {
  TextAttachmentType,
  UploadedAttachmentType,
} from '../types/Attachment.std.ts';
import type { AciString, ServiceIdString } from '../types/ServiceId.std.ts';
import {
  ServiceIdKind,
  serviceIdSchema,
  isPniString,
} from '../types/ServiceId.std.ts';
import {
  toAciObject,
  toPniObject,
  toServiceIdObject,
} from '../util/ServiceId.node.ts';
import { runTaskWithTimeout } from './TaskWithTimeout.std.ts';
import type { CallbackResultType } from './Types.d.ts';
import type {
  SerializedCertificateType,
  SendLogCallbackType,
} from './OutgoingMessage.preload.ts';
import OutgoingMessage from './OutgoingMessage.preload.ts';
import * as Bytes from '../Bytes.std.ts';
import { getRandomBytes } from '../Crypto.node.ts';
import { SendMessageProtoError, NoSenderKeyError } from './Errors.std.ts';
import { BodyRange } from '../types/BodyRange.std.ts';
import type { RawBodyRange } from '../types/BodyRange.std.ts';
import type { StoryContextType } from '../types/Util.std.ts';
import { concat, isEmpty } from '../util/iterables.std.ts';
import type { SendTypesType } from '../util/handleMessageSend.preload.ts';
import {
  shouldSaveProto,
  sendTypesEnum,
} from '../util/handleMessageSend.preload.ts';
import type { DurationInSeconds } from '../util/durations/index.std.ts';
import { SignalService as Proto } from '../protobuf/index.std.ts';
import { createLogger } from '../logging/log.std.ts';
import type { EmbeddedContactWithUploadedAvatar } from '../types/EmbeddedContact.std.ts';
import {
  numberToPhoneType,
  numberToEmailType,
  numberToAddressType,
} from '../types/EmbeddedContact.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import { drop } from '../util/drop.std.ts';
import type {
  ConversationIdentifier,
  DeleteForMeSyncEventData,
  DeleteMessageSyncTarget,
  AddressableMessage,
} from './messageReceiverEvents.std.ts';
import { getConversationFromTarget } from '../util/syncIdentifiers.preload.ts';
import type {
  CallDetails,
  CallHistoryDetails,
} from '../types/CallDisposition.std.ts';
import {
  AdhocCallStatus,
  DirectCallStatus,
  GroupCallStatus,
  CallMode,
} from '../types/CallDisposition.std.ts';
import {
  getBytesForPeerId,
  getCallIdForProto,
  getProtoForCallHistory,
} from '../util/callDisposition.preload.ts';
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types.std.ts';
import { isProtoBinaryEncodingEnabled } from '../util/isProtoBinaryEncodingEnabled.dom.ts';
import type { GroupSendToken } from '../types/GroupSendEndorsements.std.ts';
import type { OutgoingPollVote, PollCreateType } from '../types/Polls.dom.ts';
import { itemStorage } from './Storage.preload.ts';
import { accountManager } from './AccountManager.preload.ts';
import type {
  SendPinMessageType,
  SendUnpinMessageType,
} from '../types/PinnedMessage.std.ts';

const log = createLogger('SendMessage');

const MAX_EMBEDDED_GROUP_CHANGE_BYTES = 2048;
const MAX_INCREMENTAL_MAC_ATTACHMENTS = 10;

export type SendIdentifierData =
  | {
      accessKey: string;
      senderCertificate: SerializedCertificateType | null;
      groupSendToken: null;
    }
  | {
      accessKey: null;
      senderCertificate: SerializedCertificateType | null;
      groupSendToken: GroupSendToken;
    };

export type SendMetadataType = {
  [serviceId: ServiceIdString]: SendIdentifierData;
};

export type SendOptionsType = {
  sendMetadata?: SendMetadataType;
  online?: boolean;
};

export type OutgoingQuoteAttachmentType = Readonly<{
  contentType: string;
  fileName?: string;
  thumbnail?: UploadedAttachmentType;
}>;

export type OutgoingQuoteType = Readonly<{
  isGiftBadge?: boolean;
  id?: number;
  authorAci?: AciString;
  text?: string;
  attachments: ReadonlyArray<OutgoingQuoteAttachmentType>;
  bodyRanges?: ReadonlyArray<RawBodyRange>;
}>;

export type OutgoingLinkPreviewType = Readonly<{
  title?: string;
  description?: string;
  domain?: string;
  url: string;
  isStickerPack?: boolean;
  image?: Readonly<UploadedAttachmentType>;
  date?: number;
}>;

export type OutgoingTextAttachmentType = Omit<TextAttachmentType, 'preview'> & {
  preview?: OutgoingLinkPreviewType;
};

export type GroupV2InfoType = {
  groupChange?: Uint8Array<ArrayBuffer>;
  masterKey: Uint8Array<ArrayBuffer>;
  revision: number;
  members: ReadonlyArray<ServiceIdString>;
};

type GroupCallUpdateType = {
  eraId: string;
};

export type OutgoingStickerType = Readonly<{
  packId: string;
  packKey: string;
  stickerId: number;
  emoji?: string;
  data: Readonly<UploadedAttachmentType>;
}>;

export type ReactionType = {
  emoji?: string;
  remove?: boolean;
  targetAuthorAci?: AciString;
  targetTimestamp?: number;
};

export const singleProtoJobDataSchema = z.object({
  contentHint: z.number(),
  serviceId: serviceIdSchema,
  isSyncMessage: z.boolean(),
  messageIds: z.array(z.string()).optional(),
  protoBase64: z.string(),
  type: sendTypesEnum,
  urgent: z.boolean().optional(),
});

export type SingleProtoJobData = z.infer<typeof singleProtoJobDataSchema>;

export type SendDeleteForEveryoneType = Readonly<{
  isAdminDelete: boolean;
  targetSentTimestamp: number;
  targetAuthorAci: AciString;
}>;

export type SharedMessageOptionsType = Readonly<{
  // required
  timestamp: number;
  // optional
  attachments?: ReadonlyArray<Proto.AttachmentPointer.Params>;
  body?: string;
  bodyRanges?: ReadonlyArray<RawBodyRange>;
  contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
  deleteForEveryone?: SendDeleteForEveryoneType;
  expireTimer?: DurationInSeconds;
  flags?: number;
  groupCallUpdate?: GroupCallUpdateType;
  groupV2?: GroupV2InfoType;
  isViewOnce?: boolean;
  pinMessage?: SendPinMessageType;
  pollCreate?: PollCreateType;
  preview?: ReadonlyArray<OutgoingLinkPreviewType>;
  profileKey?: Uint8Array<ArrayBuffer>;
  quote?: OutgoingQuoteType;
  reaction?: ReactionType;
  sticker?: OutgoingStickerType;
  storyContext?: StoryContextType;
  targetTimestampForEdit?: number;
  unpinMessage?: SendUnpinMessageType;
}>;

export type MessageOptionsType = Readonly<
  SharedMessageOptionsType & {
    // Not needed for group messages, lives in group state
    expireTimerVersion?: number | undefined;
    recipients: ReadonlyArray<ServiceIdString>;
  }
>;

export type GroupMessageOptionsType = Readonly<
  SharedMessageOptionsType & {
    groupV2: GroupV2InfoType;
  }
>;

export type PollVoteBuildOptions = Readonly<{
  timestamp: number;
  pollVote: OutgoingPollVote;
}> &
  Pick<
    MessageOptionsType,
    'groupV2' | 'profileKey' | 'expireTimer' | 'expireTimerVersion'
  >;

export type PollTerminateBuildOptions = Readonly<{
  timestamp: number;
  pollTerminate: Readonly<{
    targetTimestamp: number;
  }>;
}> &
  Pick<
    MessageOptionsType,
    'groupV2' | 'profileKey' | 'expireTimer' | 'expireTimerVersion'
  >;

class Message {
  attachments: ReadonlyArray<Proto.AttachmentPointer.Params>;

  body?: string;

  bodyRanges?: ReadonlyArray<RawBodyRange>;

  contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;

  expireTimer?: DurationInSeconds;

  expireTimerVersion?: number;

  flags?: number;

  groupV2?: GroupV2InfoType;

  isViewOnce?: boolean;

  preview?: ReadonlyArray<OutgoingLinkPreviewType>;

  profileKey?: Uint8Array<ArrayBuffer>;

  quote?: OutgoingQuoteType;

  recipients: ReadonlyArray<ServiceIdString>;

  sticker?: OutgoingStickerType;

  reaction?: ReactionType;

  pollCreate?: PollCreateType;

  pollTerminate?: Readonly<{
    targetTimestamp: number;
  }>;

  pinMessage?: SendPinMessageType;
  unpinMessage?: SendUnpinMessageType;

  timestamp: number;

  dataMessage?: Proto.DataMessage.Params;

  deleteForEveryone?: SendDeleteForEveryoneType;

  groupCallUpdate?: GroupCallUpdateType;

  storyContext?: StoryContextType;

  pollVote?: OutgoingPollVote;

  constructor(options: MessageOptionsType) {
    this.attachments = options.attachments || [];
    this.body = options.body;
    this.bodyRanges = options.bodyRanges;
    this.contact = options.contact;
    this.expireTimer = options.expireTimer;
    this.expireTimerVersion = options.expireTimerVersion;
    this.flags = options.flags;
    this.groupV2 = options.groupV2;
    this.isViewOnce = options.isViewOnce;
    this.preview = options.preview;
    this.profileKey = options.profileKey;
    this.quote = options.quote;
    this.recipients = options.recipients;
    this.sticker = options.sticker;
    this.reaction = options.reaction;
    this.pollCreate = options.pollCreate;
    this.timestamp = options.timestamp;
    this.deleteForEveryone = options.deleteForEveryone;
    this.groupCallUpdate = options.groupCallUpdate;
    this.storyContext = options.storyContext;
    this.pinMessage = options.pinMessage;
    this.unpinMessage = options.unpinMessage;

    if (!(this.recipients instanceof Array)) {
      throw new Error('Invalid recipient list');
    }

    if (!this.groupV2 && this.recipients.length !== 1) {
      throw new Error('Invalid recipient list for non-group');
    }

    if (typeof this.timestamp !== 'number') {
      throw new Error('Invalid timestamp');
    }

    if (this.expireTimer != null) {
      if (typeof this.expireTimer !== 'number' || !(this.expireTimer >= 0)) {
        throw new Error('Invalid expireTimer');
      }
    }

    if (this.attachments) {
      if (!(this.attachments instanceof Array)) {
        throw new Error('Invalid message attachments');
      }
    }
    if (this.flags !== undefined) {
      if (typeof this.flags !== 'number') {
        throw new Error('Invalid message flags');
      }
    }
    if (this.isEndSession()) {
      if (this.body != null || this.attachments.length !== 0) {
        throw new Error('Invalid end session message');
      }
    } else if (
      typeof this.timestamp !== 'number' ||
      (this.body && typeof this.body !== 'string')
    ) {
      throw new Error('Invalid message body');
    }
  }

  isEndSession() {
    // oxlint-disable-next-line no-bitwise
    return (this.flags || 0) & Proto.DataMessage.Flags.END_SESSION;
  }

  toProto(): Proto.DataMessage.Params {
    if (this.dataMessage) {
      return this.dataMessage;
    }

    let requiredProtocolVersion = 0;

    if (this.body) {
      const mentionCount = this.bodyRanges
        ? this.bodyRanges.filter(BodyRange.isMention).length
        : 0;
      const otherRangeCount = this.bodyRanges
        ? this.bodyRanges.length - mentionCount
        : 0;
      const placeholders = this.body.match(/\uFFFC/g);
      const placeholderCount = placeholders ? placeholders.length : 0;
      const storyInfo = this.storyContext
        ? `, story: ${this.storyContext.timestamp}`
        : '';
      log.info(
        `Sending a message with ${mentionCount} mentions, ` +
          `${placeholderCount} placeholders, ` +
          `and ${otherRangeCount} other ranges${storyInfo}`
      );
    }

    if (
      this.groupV2?.groupChange &&
      this.groupV2.groupChange.byteLength > MAX_EMBEDDED_GROUP_CHANGE_BYTES
    ) {
      // As a message-size optimization, we do not embed large updates and receiving
      // devices fetch them from the group server instead
      log.info(
        'Discarding oversized group change proto ' +
          `(${this.groupV2.groupChange.byteLength} bytes)`
      );
    }

    let contact: Array<Proto.DataMessage.Contact.Params> | null = null;
    if (Array.isArray(this.contact)) {
      contact = this.contact.map(
        (contactEntry: EmbeddedContactWithUploadedAvatar) => {
          let name: Proto.DataMessage.Contact.Name.Params | null = null;
          if (contactEntry.name) {
            name = {
              givenName: contactEntry.name.givenName ?? null,
              familyName: contactEntry.name.familyName ?? null,
              prefix: contactEntry.name.prefix ?? null,
              suffix: contactEntry.name.suffix ?? null,
              middleName: contactEntry.name.middleName ?? null,
              nickname: contactEntry.name.nickname ?? null,
            };
          }
          let number: Array<Proto.DataMessage.Contact.Phone.Params> | null =
            null;
          if (Array.isArray(contactEntry.number)) {
            number = contactEntry.number.map(
              (entry): Proto.DataMessage.Contact.Phone.Params => {
                return {
                  value: entry.value,
                  type: numberToPhoneType(entry.type),
                  label: entry.label,
                };
              }
            );
          }
          let email: Array<Proto.DataMessage.Contact.Email.Params> | null =
            null;
          if (Array.isArray(contactEntry.email)) {
            email = contactEntry.email.map(
              (entry): Proto.DataMessage.Contact.Email.Params => {
                return {
                  value: entry.value,
                  type: numberToEmailType(entry.type),
                  label: entry.label,
                };
              }
            );
          }
          let address: Array<Proto.DataMessage.Contact.PostalAddress.Params> | null =
            null;
          if (Array.isArray(contactEntry.address)) {
            address = contactEntry.address.map(
              (entry): Proto.DataMessage.Contact.PostalAddress.Params => {
                return {
                  type: numberToAddressType(entry.type),
                  label: entry.label,
                  street: entry.street,
                  pobox: entry.pobox,
                  neighborhood: entry.neighborhood,
                  city: entry.city,
                  region: entry.region,
                  postcode: entry.postcode,
                  country: entry.country,
                };
              }
            );
          }
          let avatar: Proto.DataMessage.Contact.Avatar.Params | null = null;
          if (contactEntry.avatar?.avatar) {
            avatar = {
              avatar: contactEntry.avatar.avatar,
              isProfile: contactEntry.avatar.isProfile,
            };
          }

          let organization: string | null = null;
          if (contactEntry.organization) {
            organization = contactEntry.organization;
          }

          return {
            name,
            email,
            number,
            address,
            avatar,
            organization,
          };
        }
      );
    }

    let quote: Proto.DataMessage.Quote.Params | null = null;
    if (this.quote) {
      quote = {
        type: this.quote.isGiftBadge
          ? Proto.DataMessage.Quote.Type.GIFT_BADGE
          : Proto.DataMessage.Quote.Type.NORMAL,
        id: this.quote.id === undefined ? null : BigInt(this.quote.id),
        authorAciBinary:
          this.quote.authorAci && isProtoBinaryEncodingEnabled()
            ? toAciObject(this.quote.authorAci).getRawUuidBytes()
            : null,
        authorAci: isProtoBinaryEncodingEnabled()
          ? null
          : (this.quote.authorAci ?? null),
        text: this.quote.text ?? null,
        attachments: this.quote.attachments.map(attachment => {
          return {
            contentType: attachment.contentType,
            fileName: attachment.fileName ?? null,
            thumbnail: attachment.thumbnail ?? null,
          };
        }),
        bodyRanges: this.quote.bodyRanges?.map(toBodyRange) ?? null,
      };

      if (quote?.bodyRanges?.length) {
        requiredProtocolVersion = Math.max(
          requiredProtocolVersion,
          Proto.DataMessage.ProtocolVersion.MENTIONS
        );
      }
    }

    let adminDelete: Proto.DataMessage.AdminDelete.Params | null = null;
    let del: Proto.DataMessage.Delete.Params | null = null;
    if (this.deleteForEveryone) {
      const { isAdminDelete, targetSentTimestamp, targetAuthorAci } =
        this.deleteForEveryone;
      if (isAdminDelete) {
        adminDelete = {
          targetSentTimestamp: BigInt(targetSentTimestamp),
          targetAuthorAciBinary: uuidToBytes(targetAuthorAci),
        };
      } else {
        del = {
          targetSentTimestamp: BigInt(targetSentTimestamp),
        };
      }
    }

    if (this.bodyRanges?.length) {
      requiredProtocolVersion = Math.max(
        requiredProtocolVersion,
        Proto.DataMessage.ProtocolVersion.MENTIONS
      );
    }

    let groupCallUpdate: Proto.DataMessage.GroupCallUpdate.Params | null = null;
    if (this.groupCallUpdate) {
      groupCallUpdate = {
        eraId: this.groupCallUpdate.eraId,
      };
    }

    let storyContext: Proto.DataMessage.StoryContext.Params | null = null;
    if (this.storyContext) {
      storyContext = {
        sentTimestamp: BigInt(this.storyContext.timestamp),
        authorAciBinary:
          this.storyContext.authorAci && isProtoBinaryEncodingEnabled()
            ? toAciObject(this.storyContext.authorAci).getRawUuidBytes()
            : null,
        authorAci: isProtoBinaryEncodingEnabled()
          ? null
          : (this.storyContext.authorAci ?? null),
      };
    }

    let pollCreate: Proto.DataMessage.PollCreate.Params | null = null;
    if (this.pollCreate) {
      pollCreate = {
        question: this.pollCreate.question,
        allowMultiple: this.pollCreate.allowMultiple,
        options: this.pollCreate.options.slice(),
      };
      requiredProtocolVersion = Math.max(
        requiredProtocolVersion,
        Proto.DataMessage.ProtocolVersion.POLLS
      );
    }

    let pinMessage: Proto.DataMessage.PinMessage.Params | null = null;
    if (this.pinMessage != null) {
      const { targetAuthorAci, targetSentTimestamp, pinDurationSeconds } =
        this.pinMessage;

      pinMessage = {
        targetAuthorAciBinary: toAciObject(targetAuthorAci).getRawUuidBytes(),
        targetSentTimestamp: BigInt(targetSentTimestamp),
        pinDuration:
          pinDurationSeconds != null
            ? {
                pinDurationSeconds,
              }
            : {
                pinDurationForever: true,
              },
      };
    }

    let unpinMessage: Proto.DataMessage.UnpinMessage.Params | null = null;
    if (this.unpinMessage != null) {
      const { targetAuthorAci, targetSentTimestamp } = this.unpinMessage;

      unpinMessage = {
        targetAuthorAciBinary: toAciObject(targetAuthorAci).getRawUuidBytes(),
        targetSentTimestamp: BigInt(targetSentTimestamp),
      };
    }

    let incrementalMacsIncluded = 0;
    const dataMessage: Proto.DataMessage.Params = {
      timestamp: BigInt(this.timestamp),
      attachments: this.attachments.map(attachment => {
        if (attachment.incrementalMac != null) {
          if (incrementalMacsIncluded >= MAX_INCREMENTAL_MAC_ATTACHMENTS) {
            log.warn(
              `message.toProto${this.timestamp}: Dropping incremental mac`
            );
            return { ...attachment, incrementalMac: null, chunkSize: null };
          }
          incrementalMacsIncluded += 1;
        }

        return attachment;
      }),
      flags: this.flags ?? 0,
      body: this.body ?? null,
      bodyRanges: this.bodyRanges?.map(toBodyRange) ?? null,
      groupV2: this.groupV2
        ? {
            masterKey: this.groupV2.masterKey,
            revision: this.groupV2.revision,
            groupChange:
              this.groupV2.groupChange != null &&
              this.groupV2.groupChange.byteLength <
                MAX_EMBEDDED_GROUP_CHANGE_BYTES
                ? this.groupV2.groupChange
                : null,
          }
        : null,
      sticker: this.sticker
        ? ({
            packId: Bytes.fromHex(this.sticker.packId),
            packKey: Bytes.fromBase64(this.sticker.packKey),
            stickerId: this.sticker.stickerId,
            emoji: this.sticker.emoji ?? null,
            data: this.sticker.data,
          } satisfies Proto.DataMessage.Sticker.Params)
        : null,
      reaction: this.reaction
        ? ({
            emoji: this.reaction.emoji ?? null,
            remove: Boolean(this.reaction.remove),
            targetAuthorAciBinary:
              this.reaction.targetAuthorAci && isProtoBinaryEncodingEnabled()
                ? toAciObject(this.reaction.targetAuthorAci).getRawUuidBytes()
                : null,
            targetAuthorAci: isProtoBinaryEncodingEnabled()
              ? null
              : (this.reaction.targetAuthorAci ?? null),
            targetSentTimestamp:
              this.reaction.targetTimestamp == null
                ? null
                : BigInt(this.reaction.targetTimestamp),
          } satisfies Proto.DataMessage.Reaction.Params)
        : null,

      preview:
        this.preview?.map((preview): Proto.Preview.Params => {
          return {
            title: preview.title ?? null,
            url: preview.url,
            description: preview.description ?? null,
            date: preview.date ? BigInt(preview.date) : null,
            image: preview.image ?? null,
          };
        }) ?? null,

      contact,
      quote,
      adminDelete,
      delete: del,
      groupCallUpdate,
      storyContext,
      pollCreate,
      pinMessage,
      unpinMessage,

      // Handled separately
      pollVote: null,
      pollTerminate: null,

      expireTimer: this.expireTimer ?? null,
      expireTimerVersion: this.expireTimerVersion ?? null,
      profileKey: this.profileKey ?? null,
      isViewOnce: Boolean(this.isViewOnce),
      requiredProtocolVersion,

      payment: null,
      giftBadge: null,
    };
    this.dataMessage = dataMessage;
    return dataMessage;
  }
}

export type AddPniSignatureMessageToProtoOptionsType = Readonly<{
  conversation?: ConversationModel;
  proto: Proto.Content.Params;
  reason: string;
}>;

export function addPniSignatureMessageToProto({
  conversation,
  proto,
  reason,
}: AddPniSignatureMessageToProtoOptionsType): void {
  if (!conversation) {
    return;
  }

  const pniSignatureMessage = conversation?.getPniSignatureMessage();
  if (!pniSignatureMessage) {
    return;
  }

  log.info(
    `addPniSignatureMessageToProto(${reason}): ` +
      `adding pni signature for ${conversation.idForLogging()}`
  );

  // oxlint-disable-next-line no-param-reassign
  proto.pniSignatureMessage = {
    pni: toPniObject(pniSignatureMessage.pni).getRawUuidBytes(),
    signature: pniSignatureMessage.signature,
  };
}

// oxlint-disable-next-line max-classes-per-file
export class MessageSender {
  pendingMessages: {
    [id: string]: PQueue;
  };

  constructor() {
    this.pendingMessages = {};
  }

  async queueJobForServiceId<T>(
    serviceId: ServiceIdString,
    runJob: () => Promise<T>
  ): Promise<T> {
    const { id } = await window.ConversationController.getOrCreateAndWait(
      serviceId,
      'private'
    );
    this.pendingMessages[id] =
      this.pendingMessages[id] || new PQueue({ concurrency: 1 });

    const queue = this.pendingMessages[id];

    return queue.add(() =>
      runTaskWithTimeout(runJob, `queueJobForServiceId ${serviceId} ${id}`)
    );
  }

  // Attachment upload functions

  static getRandomPadding(): Uint8Array<ArrayBuffer> {
    // Generate a random int from 1 and 512
    const buffer = getRandomBytes(2);
    // oxlint-disable-next-line typescript/no-non-null-assertion, no-bitwise
    const paddingLength = (new Uint16Array(buffer)[0]! & 0x1ff) + 1;

    // Generate a random padding buffer of the chosen size
    return getRandomBytes(paddingLength);
  }

  // Proto assembly

  getTextAttachmentProto(
    attachmentAttrs: OutgoingTextAttachmentType
  ): Proto.TextAttachment.Params {
    const { preview, gradient } = attachmentAttrs;

    let background: Proto.TextAttachment.Params['background'];
    if (gradient) {
      background = {
        gradient: {
          startColor: gradient.startColor ?? null,
          endColor: gradient.endColor ?? null,
          angle: gradient.angle ?? null,
          colors: gradient.colors?.slice() ?? null,
          positions: gradient.positions?.slice() ?? null,
        },
      };
    } else if (attachmentAttrs.color) {
      background = {
        color: attachmentAttrs.color,
      };
    } else {
      background = null;
    }

    return {
      text: attachmentAttrs.text ?? null,
      textStyle: attachmentAttrs.textStyle ?? 0,

      textForegroundColor: attachmentAttrs.textForegroundColor ?? null,
      textBackgroundColor: attachmentAttrs.textBackgroundColor ?? null,

      preview: preview
        ? {
            image: preview.image ?? null,
            title: preview.title ?? null,
            url: preview.url,
            description: null,
            date: null,
          }
        : null,

      background,
    };
  }

  async getDataOrEditMessage(
    options: Readonly<MessageOptionsType>
  ): Promise<Uint8Array<ArrayBuffer>> {
    const message = await this.getHydratedMessage(options);
    const dataMessage = message.toProto();

    if (options.targetTimestampForEdit) {
      return Proto.EditMessage.encode({
        dataMessage,
        targetSentTimestamp: BigInt(options.targetTimestampForEdit),
      });
    }
    return Proto.DataMessage.encode(dataMessage);
  }

  createDataMessageProtoForPollVote({
    groupV2,
    timestamp,
    profileKey,
    expireTimer,
    expireTimerVersion,
    pollVote,
  }: PollVoteBuildOptions): Proto.DataMessage.Params {
    return {
      timestamp: BigInt(timestamp),
      groupV2: groupV2
        ? {
            masterKey: groupV2.masterKey,
            revision: groupV2.revision,
            groupChange: null,
          }
        : null,
      expireTimer: expireTimer ?? null,
      expireTimerVersion: expireTimerVersion ?? null,
      profileKey: profileKey ?? null,
      pollVote: {
        targetAuthorAciBinary: toAciObject(
          pollVote.targetAuthorAci
        ).getRawUuidBytes(),
        targetSentTimestamp: BigInt(pollVote.targetTimestamp),
        optionIndexes: pollVote.optionIndexes.slice(),
        voteCount: pollVote.voteCount,
      },

      body: null,
      attachments: null,
      flags: null,
      quote: null,
      contact: null,
      preview: null,
      sticker: null,
      requiredProtocolVersion: null,
      isViewOnce: null,
      reaction: null,
      delete: null,
      bodyRanges: null,
      groupCallUpdate: null,
      payment: null,
      storyContext: null,
      giftBadge: null,
      pollCreate: null,
      pollTerminate: null,
      pinMessage: null,
      unpinMessage: null,
      adminDelete: null,
    };
  }

  async getPollVoteDataMessage({
    groupV2,
    timestamp,
    profileKey,
    expireTimer,
    expireTimerVersion,
    pollVote,
  }: PollVoteBuildOptions): Promise<Uint8Array<ArrayBuffer>> {
    const proto = this.createDataMessageProtoForPollVote({
      groupV2,
      timestamp,
      profileKey,
      expireTimer,
      expireTimerVersion,
      pollVote,
    });
    return Proto.DataMessage.encode(proto);
  }

  async getPollVoteContentMessage({
    groupV2,
    timestamp,
    profileKey,
    expireTimer,
    expireTimerVersion,
    pollVote,
  }: PollVoteBuildOptions): Promise<Proto.Content.Params> {
    const dataMessage = this.createDataMessageProtoForPollVote({
      groupV2,
      timestamp,
      profileKey,
      expireTimer,
      expireTimerVersion,
      pollVote,
    });
    return {
      content: {
        dataMessage,
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };
  }

  createDataMessageProtoForPollTerminate({
    groupV2,
    timestamp,
    profileKey,
    expireTimer,
    expireTimerVersion,
    pollTerminate,
  }: PollTerminateBuildOptions): Proto.DataMessage.Params {
    return {
      timestamp: BigInt(timestamp),
      groupV2: groupV2
        ? {
            masterKey: groupV2.masterKey,
            revision: groupV2.revision,
            groupChange: null,
          }
        : null,
      expireTimer: expireTimer ?? null,
      expireTimerVersion: expireTimerVersion ?? null,
      profileKey: profileKey ?? null,
      pollTerminate: {
        targetSentTimestamp: BigInt(pollTerminate.targetTimestamp),
      },

      body: null,
      attachments: null,
      flags: null,
      quote: null,
      contact: null,
      preview: null,
      sticker: null,
      requiredProtocolVersion: null,
      isViewOnce: null,
      reaction: null,
      delete: null,
      bodyRanges: null,
      groupCallUpdate: null,
      payment: null,
      storyContext: null,
      giftBadge: null,
      pollCreate: null,
      pollVote: null,
      pinMessage: null,
      unpinMessage: null,
      adminDelete: null,
    };
  }

  async getPollTerminateContentMessage({
    groupV2,
    timestamp,
    profileKey,
    expireTimer,
    expireTimerVersion,
    pollTerminate,
  }: PollTerminateBuildOptions): Promise<Proto.Content.Params> {
    const dataMessage = this.createDataMessageProtoForPollTerminate({
      groupV2,
      timestamp,
      profileKey,
      expireTimer,
      expireTimerVersion,
      pollTerminate,
    });
    return {
      content: {
        dataMessage,
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };
  }

  async getStoryMessage({
    allowsReplies,
    bodyRanges,
    fileAttachment,
    groupV2,
    profileKey,
    textAttachment,
  }: {
    allowsReplies?: boolean;
    bodyRanges?: Array<RawBodyRange>;
    fileAttachment?: UploadedAttachmentType;
    groupV2?: GroupV2InfoType;
    profileKey: Uint8Array<ArrayBuffer>;
    textAttachment?: OutgoingTextAttachmentType;
  }): Promise<Proto.StoryMessage.Params> {
    let attachment: Proto.StoryMessage.Params['attachment'];

    if (textAttachment) {
      attachment = {
        textAttachment: this.getTextAttachmentProto(textAttachment),
      };
    } else if (fileAttachment) {
      attachment = { fileAttachment };
    } else {
      attachment = null;
    }

    return {
      profileKey,
      allowsReplies: Boolean(allowsReplies),
      group: groupV2
        ? {
            masterKey: groupV2.masterKey,
            revision: groupV2.revision,
            groupChange: groupV2.groupChange ?? null,
          }
        : null,
      bodyRanges: fileAttachment
        ? (bodyRanges?.map(toBodyRange) ?? null)
        : null,
      attachment,
    };
  }

  async getContentMessage(
    options: Readonly<MessageOptionsType> &
      Readonly<{
        includePniSignatureMessage?: boolean;
      }>
  ): Promise<Proto.Content.Params> {
    const message = await this.getHydratedMessage(options);
    const dataMessage = message.toProto();

    const contentMessage: Proto.Content.Params = options.targetTimestampForEdit
      ? {
          content: {
            editMessage: {
              dataMessage,
              targetSentTimestamp: BigInt(options.targetTimestampForEdit),
            },
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        }
      : {
          content: {
            dataMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        };

    const { includePniSignatureMessage } = options;
    if (includePniSignatureMessage) {
      strictAssert(
        message.recipients.length === 1,
        'getContentMessage: includePniSignatureMessage is single recipient only'
      );

      const conversation = window.ConversationController.get(
        message.recipients[0]
      );

      addPniSignatureMessageToProto({
        conversation,
        proto: contentMessage,
        reason: `getContentMessage(${message.timestamp})`,
      });
    }

    return contentMessage;
  }

  async getHydratedMessage(
    attributes: Readonly<MessageOptionsType>
  ): Promise<Message> {
    const message = new Message(attributes);

    return message;
  }

  getTypingContentMessage(
    options: Readonly<{
      recipientId?: ServiceIdString;
      groupId?: Uint8Array<ArrayBuffer>;
      groupMembers: ReadonlyArray<ServiceIdString>;
      isTyping: boolean;
      timestamp?: number;
    }>
  ): Proto.Content.Params {
    const ACTION_ENUM = Proto.TypingMessage.Action;
    const { recipientId, groupId, isTyping, timestamp } = options;

    if (!recipientId && !groupId) {
      throw new Error(
        'getTypingContentMessage: Need to provide either recipientId or groupId!'
      );
    }

    const finalTimestamp = timestamp || Date.now();
    const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED;

    const content: Proto.Content.Params = {
      content: {
        typingMessage: {
          groupId: groupId ?? null,
          action,
          timestamp: BigInt(finalTimestamp),
        },
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };

    if (recipientId) {
      addPniSignatureMessageToProto({
        conversation: window.ConversationController.get(recipientId),
        proto: content,
        reason: `getTypingContentMessage(${finalTimestamp})`,
      });
    }

    return content;
  }

  getAttrsFromGroupOptions(
    options: Readonly<GroupMessageOptionsType>
  ): MessageOptionsType {
    const {
      deleteForEveryone,
      attachments,
      bodyRanges,
      contact,
      expireTimer,
      flags,
      groupCallUpdate,
      groupV2,
      isViewOnce,
      body,
      preview,
      profileKey,
      quote,
      reaction,
      sticker,
      storyContext,
      targetTimestampForEdit,
      timestamp,
      pinMessage,
      unpinMessage,
      pollCreate,
    } = options;

    if (!groupV2) {
      throw new Error(
        'getAttrsFromGroupOptions: No groupv2 information provided!'
      );
    }

    const myAci = itemStorage.user.getCheckedAci();

    const groupMembers = groupV2?.members || [];

    const blockedIdentifiers = new Set(
      concat(
        itemStorage.blocked.getBlockedServiceIds(),
        itemStorage.blocked.getBlockedNumbers()
      )
    );

    const recipients = groupMembers.filter(
      recipient => recipient !== myAci && !blockedIdentifiers.has(recipient)
    );

    return {
      deleteForEveryone,
      attachments,
      bodyRanges,
      body,
      contact,
      expireTimer,
      expireTimerVersion: undefined,
      flags,
      groupCallUpdate,
      groupV2,
      isViewOnce,
      preview,
      profileKey,
      quote,
      reaction,
      recipients,
      sticker,
      storyContext,
      targetTimestampForEdit,
      timestamp,
      pinMessage,
      unpinMessage,
      pollCreate,
    };
  }

  static padSyncMessage(
    params: RequireExactlyOne<Omit<Proto.SyncMessage.Params, 'padding'>>
  ): Proto.SyncMessage.Params {
    return {
      read: null,
      viewed: null,
      stickerPackOperation: null,
      content: null,
      ...params,
      padding: this.getRandomPadding(),
    };
  }

  // Low-level sends

  async sendMessage({
    messageOptions,
    contentHint,
    groupId,
    options,
    urgent,
    story,
    includePniSignatureMessage,
  }: Readonly<{
    messageOptions: MessageOptionsType;
    contentHint: number;
    groupId: string | undefined;
    options?: SendOptionsType;
    urgent: boolean;
    story?: boolean;
    includePniSignatureMessage?: boolean;
  }>): Promise<CallbackResultType> {
    const proto = await this.getContentMessage({
      ...messageOptions,
      includePniSignatureMessage,
    });

    return new Promise((resolve, reject) => {
      drop(
        this.sendMessageProto({
          callback: (res: CallbackResultType) => {
            if (res.errors && res.errors.length > 0) {
              reject(new SendMessageProtoError(res));
            } else {
              resolve(res);
            }
          },
          contentHint,
          groupId,
          options,
          proto,
          recipients: messageOptions.recipients || [],
          timestamp: messageOptions.timestamp,
          urgent,
          story,
        })
      );
    });
  }

  // Note: all the other low-level sends call this, so it is a chokepoint for 1:1 sends
  //   The chokepoint for group sends is sendContentMessageToGroup
  async sendMessageProto({
    callback,
    contentHint,
    groupId,
    options,
    proto,
    recipients,
    sendLogCallback,
    story,
    timestamp,
    urgent,
  }: Readonly<{
    callback: (result: CallbackResultType) => void;
    contentHint: number;
    groupId: string | undefined;
    options?: SendOptionsType;
    proto: Proto.Content.Params | PlaintextContent;
    recipients: ReadonlyArray<ServiceIdString>;
    sendLogCallback?: SendLogCallbackType;
    story?: boolean;
    timestamp: number;
    urgent: boolean;
  }>): Promise<void> {
    try {
      if (accountManager.areKeysOutOfDate(ServiceIdKind.ACI)) {
        log.warn(
          `sendMessageProto/${timestamp}: Keys are out of date; updating before send`
        );
        await accountManager.maybeUpdateKeys(ServiceIdKind.ACI);
        if (accountManager.areKeysOutOfDate(ServiceIdKind.ACI)) {
          throw new Error('Keys still out of date after update');
        }
      }
    } catch (error) {
      // TODO: DESKTOP-5642
      callback({
        dataMessage: undefined,
        editMessage: undefined,
        errors: [error],
      });
      return;
    }

    const outgoing = new OutgoingMessage({
      callback,
      contentHint,
      groupId,
      serviceIds: recipients,
      message: proto,
      options,
      sendLogCallback,
      story,
      timestamp,
      urgent,
    });

    recipients.forEach(serviceId => {
      drop(
        this.queueJobForServiceId(serviceId, async () =>
          outgoing.sendToServiceId(serviceId)
        )
      );
    });
  }

  async sendMessageProtoAndWait({
    timestamp,
    recipients,
    proto,
    contentHint,
    groupId,
    options,
    urgent,
    story,
  }: Readonly<{
    timestamp: number;
    recipients: Array<ServiceIdString>;
    proto: Proto.Content.Params | PlaintextContent;
    contentHint: number;
    groupId: string | undefined;
    options?: SendOptionsType;
    urgent: boolean;
    story?: boolean;
  }>): Promise<CallbackResultType> {
    return new Promise((resolve, reject) => {
      const callback = (result: CallbackResultType) => {
        if (result && result.errors && result.errors.length > 0) {
          reject(new SendMessageProtoError(result));
          return;
        }
        resolve(result);
      };

      drop(
        this.sendMessageProto({
          callback,
          contentHint,
          groupId,
          options,
          proto,
          recipients,
          timestamp,
          urgent,
          story,
        })
      );
    });
  }

  async sendIndividualProto({
    contentHint,
    groupId,
    serviceId,
    options,
    proto,
    timestamp,
    urgent,
  }: Readonly<{
    contentHint: number;
    groupId?: string;
    serviceId: ServiceIdString | undefined;
    options?: SendOptionsType;
    proto: Proto.Content.Params | PlaintextContent;
    timestamp: number;
    urgent: boolean;
  }>): Promise<CallbackResultType> {
    assertDev(serviceId, "ServiceId can't be undefined");
    return new Promise((resolve, reject) => {
      const callback = (res: CallbackResultType) => {
        if (res && res.errors && res.errors.length > 0) {
          reject(new SendMessageProtoError(res));
        } else {
          resolve(res);
        }
      };
      drop(
        this.sendMessageProto({
          callback,
          contentHint,
          groupId,
          options,
          proto,
          recipients: [serviceId],
          timestamp,
          urgent,
        })
      );
    });
  }

  // You might wonder why this takes a groupId. models/messages.resend() can send a group
  //   message to just one person.
  async sendMessageToServiceId({
    messageOptions,
    contentHint,
    groupId,
    serviceId,
    options,
    story,
    urgent,
    includePniSignatureMessage,
  }: Readonly<{
    serviceId: ServiceIdString;
    groupId: string | undefined;
    messageOptions: Omit<MessageOptionsType, 'recipients'>;
    contentHint: number;
    options?: SendOptionsType;
    story?: boolean;
    urgent: boolean;
    includePniSignatureMessage?: boolean;
  }>): Promise<CallbackResultType> {
    return this.sendMessage({
      messageOptions: {
        ...messageOptions,
        recipients: [serviceId],
      },
      contentHint,
      groupId,
      options,
      story,
      urgent,
      includePniSignatureMessage,
    });
  }

  // Support for sync messages

  // Note: this is used for sending real messages to your other devices after sending a
  //   message to others.
  async sendSyncMessage({
    encodedDataMessage,
    encodedEditMessage,
    timestamp,
    destinationE164,
    destinationServiceId,
    expirationStartTimestamp,
    conversationIdsSentTo = [],
    conversationIdsWithSealedSender = new Set(),
    isUpdate,
    urgent,
    options,
    storyMessage,
    storyMessageRecipients,
  }: Readonly<{
    encodedDataMessage?: Uint8Array<ArrayBuffer>;
    encodedEditMessage?: Uint8Array<ArrayBuffer>;
    timestamp: number;
    destinationE164: string | undefined;
    destinationServiceId: ServiceIdString | undefined;
    expirationStartTimestamp: number | null;
    conversationIdsSentTo?: Iterable<string>;
    conversationIdsWithSealedSender?: Set<string>;
    isUpdate?: boolean;
    urgent: boolean;
    options?: SendOptionsType;
    storyMessage?: Proto.StoryMessage.Params;
    storyMessageRecipients?: StoryMessageRecipientsType;
  }>): Promise<CallbackResultType> {
    const myAci = itemStorage.user.getCheckedAci();

    let editMessage: Proto.EditMessage.Params | null;
    let message: Proto.DataMessage.Params | null;
    if (encodedEditMessage) {
      editMessage = Proto.EditMessage.decode(encodedEditMessage);
      message = null;
    } else if (encodedDataMessage) {
      message = Proto.DataMessage.decode(encodedDataMessage);
      editMessage = null;
    } else {
      message = null;
      editMessage = null;
    }

    // Though this field has 'unidentified' in the name, it should have entries for each
    //   number we sent to.
    let unidentifiedStatus: Array<Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus.Params> | null;
    if (isEmpty(conversationIdsSentTo)) {
      unidentifiedStatus = null;
    } else {
      unidentifiedStatus = await pMap(
        conversationIdsSentTo,
        async (
          conversationId
        ): Promise<Proto.SyncMessage.Sent.UnidentifiedDeliveryStatus.Params> => {
          const conv = window.ConversationController.get(conversationId);
          const serviceId = conv?.getServiceId();
          let destinationPniIdentityKey: Uint8Array<ArrayBuffer> | null = null;
          if (conv) {
            if (isPniString(serviceId)) {
              const pniIdentityKey =
                await signalProtocolStore.loadIdentityKey(serviceId);
              if (pniIdentityKey) {
                destinationPniIdentityKey = pniIdentityKey;
              }
            }
          }
          return {
            unidentified: conversationIdsWithSealedSender.has(conversationId),
            ...(isProtoBinaryEncodingEnabled()
              ? {
                  destinationServiceId: null,
                  destinationServiceIdBinary: serviceId
                    ? toServiceIdObject(serviceId).getServiceIdBinary()
                    : null,
                }
              : {
                  destinationServiceId: serviceId ?? null,
                  destinationServiceIdBinary: null,
                }),
            destinationPniIdentityKey,
          };
        },
        { concurrency: 10 }
      );
    }

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        sent: {
          timestamp: BigInt(timestamp),
          destinationE164: destinationE164 ?? null,
          ...(isProtoBinaryEncodingEnabled()
            ? {
                destinationServiceId: null,
                destinationServiceIdBinary: destinationServiceId
                  ? toServiceIdObject(destinationServiceId).getServiceIdBinary()
                  : null,
              }
            : {
                destinationServiceId: destinationServiceId ?? null,
                destinationServiceIdBinary: null,
              }),
          expirationStartTimestamp: expirationStartTimestamp
            ? BigInt(expirationStartTimestamp)
            : null,
          storyMessage: storyMessage ?? null,
          storyMessageRecipients:
            storyMessageRecipients?.map(
              (
                recipient
              ): Proto.SyncMessage.Sent.StoryMessageRecipient.Params => {
                return {
                  ...(isProtoBinaryEncodingEnabled()
                    ? {
                        destinationServiceId: null,
                        destinationServiceIdBinary:
                          recipient.destinationServiceId
                            ? toServiceIdObject(
                                recipient.destinationServiceId
                              ).getServiceIdBinary()
                            : null,
                      }
                    : {
                        destinationServiceId:
                          recipient.destinationServiceId ?? null,
                        destinationServiceIdBinary: null,
                      }),
                  isAllowedToReply: recipient.isAllowedToReply,
                  distributionListIds: recipient.distributionListIds,
                };
              }
            ) ?? null,
          unidentifiedStatus,
          editMessage,
          message,
          isRecipientUpdate: Boolean(isUpdate),
        },
      },
    });

    return this.sendIndividualProto({
      serviceId: myAci,
      proto: {
        content: {
          syncMessage,
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      },
      timestamp,
      contentHint: ContentHint.Resendable,
      options,
      urgent,
    });
  }

  static getRequestBlockSyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const request: Proto.SyncMessage.Request.Params = {
      type: Proto.SyncMessage.Request.Type.BLOCKED,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        request,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'blockSyncRequest',
      urgent: false,
    };
  }

  static getRequestConfigurationSyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const request: Proto.SyncMessage.Request.Params = {
      type: Proto.SyncMessage.Request.Type.CONFIGURATION,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        request,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'configurationSyncRequest',
      urgent: false,
    };
  }

  static getRequestContactSyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const request: Proto.SyncMessage.Request.Params = {
      type: Proto.SyncMessage.Request.Type.CONTACTS,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        request,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'contactSyncRequest',
      urgent: true,
    };
  }

  static getFetchManifestSyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const fetchLatest: Proto.SyncMessage.FetchLatest.Params = {
      type: Proto.SyncMessage.FetchLatest.Type.STORAGE_MANIFEST,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        fetchLatest,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'fetchLatestManifestSync',
      urgent: false,
    };
  }

  static getFetchLocalProfileSyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const fetchLatest: Proto.SyncMessage.FetchLatest.Params = {
      type: Proto.SyncMessage.FetchLatest.Type.LOCAL_PROFILE,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        fetchLatest,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'fetchLocalProfileSync',
      urgent: false,
    };
  }

  static getRequestKeySyncMessage(): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const request: Proto.SyncMessage.Request.Params = {
      type: Proto.SyncMessage.Request.Type.KEYS,
    };

    const syncMessage = this.padSyncMessage({
      content: {
        request,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'keySyncRequest',
      urgent: true,
    };
  }

  static getDeleteForMeSyncMessage(
    data: DeleteForMeSyncEventData
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const deleteForMe = {
      messageDeletes:
        new Array<Proto.SyncMessage.DeleteForMe.MessageDeletes.Params>(),
      conversationDeletes:
        new Array<Proto.SyncMessage.DeleteForMe.ConversationDelete.Params>(),
      localOnlyConversationDeletes:
        new Array<Proto.SyncMessage.DeleteForMe.LocalOnlyConversationDelete.Params>(),
      attachmentDeletes:
        new Array<Proto.SyncMessage.DeleteForMe.AttachmentDelete.Params>(),
    } satisfies Proto.SyncMessage.DeleteForMe.Params;

    const messageDeletes = new Map<string, Array<DeleteMessageSyncTarget>>();

    data.forEach(item => {
      if (item.type === 'delete-message') {
        const conversation = getConversationFromTarget(item.conversation);
        if (!conversation) {
          throw new Error(
            'getDeleteForMeSyncMessage: Failed to find conversation for delete-message'
          );
        }
        const existing = messageDeletes.get(conversation.id);
        if (existing) {
          existing.push(item);
        } else {
          messageDeletes.set(conversation.id, [item]);
        }
      } else if (item.type === 'delete-conversation') {
        const mostRecentMessages =
          item.mostRecentMessages.map(toAddressableMessage);
        const mostRecentNonExpiringMessages =
          item.mostRecentNonExpiringMessages?.map(toAddressableMessage) ?? null;
        const conversation = toConversationIdentifier(item.conversation);

        deleteForMe.conversationDeletes.push({
          conversation,
          isFullDelete: true,
          mostRecentMessages,
          mostRecentNonExpiringMessages,
        });
      } else if (item.type === 'delete-local-conversation') {
        const conversation = toConversationIdentifier(item.conversation);

        deleteForMe.localOnlyConversationDeletes.push({
          conversation,
        });
      } else if (item.type === 'delete-single-attachment') {
        const conversation = toConversationIdentifier(item.conversation);
        const targetMessage = toAddressableMessage(item.message);

        deleteForMe.attachmentDeletes.push({
          conversation,
          targetMessage,
          clientUuid:
            item.clientUuid == null ? null : uuidToBytes(item.clientUuid),
          fallbackDigest:
            item.fallbackDigest == null
              ? null
              : Bytes.fromBase64(item.fallbackDigest),
          fallbackPlaintextHash:
            item.fallbackPlaintextHash == null
              ? null
              : Bytes.fromHex(item.fallbackPlaintextHash),
        });
      } else {
        throw missingCaseError(item);
      }
    });

    if (messageDeletes.size > 0) {
      for (const [conversationId, items] of messageDeletes.entries()) {
        const first = items[0];
        if (!first) {
          throw new Error('Failed to fetch first from items');
        }
        const messages = items.map(item => toAddressableMessage(item.message));
        const conversation = toConversationIdentifier(first.conversation);

        if (items.length > MAX_MESSAGE_COUNT) {
          log.warn(
            `getDeleteForMeSyncMessage: Sending ${items.length} message deletes for conversationId ${conversationId}`
          );
        }

        deleteForMe.messageDeletes.push({
          messages,
          conversation,
        });
      }
    }

    const syncMessage = this.padSyncMessage({
      content: {
        deleteForMe,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'deleteForMeSync',
      urgent: false,
    };
  }

  static getAttachmentBackfillSyncMessage(
    targetConversation: ConversationIdentifier,
    targetMessage: AddressableMessage
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const syncMessage = this.padSyncMessage({
      content: {
        attachmentBackfillRequest: {
          targetMessage: toAddressableMessage(targetMessage),
          targetConversation: toConversationIdentifier(targetConversation),
        },
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'attachmentBackfillRequestSync',
      urgent: false,
    };
  }

  static getClearCallHistoryMessage(
    latestCall: CallHistoryDetails
  ): SingleProtoJobData {
    const ourAci = itemStorage.user.getCheckedAci();
    const callLogEvent: Proto.SyncMessage.CallLogEvent.Params = {
      type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
      timestamp: BigInt(latestCall.timestamp),
      conversationId: getBytesForPeerId(latestCall),
      callId: getCallIdForProto(latestCall),
    };

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        callLogEvent,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: ourAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'callLogEventSync',
      urgent: false,
    };
  }

  static getDeleteCallEvent(callDetails: CallDetails): SingleProtoJobData {
    const ourAci = itemStorage.user.getCheckedAci();
    const { mode } = callDetails;
    let status;
    if (mode === CallMode.Adhoc) {
      status = AdhocCallStatus.Deleted;
    } else if (mode === CallMode.Direct) {
      status = DirectCallStatus.Deleted;
    } else if (mode === CallMode.Group) {
      status = GroupCallStatus.Deleted;
    } else {
      throw missingCaseError(mode);
    }
    const callEvent = getProtoForCallHistory({
      ...callDetails,
      status,
    });

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        callEvent,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: ourAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'callLogEventSync',
      urgent: false,
    };
  }

  async syncReadMessages(
    reads: ReadonlyArray<{
      senderAci?: AciString;
      timestamp: number;
    }>,
    options?: Readonly<SendOptionsType>
  ): Promise<CallbackResultType> {
    const myAci = itemStorage.user.getCheckedAci();

    const read = new Array<Proto.SyncMessage.Read.Params>();
    for (const r of reads) {
      if (isProtoBinaryEncodingEnabled()) {
        read.push({
          timestamp: BigInt(r.timestamp),
          senderAci: null,
          senderAciBinary: r.senderAci
            ? toAciObject(r.senderAci).getRawUuidBytes()
            : null,
        });
      } else {
        read.push({
          timestamp: BigInt(r.timestamp),
          senderAci: r.senderAci ?? null,
          senderAciBinary: null,
        });
      }
    }

    const syncMessage = MessageSender.padSyncMessage({
      read,
    });

    return this.sendIndividualProto({
      serviceId: myAci,
      proto: {
        content: {
          syncMessage,
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      },
      timestamp: Date.now(),
      contentHint: ContentHint.Resendable,
      options,
      urgent: true,
    });
  }

  async syncView(
    views: ReadonlyArray<{
      senderAci?: AciString;
      timestamp: number;
    }>,
    options?: SendOptionsType
  ): Promise<CallbackResultType> {
    const myAci = itemStorage.user.getCheckedAci();

    const viewed = views.map(
      ({ senderAci, timestamp }): Proto.SyncMessage.Viewed.Params => {
        if (isProtoBinaryEncodingEnabled()) {
          return {
            timestamp: BigInt(timestamp),
            senderAci: null,
            senderAciBinary: senderAci
              ? toAciObject(senderAci).getRawUuidBytes()
              : null,
          };
        }
        return {
          timestamp: BigInt(timestamp),
          senderAci: senderAci ?? null,
          senderAciBinary: null,
        };
      }
    );

    const syncMessage = MessageSender.padSyncMessage({
      viewed,
    });

    return this.sendIndividualProto({
      serviceId: myAci,
      proto: {
        content: {
          syncMessage,
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      },
      timestamp: Date.now(),
      contentHint: ContentHint.Resendable,
      options,
      urgent: false,
    });
  }

  async syncViewOnceOpen(
    viewOnceOpens: ReadonlyArray<{
      senderAci?: AciString;
      senderE164?: string;
      timestamp: number;
    }>,
    options?: Readonly<SendOptionsType>
  ): Promise<CallbackResultType> {
    if (viewOnceOpens.length !== 1) {
      throw new Error(
        `syncViewOnceOpen: ${viewOnceOpens.length} opens provided. Can only handle one.`
      );
    }
    // oxlint-disable-next-line typescript/no-non-null-assertion
    const { senderAci, timestamp } = viewOnceOpens[0]!;

    if (!senderAci) {
      throw new Error('syncViewOnceOpen: Missing senderAci');
    }

    const myAci = itemStorage.user.getCheckedAci();

    const viewOnceOpen: Proto.SyncMessage.ViewOnceOpen.Params = {
      timestamp: BigInt(timestamp),
      senderAci: null,
      senderAciBinary: null,
    };
    if (isProtoBinaryEncodingEnabled()) {
      viewOnceOpen.senderAciBinary = toAciObject(senderAci).getRawUuidBytes();
    } else {
      viewOnceOpen.senderAci = senderAci;
    }

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        viewOnceOpen,
      },
    });

    return this.sendIndividualProto({
      serviceId: myAci,
      proto: {
        content: {
          syncMessage,
        },
        pniSignatureMessage: null,
        senderKeyDistributionMessage: null,
      },
      timestamp: Date.now(),
      contentHint: ContentHint.Resendable,
      options,
      urgent: false,
    });
  }

  static getBlockSync(
    options: Readonly<{
      e164s: Array<string>;
      acis: Array<AciString>;
      groupIds: Array<Uint8Array<ArrayBuffer>>;
    }>
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const blocked: Proto.SyncMessage.Blocked.Params = {
      numbers: options.e164s,
      acisBinary: null,
      acis: null,
      groupIds: options.groupIds,
    };
    if (isProtoBinaryEncodingEnabled()) {
      blocked.acisBinary = options.acis.map(aci =>
        toAciObject(aci).getRawUuidBytes()
      );
    } else {
      blocked.acis = options.acis;
    }

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        blocked,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'blockSync',
      urgent: false,
    };
  }

  static getMessageRequestResponseSync(
    options: Readonly<{
      threadAci?: AciString;
      groupId?: Uint8Array<ArrayBuffer>;
      type: number;
    }>
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    const messageRequestResponse: Proto.SyncMessage.MessageRequestResponse.Params =
      {
        type: options.type,
        groupId: options.groupId ? options.groupId : null,
        threadAciBinary: null,
        threadAci: null,
      };

    if (options.threadAci !== undefined) {
      if (isProtoBinaryEncodingEnabled()) {
        messageRequestResponse.threadAciBinary = toAciObject(
          options.threadAci
        ).getRawUuidBytes();
      } else {
        messageRequestResponse.threadAci = options.threadAci;
      }
    }
    const syncMessage = MessageSender.padSyncMessage({
      content: {
        messageRequestResponse,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'messageRequestSync',
      urgent: false,
    };
  }

  static getStickerPackSync(
    operations: ReadonlyArray<{
      packId: string;
      packKey: string;
      installed: boolean;
    }>
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();
    const ENUM = Proto.SyncMessage.StickerPackOperation.Type;

    const stickerPackOperation = operations.map(
      (item): Proto.SyncMessage.StickerPackOperation.Params => {
        const { packId, packKey, installed } = item;

        return {
          packId: Bytes.fromHex(packId),
          packKey: Bytes.fromBase64(packKey),
          type: installed ? ENUM.INSTALL : ENUM.REMOVE,
        };
      }
    );

    const syncMessage = MessageSender.padSyncMessage({
      stickerPackOperation,
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'stickerPackSync',
      urgent: false,
    };
  }

  static getVerificationSync(
    destinationE164: string | undefined,
    destinationAci: AciString | undefined,
    state: number,
    identityKey: Readonly<Uint8Array<ArrayBuffer>>
  ): SingleProtoJobData {
    const myAci = itemStorage.user.getCheckedAci();

    if (!destinationE164 && !destinationAci) {
      throw new Error('syncVerification: Neither e164 nor UUID were provided');
    }

    const verified: Proto.Verified.Params = {
      state,
      identityKey,
      nullMessage: MessageSender.getRandomPadding(),
      destinationAci: null,
      destinationAciBinary: null,
    };
    if (destinationAci) {
      if (isProtoBinaryEncodingEnabled()) {
        verified.destinationAciBinary =
          toAciObject(destinationAci).getRawUuidBytes();
      } else {
        verified.destinationAci = destinationAci;
      }
    }

    const syncMessage = MessageSender.padSyncMessage({
      content: {
        verified,
      },
    });

    return {
      contentHint: ContentHint.Resendable,
      serviceId: myAci,
      isSyncMessage: true,
      protoBase64: Bytes.toBase64(
        Proto.Content.encode({
          content: {
            syncMessage,
          },
          pniSignatureMessage: null,
          senderKeyDistributionMessage: null,
        })
      ),
      type: 'verificationSync',
      urgent: false,
    };
  }

  // Sending messages to contacts

  async sendCallingMessage(
    serviceId: ServiceIdString,
    callMessage: Readonly<Proto.CallMessage.Params>,
    timestamp: number,
    urgent: boolean,
    options?: Readonly<SendOptionsType>
  ): Promise<CallbackResultType> {
    const recipients = [serviceId];

    const contentMessage: Proto.Content.Params = {
      content: {
        callMessage,
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };

    const conversation = window.ConversationController.get(serviceId);

    addPniSignatureMessageToProto({
      conversation,
      proto: contentMessage,
      reason: `sendCallingMessage(${timestamp})`,
    });

    return this.sendMessageProtoAndWait({
      timestamp,
      recipients,
      proto: contentMessage,
      contentHint: ContentHint.Default,
      groupId: undefined,
      options,
      urgent,
    });
  }

  async sendDeliveryReceipt(
    options: Readonly<{
      senderAci: AciString;
      timestamps: Array<number>;
      isDirectConversation: boolean;
      options?: Readonly<SendOptionsType>;
    }>
  ): Promise<CallbackResultType> {
    return this.#sendReceiptMessage({
      ...options,
      type: Proto.ReceiptMessage.Type.DELIVERY,
    });
  }

  async sendReadReceipt(
    options: Readonly<{
      senderAci: AciString;
      timestamps: Array<number>;
      isDirectConversation: boolean;
      options?: Readonly<SendOptionsType>;
    }>
  ): Promise<CallbackResultType> {
    return this.#sendReceiptMessage({
      ...options,
      type: Proto.ReceiptMessage.Type.READ,
    });
  }

  async sendViewedReceipt(
    options: Readonly<{
      senderAci: AciString;
      timestamps: Array<number>;
      isDirectConversation: boolean;
      options?: Readonly<SendOptionsType>;
    }>
  ): Promise<CallbackResultType> {
    return this.#sendReceiptMessage({
      ...options,
      type: Proto.ReceiptMessage.Type.VIEWED,
    });
  }

  async #sendReceiptMessage({
    senderAci,
    timestamps,
    type,
    isDirectConversation,
    options,
  }: Readonly<{
    senderAci: AciString;
    timestamps: Array<number>;
    type: Proto.ReceiptMessage.Type;
    isDirectConversation: boolean;
    options?: Readonly<SendOptionsType>;
  }>): Promise<CallbackResultType> {
    const timestamp = Date.now();

    const receiptMessage: Proto.ReceiptMessage.Params = {
      type,
      timestamp: timestamps.map(receiptTimestamp => BigInt(receiptTimestamp)),
    };

    const contentMessage = {
      content: {
        receiptMessage,
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };

    if (isDirectConversation) {
      const conversation = window.ConversationController.get(senderAci);

      addPniSignatureMessageToProto({
        conversation,
        proto: contentMessage,
        reason: `sendReceiptMessage(${type}, ${timestamp})`,
      });
    }

    return this.sendIndividualProto({
      serviceId: senderAci,
      proto: contentMessage,
      timestamp,
      contentHint: ContentHint.Resendable,
      options,
      urgent: false,
    });
  }

  static getNullMessage(
    options: Readonly<{
      padding?: Uint8Array<ArrayBuffer>;
    }> = {}
  ): Proto.Content.Params {
    return {
      content: {
        nullMessage: {
          padding: options.padding || MessageSender.getRandomPadding(),
        },
      },
      pniSignatureMessage: null,
      senderKeyDistributionMessage: null,
    };
  }

  // Group sends

  // Used to ensure that when we send to a group the old way, we save to the send log as
  //   we send to each recipient. Then we don't have a long delay between the first send
  //   and the final save to the database with all recipients.
  makeSendLogCallback({
    contentHint,
    messageId,
    proto,
    sendType,
    timestamp,
    urgent,
    hasPniSignatureMessage,
  }: Readonly<{
    contentHint: number;
    messageId?: string;
    proto: Uint8Array<ArrayBuffer>;
    sendType: SendTypesType;
    timestamp: number;
    urgent: boolean;
    hasPniSignatureMessage: boolean;
  }>): SendLogCallbackType {
    let initialSavePromise: Promise<number>;

    return async ({
      serviceId,
      deviceIds,
    }: {
      serviceId: ServiceIdString;
      deviceIds: Array<number>;
    }) => {
      if (!shouldSaveProto(sendType)) {
        return;
      }

      const conversation = window.ConversationController.get(serviceId);
      if (!conversation) {
        log.warn(
          `makeSendLogCallback: Unable to find conversation for serviceId ${serviceId}`
        );
        return;
      }
      const recipientServiceId = conversation.getServiceId();
      if (!recipientServiceId) {
        log.warn(
          `makeSendLogCallback: Conversation ${conversation.idForLogging()} had no UUID`
        );
        return;
      }

      if (initialSavePromise === undefined) {
        initialSavePromise = DataWriter.insertSentProto(
          {
            contentHint,
            proto,
            timestamp,
            urgent,
            hasPniSignatureMessage,
          },
          {
            recipients: { [recipientServiceId]: deviceIds },
            messageIds: messageId ? [messageId] : [],
          }
        );
        await initialSavePromise;
      } else {
        const id = await initialSavePromise;
        await DataWriter.insertProtoRecipients({
          id,
          recipientServiceId,
          deviceIds,
        });
      }
    };
  }

  // No functions should really call this; since most group sends are now via Sender Key
  async sendGroupProto({
    contentHint,
    groupId,
    options,
    proto,
    recipients,
    sendLogCallback,
    story,
    timestamp,
    urgent,
  }: Readonly<{
    contentHint: number;
    groupId: string | undefined;
    options?: SendOptionsType;
    proto: Proto.Content.Params;
    recipients: ReadonlyArray<ServiceIdString>;
    sendLogCallback?: SendLogCallbackType;
    story?: boolean;
    timestamp: number;
    urgent: boolean;
  }>): Promise<CallbackResultType> {
    const myE164 = itemStorage.user.getNumber();
    const myAci = itemStorage.user.getAci();
    const serviceIds = recipients.filter(id => id !== myE164 && id !== myAci);

    if (serviceIds.length === 0) {
      const dataMessage = proto.content?.dataMessage
        ? Proto.DataMessage.encode(proto.content.dataMessage)
        : undefined;

      const editMessage = proto.content?.editMessage
        ? Proto.EditMessage.encode(proto.content.editMessage)
        : undefined;

      return Promise.resolve({
        dataMessage,
        editMessage,
        errors: [],
        failoverServiceIds: [],
        successfulServiceIds: [],
        unidentifiedDeliveries: [],
        contentHint,
        urgent,
      });
    }

    return new Promise((resolve, reject) => {
      const callback = (res: CallbackResultType) => {
        if (res.errors && res.errors.length > 0) {
          reject(new SendMessageProtoError(res));
        } else {
          resolve(res);
        }
      };

      drop(
        this.sendMessageProto({
          callback,
          contentHint,
          groupId,
          options,
          proto,
          recipients: serviceIds,
          sendLogCallback,
          story,
          timestamp,
          urgent,
        })
      );
    });
  }

  async getSenderKeyDistributionMessage(
    distributionId: string,
    {
      throwIfNotInDatabase,
      timestamp,
    }: { throwIfNotInDatabase?: boolean; timestamp: number }
  ): Promise<Proto.Content.Params> {
    const ourAci = itemStorage.user.getCheckedAci();
    const ourDeviceId = parseIntOrThrow(
      itemStorage.user.getDeviceId(),
      'getSenderKeyDistributionMessage'
    );

    const protocolAddress = ProtocolAddress.new(ourAci, ourDeviceId);
    const address = new QualifiedAddress(
      ourAci,
      new Address(ourAci, ourDeviceId)
    );

    const senderKeyDistributionMessage =
      await signalProtocolStore.enqueueSenderKeyJob(address, async () => {
        const senderKeyStore = new SenderKeys({
          signalProtocolStore,
          ourServiceId: ourAci,
          zone: GLOBAL_ZONE,
        });

        if (throwIfNotInDatabase) {
          const key = await senderKeyStore.getSenderKey(
            protocolAddress,
            distributionId
          );
          if (!key) {
            throw new NoSenderKeyError(
              `getSenderKeyDistributionMessage: Distribution ${distributionId} was not in database as expected`
            );
          }
        }

        return SenderKeyDistributionMessage.create(
          protocolAddress,
          distributionId,
          senderKeyStore
        );
      });

    log.info(
      `getSenderKeyDistributionMessage: Building ${distributionId} with timestamp ${timestamp}`
    );
    return {
      content: null,
      pniSignatureMessage: null,
      senderKeyDistributionMessage: senderKeyDistributionMessage.serialize(),
    };
  }

  // The one group send exception - a message that should never be sent via sender key
  async sendSenderKeyDistributionMessage(
    {
      contentHint,
      distributionId,
      groupId,
      serviceIds,
      throwIfNotInDatabase,
      story,
      urgent,
    }: Readonly<{
      contentHint?: number;
      distributionId: string;
      groupId: string | undefined;
      serviceIds: ReadonlyArray<ServiceIdString>;
      throwIfNotInDatabase?: boolean;
      story?: boolean;
      urgent: boolean;
    }>,
    options?: Readonly<SendOptionsType>
  ): Promise<CallbackResultType> {
    const timestamp = Date.now();
    const contentMessage = await this.getSenderKeyDistributionMessage(
      distributionId,
      {
        throwIfNotInDatabase,
        timestamp,
      }
    );

    const sendLogCallback =
      serviceIds.length > 1
        ? this.makeSendLogCallback({
            contentHint: contentHint ?? ContentHint.Implicit,
            proto: Proto.Content.encode(contentMessage),
            sendType: 'senderKeyDistributionMessage',
            timestamp,
            urgent,
            hasPniSignatureMessage: false,
          })
        : undefined;

    return this.sendGroupProto({
      contentHint: contentHint ?? ContentHint.Implicit,
      groupId,
      options,
      proto: contentMessage,
      recipients: serviceIds,
      sendLogCallback,
      story,
      timestamp,
      urgent,
    });
  }
}

export const messageSender = new MessageSender();

// Helpers

function toAddressableMessage(
  message: AddressableMessage
): Proto.AddressableMessage.Params {
  const sentTimestamp = BigInt(message.sentAt);

  let author: Proto.AddressableMessage.Params['author'];
  if (message.type === 'aci') {
    if (isProtoBinaryEncodingEnabled()) {
      author = {
        authorServiceIdBinary: toAciObject(
          message.authorAci
        ).getServiceIdBinary(),
      };
    } else {
      author = {
        authorServiceId: message.authorAci,
      };
    }
  } else if (message.type === 'e164') {
    author = {
      authorE164: message.authorE164,
    };
  } else if (message.type === 'pni') {
    if (isProtoBinaryEncodingEnabled()) {
      author = {
        authorServiceIdBinary: toPniObject(
          message.authorPni
        ).getServiceIdBinary(),
      };
    } else {
      author = {
        authorServiceId: message.authorPni,
      };
    }
  } else {
    throw missingCaseError(message);
  }

  return {
    sentTimestamp,
    author,
  };
}

function toConversationIdentifier(
  conversation: ConversationIdentifier
): Proto.ConversationIdentifier.Params {
  if (conversation.type === 'aci') {
    if (isProtoBinaryEncodingEnabled()) {
      return {
        identifier: {
          threadServiceIdBinary: toAciObject(
            conversation.aci
          ).getServiceIdBinary(),
        },
      };
    }
    return {
      identifier: {
        threadServiceId: conversation.aci,
      },
    };
  }
  if (conversation.type === 'pni') {
    if (isProtoBinaryEncodingEnabled()) {
      return {
        identifier: {
          threadServiceIdBinary: toPniObject(
            conversation.pni
          ).getServiceIdBinary(),
        },
      };
    }
    return {
      identifier: {
        threadServiceId: conversation.pni,
      },
    };
  }
  if (conversation.type === 'group') {
    return {
      identifier: {
        threadGroupId: Bytes.fromBase64(conversation.groupId),
      },
    };
  }
  if (conversation.type === 'e164') {
    return {
      identifier: {
        threadE164: conversation.e164,
      },
    };
  }
  throw missingCaseError(conversation);
}

function toBodyRange(bodyRange: RawBodyRange): Proto.BodyRange.Params {
  const { start, length } = bodyRange;

  let associatedValue: Proto.BodyRange.Params['associatedValue'];
  if (BodyRange.isMention(bodyRange)) {
    if (isProtoBinaryEncodingEnabled()) {
      associatedValue = {
        mentionAciBinary: toAciObject(bodyRange.mentionAci).getRawUuidBytes(),
      };
    } else {
      associatedValue = {
        mentionAci: bodyRange.mentionAci,
      };
    }
  } else if (BodyRange.isFormatting(bodyRange)) {
    associatedValue = {
      style: bodyRange.style,
    };
  } else {
    throw missingCaseError(bodyRange);
  }

  return {
    start,
    length,
    associatedValue,
  };
}
