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

import lodash from 'lodash';
import pMap from 'p-map';
import Queue from 'p-queue';

import { strictAssert } from '../util/assert.std.ts';
import { dropNull } from '../util/dropNull.std.ts';
import { makeLookup } from '../util/makeLookup.std.ts';
import { maybeParseUrl } from '../util/url.std.ts';
import { getMessagesById } from '../messages/getMessagesById.preload.ts';
import * as Bytes from '../Bytes.std.ts';
import * as Errors from './errors.std.ts';
import { deriveStickerPackKey, decryptAttachmentV1 } from '../Crypto.node.ts';
import { IMAGE_WEBP, type MIMEType } from './MIME.std.ts';
import { sniffImageMimeType } from '../util/sniffImageMimeType.std.ts';
import type {
  AttachmentType,
  AttachmentWithHydratedData,
} from './Attachment.std.ts';
import type {
  StickerType as StickerFromDBType,
  StickerPackType,
  StickerPackStatusType,
  UninstalledStickerPackType,
} from '../sql/Interface.std.ts';
import { DataReader, DataWriter } from '../sql/Client.preload.ts';
import { SignalService as Proto } from '../protobuf/index.std.ts';
import { createLogger } from '../logging/log.std.ts';
import type { StickersStateType } from '../state/ducks/stickers.preload.ts';
import { MINUTE } from '../util/durations/index.std.ts';
import {
  processNewEphemeralSticker,
  processNewSticker,
  deleteTempFile,
  deleteSticker,
  readStickerData,
  writeNewStickerData,
  writeNewAttachmentData,
} from '../util/migrations.preload.ts';
import { drop } from '../util/drop.std.ts';
import { isNotNil } from '../util/isNotNil.std.ts';
import { encryptLegacyAttachment } from '../util/encryptLegacyAttachment.preload.ts';
import { AttachmentDisposition } from '../util/getLocalAttachmentUrl.std.ts';
import { isPackIdValid, redactPackId } from '../util/Stickers.std.ts';
import { getPlaintextHashForInMemoryAttachment } from '../AttachmentCrypto.node.ts';
import {
  isOnline,
  getSticker as doGetSticker,
  getStickerPackManifest,
} from '../textsecure/WebAPI.preload.ts';
import { getExistingAttachmentDataForReuse } from '../util/attachments/deduplicateAttachment.preload.ts';

const { isNumber, reject, groupBy, values, chunk } = lodash;

const log = createLogger('Stickers');

export type ActionSourceType =
  | 'startup'
  | 'syncMessage'
  | 'storageService'
  | 'ui';

export type StickerType = {
  packId: string;
  stickerId: number;
  packKey: string;
  emoji?: string;
  data?: AttachmentType;
  path?: string;
  width?: number;
  height?: number;
  version?: 2;
  localKey?: string;
};
export type StickerWithHydratedData = StickerType & {
  data: AttachmentWithHydratedData;
};

export type RecentStickerType = Readonly<{
  stickerId: number;
  packId: string;
}>;

export type BlessedType = Pick<StickerPackType, 'key' | 'status'>;

export type DownloadMap = Record<
  string,
  {
    id: string;
    key: string;
    status?: StickerPackStatusType;
  }
>;

export type StickerPackPointerType = Readonly<{
  id: string;
  key: string;
}>;

export const STICKERPACK_ID_BYTE_LEN = 16;
export const STICKERPACK_KEY_BYTE_LEN = 32;

// Number of messages loaded and saved at the same time when resolving sticker
// pack references.
const RESOLVE_REFERENCES_BATCH_SIZE = 1000;

const BLESSED_PACKS: Record<string, BlessedType> = {
  // Rocky Talk
  '42fb75e1827c0c945cfb5ca0975db03c': {
    key: '7uJ+K593PgpV6iTDQLe+hYcRpuK9m27nBEND4OQovmU=',
    status: 'downloaded',
  },
  // My Daily Life 1
  ccc89a05dc077856b57351e90697976c: {
    key: 'RXMOYPCdVWYRUiN0RTemt9nqmc7qy3eh+9aAG5YH+88=',
    status: 'downloaded',
  },
  // Zozo the French Bulldog
  fb535407d2f6497ec074df8b9c51dd1d: {
    key: 'F+lxwTQDViJ4HS7iSeZHO3dFg3ULaMEbuCt1CcaLbf0=',
    status: 'downloaded',
  },
  // Croco's Feelings
  '3044281a51307306e5442f2e9070953a': {
    key: 'xMqqhDl+GmMKWWD1SguCdTyIpeUuDe/mFbpN2A8TDL8=',
    status: 'downloaded',
  },
  // My Daily Life 2
  a2414255948558316f37c1d36c64cd28: {
    key: '/aEpNxltI28cqeEZalZULh0c72/4TivgOChxf6IK02Y=',
    status: 'downloaded',
  },
  // Cozy Season
  '684d2b7bcfc2eec6f57f2e7be0078e0f': {
    key: 'hm4Ny0obJfKwTfJwzXQnI+SmVVwKGrw/PzDcxaIBDFU=',
    status: 'downloaded',
  },
  // Chug the Mouse
  f19548e5afa38d1ce4f5c3191eba5e30: {
    key: 'LLMHZ0D2aapExsBjKQskmn0ApLAu2PnppbkCo38bvEE=',
    status: 'downloaded',
  },
  // Bandit the Cat
  '9acc9e8aba563d26a4994e69263e3b25': {
    key: 'Wm3/OUjCjvubeq+T7MN1xp/DFueAd+0mhnoU0QoPahI=',
    status: 'downloaded',
  },
  // Swoon / Hands
  e61fa0867031597467ccc036cc65d403: {
    key: 'E657GnQHMYKA6bOMEmHe044OcTi5+WSmzLtz5A9zeps=',
    status: 'downloaded',
  },
  // Swoon / Faces
  cca32f5b905208b7d0f1e17f23fdc185: {
    key: 'i/jpX3pFver+DI9bAC7wGrlbjxtbqsQBnM1ra+Cxg3o=',
    status: 'downloaded',
  },
  // Day by Day
  cfc50156556893ef9838069d3890fe49: {
    key: 'X1vqt9OCRDywCh5I65Upe2uMrf0GMeXQ2dyUnmmZ/0s=',
    status: 'downloaded',
  },
};

const STICKER_PACK_DEFAULTS: StickerPackType = {
  id: '',
  key: '',

  author: '',
  coverStickerId: 0,
  createdAt: 0,
  downloadAttempts: 0,
  status: 'ephemeral',
  stickerCount: 0,
  stickers: {},
  title: '',

  storageNeedsSync: false,
};

const DOWNLOAD_PRIORITY_NORMAL = 0;
const DOWNLOAD_PRIORITY_HIGH = 1;

let initialState: StickersStateType | undefined;
let packsToDownload: DownloadMap | undefined;
const downloadQueue = new Queue({ concurrency: 1, timeout: MINUTE * 30 });
const downloadQueueData = new Map<
  string,
  { depth: number; finalStatus: StickerPackStatusType | undefined }
>();

export async function load(): Promise<void> {
  const [packs, recentStickers] = await Promise.all([
    getPacksForRedux(),
    getRecentStickersForRedux(),
  ]);

  const blessedPacks: Record<string, boolean> = Object.create(null);
  for (const key of Object.keys(BLESSED_PACKS)) {
    blessedPacks[key] = true;
  }

  initialState = {
    packs,
    recentStickers,
    blessedPacks,
    installedPack: null,
  };

  packsToDownload = capturePacksToDownload(packs);
}

export async function createPacksFromBackup(
  packs: ReadonlyArray<StickerPackPointerType>
): Promise<void> {
  const known = new Set(packs.map(({ id }) => id));
  const pairs = packs.slice();
  const uninstalled = new Array<UninstalledStickerPackType>();

  for (const [id, { key }] of Object.entries(BLESSED_PACKS)) {
    if (known.has(id)) {
      continue;
    }

    // Blessed packs that are not in the backup were uninstalled
    pairs.push({ id, key });
    uninstalled.push({
      id,
      key: undefined,
      uninstalledAt: Date.now(),
      storageNeedsSync: false,
    });
  }

  const packsToStore = pairs.map(
    ({ id, key }): StickerPackType => ({
      ...STICKER_PACK_DEFAULTS,

      id,
      key,
      attemptedStatus: 'installed' as const,
      status: 'pending' as const,
    })
  );

  await DataWriter.createOrUpdateStickerPacks(packsToStore);
  await DataWriter.addUninstalledStickerPacks(uninstalled);

  packsToDownload = capturePacksToDownload(makeLookup(packsToStore, 'id'));
}

export async function getStickerPacksForBackup(): Promise<
  Array<StickerPackPointerType>
> {
  const result = new Array<StickerPackPointerType>();
  const stickerPacks = await DataReader.getAllStickerPacks();
  const uninstalled = new Set(
    (await DataReader.getUninstalledStickerPacks()).map(({ id }) => id)
  );
  for (const { id, key, status } of stickerPacks) {
    if (uninstalled.has(id)) {
      continue;
    }

    if (status === 'known' || status === 'ephemeral') {
      continue;
    }

    result.push({ id, key });
  }
  return result;
}

export function getDataFromLink(
  link: string
): undefined | StickerPackPointerType {
  const url = maybeParseUrl(link);
  if (!url) {
    return undefined;
  }

  const { hash } = url;
  if (!hash) {
    return undefined;
  }

  let params;
  try {
    params = new URLSearchParams(hash.slice(1));
  } catch (err) {
    return undefined;
  }

  const id = params.get('pack_id');
  if (!isPackIdValid(id)) {
    return undefined;
  }

  const key = params.get('pack_key');
  if (!key) {
    return undefined;
  }

  return { id, key };
}

export function downloadQueuedPacks(): void {
  log.info('downloadQueuedPacks');
  strictAssert(packsToDownload, 'Stickers not initialized');

  for (const [id, packToDownload] of Object.entries(packsToDownload)) {
    const { key, status } = packToDownload;

    // The queuing is done inside this function, no need to await here
    drop(
      downloadStickerPack(id, key, {
        finalStatus: status,
        suppressError: true,
        actionSource: 'startup',
      })
    );
  }

  packsToDownload = {};
}

function capturePacksToDownload(
  existingPackLookup: Record<string, StickerPackType>
): DownloadMap {
  const toDownload: DownloadMap = Object.create(null);

  // First, ensure that blessed packs are in good shape
  for (const [id, blessedPack] of Object.entries(BLESSED_PACKS)) {
    const existing = existingPackLookup[id];
    if (
      !existing ||
      (existing.status !== 'downloaded' && existing.status !== 'installed')
    ) {
      toDownload[id] = {
        id,
        ...blessedPack,
      };
    }
  }

  // Then, find error cases in packs we already know about
  for (const [id, existing] of Object.entries(existingPackLookup)) {
    if (toDownload[id]) {
      continue;
    }

    // These packs should never end up in the database, but if they do we'll delete them
    if (existing.status === 'ephemeral') {
      void deletePack(id);
      continue;
    }

    // We don't automatically download these; not until a user action kicks it off
    if (existing.status === 'known') {
      continue;
    }

    if (doesPackNeedDownload(existing)) {
      const status =
        existing.attemptedStatus === 'installed' ? 'installed' : undefined;
      toDownload[id] = {
        id,
        key: existing.key,
        status,
      };
    }
  }

  return toDownload;
}

function doesPackNeedDownload(pack?: StickerPackType): boolean {
  if (!pack) {
    return true;
  }

  const { status, stickerCount } = pack;

  if ((status === 'installed' || status === 'downloaded') && stickerCount > 0) {
    return false;
  }

  // If we don't understand a pack's status, we'll download it
  // If a pack has any other status, we'll download it
  // If a pack has zero stickers in it, we'll download it

  // Note: If a pack downloaded with less than the expected number of stickers, we're
  //   okay with that.

  return true;
}

async function getPacksForRedux(): Promise<Record<string, StickerPackType>> {
  const [packs, stickers] = await Promise.all([
    DataReader.getAllStickerPacks(),
    DataReader.getAllStickers(),
  ]);

  const stickersByPack = groupBy(stickers, sticker => sticker.packId);
  const fullSet: Array<StickerPackType> = packs.map(pack => ({
    ...pack,
    stickers: makeLookup(stickersByPack[pack.id] || [], 'id'),
  }));

  return makeLookup(fullSet, 'id');
}

async function getRecentStickersForRedux(): Promise<Array<RecentStickerType>> {
  const recent = await DataReader.getRecentStickers();
  return recent.map(sticker => ({
    packId: sticker.packId,
    stickerId: sticker.id,
  }));
}

export function getInitialState(): StickersStateType {
  strictAssert(initialState !== undefined, 'Stickers not initialized');
  return initialState;
}

function getReduxStickerActions() {
  const actions = window.reduxActions;
  strictAssert(actions && actions.stickers, 'Redux not ready');

  return actions.stickers;
}

function decryptSticker(
  packKey: string,
  ciphertext: Uint8Array<ArrayBuffer>
): Uint8Array<ArrayBuffer> {
  const binaryKey = Bytes.fromBase64(packKey);
  const derivedKey = deriveStickerPackKey(binaryKey);

  // Note this download and decrypt in memory is okay because these files are maximum
  //   300kb, enforced by the server.
  const plaintext = decryptAttachmentV1(ciphertext, derivedKey);

  return plaintext;
}

async function downloadSticker(
  packId: string,
  packKey: string,
  proto: Proto.StickerPack.Sticker.Params,
  { ephemeral }: { ephemeral?: boolean } = {}
): Promise<Omit<StickerFromDBType, 'isCoverOnly'>> {
  const { id, emoji } = proto;
  strictAssert(id != null, "Sticker id can't be null");

  const ciphertext = await doGetSticker(packId, id);
  const plaintext = decryptSticker(packKey, ciphertext);

  const sticker = ephemeral
    ? await processNewEphemeralSticker(plaintext)
    : await processNewSticker(plaintext);

  return {
    id,
    emoji: dropNull(emoji),
    ...sticker,
    packId,
  };
}

export async function savePackMetadata(
  packId: string,
  packKey: string,
  {
    messageId,
    stickerId,
    isUnresolved,
  }: { messageId: string; stickerId: number; isUnresolved: boolean }
): Promise<void> {
  const existing = getStickerPack(packId);
  if (existing) {
    return;
  }

  const { stickerPackAdded } = getReduxStickerActions();
  const pack = {
    ...STICKER_PACK_DEFAULTS,

    id: packId,
    key: packKey,
    status: 'known' as const,
  };
  stickerPackAdded(pack);

  await DataWriter.createOrUpdateStickerPack(pack);
  if (messageId) {
    await DataWriter.addStickerPackReference({
      messageId,
      packId,
      stickerId,
      isUnresolved,
    });
  }
}

export async function removeEphemeralPack(packId: string): Promise<void> {
  const existing = getStickerPack(packId);
  strictAssert(existing, `No existing sticker pack with id: ${packId}`);
  if (
    existing.status !== 'ephemeral' &&
    !(existing.status === 'error' && existing.attemptedStatus === 'ephemeral')
  ) {
    return;
  }

  const { removeStickerPack } = getReduxStickerActions();
  removeStickerPack(packId);

  const stickers = values(existing.stickers);
  const paths = stickers.map(sticker => sticker.path);
  await pMap(paths, deleteTempFile, {
    concurrency: 3,
  });

  // Remove it from database in case it made it there
  await DataWriter.deleteStickerPack(packId);
}

export async function downloadEphemeralPack(
  packId: string,
  packKey: string
): Promise<void> {
  const { stickerAdded, stickerPackAdded, stickerPackUpdated } =
    getReduxStickerActions();

  const existingPack = getStickerPack(packId);
  if (
    existingPack &&
    (existingPack.status === 'downloaded' ||
      existingPack.status === 'installed' ||
      existingPack.status === 'pending')
  ) {
    if (existingPack.status === 'pending') {
      log.info(
        `Ephemeral download for pending sticker pack ${redactPackId(
          packId
        )} requested, redownloading with priority.`
      );
      drop(downloadStickerPack(packId, packKey, { actionSource: 'ui' }));
    } else {
      log.warn(
        `Ephemeral download for sticker pack ${redactPackId(
          packId
        )} requested, we already know about it. Skipping.`
      );
    }
    return;
  }

  try {
    // Synchronous placeholder to help with race conditions
    const placeholder = {
      ...STICKER_PACK_DEFAULTS,

      id: packId,
      key: packKey,
      status: 'ephemeral' as const,
    };
    stickerPackAdded(placeholder);

    const ciphertext = await getStickerPackManifest(packId);
    const plaintext = decryptSticker(packKey, ciphertext);
    const proto = Proto.StickerPack.decode(plaintext);
    const firstStickerProto = proto.stickers ? proto.stickers[0] : null;
    const stickerCount = proto.stickers.length;

    const coverProto = proto.cover || firstStickerProto;
    const coverStickerId = coverProto ? coverProto.id : null;

    if (!coverProto || !isNumber(coverStickerId)) {
      throw new Error(
        `Sticker pack ${redactPackId(
          packId
        )} is malformed - it has no cover, and no stickers`
      );
    }

    const nonCoverStickers = reject(
      proto.stickers,
      sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
    );
    const coverSticker = proto.stickers.filter(
      sticker => isNumber(sticker.id) && sticker.id === coverStickerId
    );
    if (coverSticker[0] && !coverProto.emoji) {
      coverProto.emoji = coverSticker[0].emoji;
    }

    const coverIncludedInList = nonCoverStickers.length < stickerCount;

    const pack = {
      ...STICKER_PACK_DEFAULTS,

      id: packId,
      key: packKey,
      coverStickerId,
      stickerCount,
      status: 'ephemeral' as const,
      title: proto.title ?? '',
      author: proto.author ?? '',
    };
    stickerPackAdded(pack);

    const downloadStickerJob = async (
      stickerProto: Proto.StickerPack.Sticker.Params
    ): Promise<boolean> => {
      let stickerInfo;
      try {
        stickerInfo = await downloadSticker(packId, packKey, stickerProto, {
          ephemeral: true,
        });
      } catch (error: unknown) {
        log.error(
          `downloadEphemeralPack/downloadStickerJob error: ${Errors.toLogFormat(
            error
          )}`
        );
        return false;
      }
      const sticker = {
        ...stickerInfo,
        isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId,
      };

      const statusCheck = getStickerPackStatus(packId);
      if (statusCheck !== 'ephemeral') {
        throw new Error(
          `Ephemeral download for pack ${redactPackId(
            packId
          )} interrupted by status change. Status is now ${statusCheck}.`
        );
      }

      stickerAdded(sticker);
      return true;
    };

    // Download the cover first
    await downloadStickerJob(coverProto);

    // Then the rest
    const jobResults = await pMap(nonCoverStickers, downloadStickerJob, {
      concurrency: 3,
    });

    const successfulStickerCount = jobResults.filter(item => item).length;
    if (successfulStickerCount === 0 && nonCoverStickers.length !== 0) {
      throw new Error('downloadEphemeralPack: All stickers failed to download');
    }
  } catch (error) {
    // Because the user could install this pack while we are still downloading this
    //   ephemeral pack, we don't want to go change its status unless we're still in
    //   ephemeral mode.
    const statusCheck = getStickerPackStatus(packId);
    if (statusCheck === 'ephemeral') {
      stickerPackUpdated(packId, {
        attemptedStatus: 'ephemeral',
        status: 'error',
      });
    }
    log.error(
      `Ephemeral download error for sticker pack ${redactPackId(packId)}:`,
      Errors.toLogFormat(error)
    );
  }
}

export type DownloadStickerPackOptions = Readonly<{
  actionSource: ActionSourceType;
  finalStatus?: StickerPackStatusType;
  suppressError?: boolean;
}>;

export async function downloadStickerPack(
  packId: string,
  packKey: string,
  options: DownloadStickerPackOptions
): Promise<void> {
  // Store finalStatus. When we click on a sticker we want to redownload with priority
  // while retaining the finalStatus, so we need a way to look up the last finalStatus.
  const data = downloadQueueData.get(packId);
  const finalStatus = options.finalStatus ?? data?.finalStatus;
  const depth = data ? data.depth + 1 : 1;
  downloadQueueData.set(packId, { depth, finalStatus });

  const queueOptions = {
    priority:
      options.actionSource === 'ui'
        ? DOWNLOAD_PRIORITY_HIGH
        : DOWNLOAD_PRIORITY_NORMAL,
  };

  // This will ensure that only one download process is in progress at any given time
  return downloadQueue.add(async () => {
    try {
      await doDownloadStickerPack(packId, packKey, { ...options, finalStatus });
    } catch (error) {
      log.error(
        'doDownloadStickerPack threw an error:',
        Errors.toLogFormat(error)
      );
    } finally {
      const dataAfter = downloadQueueData.get(packId);
      if (dataAfter) {
        if (dataAfter.depth <= 1) {
          downloadQueueData.delete(packId);
        } else {
          downloadQueueData.set(packId, {
            ...dataAfter,
            depth: dataAfter.depth - 1,
          });
        }
      }
    }
  }, queueOptions);
}

async function doDownloadStickerPack(
  packId: string,
  packKey: string,
  {
    finalStatus = 'downloaded',
    actionSource,
    suppressError = false,
  }: DownloadStickerPackOptions
): Promise<void> {
  const {
    stickerAdded,
    stickerPackAdded,
    stickerPackUpdated,
    installStickerPack,
  } = getReduxStickerActions();

  if (finalStatus !== 'downloaded' && finalStatus !== 'installed') {
    throw new Error(
      `doDownloadStickerPack: invalid finalStatus of ${finalStatus} requested.`
    );
  }

  const existing = getStickerPack(packId);
  if (!doesPackNeedDownload(existing)) {
    return;
  }

  // We don't count this as an attempt if we're offline
  const attemptIncrement = isOnline() ? 1 : 0;
  const downloadAttempts =
    (existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement;
  if (downloadAttempts > 3) {
    log.warn(
      `Refusing to attempt another download for pack ${redactPackId(
        packId
      )}, attempt number ${downloadAttempts}`
    );

    if (existing && existing.status !== 'error') {
      await DataWriter.updateStickerPackStatus(packId, 'error');
      stickerPackUpdated(
        packId,
        {
          status: 'error',
        },
        { suppressError }
      );
    }

    return;
  }

  let coverProto: Proto.StickerPack.Sticker.Params | undefined;
  let coverStickerId: number | undefined;
  let coverIncludedInList = false;
  let nonCoverStickers: Array<Proto.StickerPack.Sticker.Params> = [];

  try {
    // Synchronous placeholder to help with race conditions
    const placeholder = {
      ...STICKER_PACK_DEFAULTS,

      id: packId,
      key: packKey,
      attemptedStatus: finalStatus,
      downloadAttempts,
      status: 'pending' as const,
    };
    stickerPackAdded(placeholder);

    const ciphertext = await getStickerPackManifest(packId);
    const plaintext = decryptSticker(packKey, ciphertext);
    const proto = Proto.StickerPack.decode(plaintext);
    const firstStickerProto = proto.stickers ? proto.stickers[0] : undefined;
    const stickerCount = proto.stickers.length;

    coverProto = proto.cover || firstStickerProto;
    coverStickerId = dropNull(coverProto ? coverProto.id : undefined);

    if (!coverProto || !isNumber(coverStickerId)) {
      throw new Error(
        `Sticker pack ${redactPackId(
          packId
        )} is malformed - it has no cover, and no stickers`
      );
    }

    nonCoverStickers = reject(
      proto.stickers,
      sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
    );
    const coverSticker = proto.stickers.filter(
      sticker => isNumber(sticker.id) && sticker.id === coverStickerId
    );
    if (coverSticker[0] && !coverProto.emoji) {
      coverProto.emoji = coverSticker[0].emoji;
    }

    coverIncludedInList = nonCoverStickers.length < stickerCount;

    // status can be:
    //   - 'known'
    //   - 'ephemeral' (should not hit database)
    //   - 'pending'
    //   - 'downloaded'
    //   - 'error'
    //   - 'installed'
    const pack: StickerPackType = {
      id: packId,
      key: packKey,
      attemptedStatus: finalStatus,
      coverStickerId,
      downloadAttempts,
      stickerCount,
      status: 'pending',
      createdAt: Date.now(),
      stickers: {},
      title: proto.title ?? '',
      author: proto.author ?? '',

      // Redux handles these
      storageNeedsSync: false,
    };
    await DataWriter.createOrUpdateStickerPack(pack);
    stickerPackAdded(pack);
  } catch (error) {
    log.error(
      `Error downloading manifest for sticker pack ${redactPackId(packId)}:`,
      Errors.toLogFormat(error)
    );

    const pack = {
      ...STICKER_PACK_DEFAULTS,

      id: packId,
      key: packKey,
      attemptedStatus: finalStatus,
      downloadAttempts,
      status: 'error' as const,
    };
    await DataWriter.createOrUpdateStickerPack(pack);
    stickerPackAdded(pack, { suppressError });

    return;
  }

  // We have a separate try/catch here because we're starting to download stickers here
  //   and we want to preserve more of the pack on an error.
  try {
    const downloadStickerJob = async (
      stickerProto: Proto.StickerPack.Sticker.Params
    ): Promise<boolean> => {
      try {
        const stickerInfo = await downloadSticker(
          packId,
          packKey,
          stickerProto
        );
        const sticker = {
          ...stickerInfo,
          isCoverOnly:
            !coverIncludedInList && stickerInfo.id === coverStickerId,
        };
        await DataWriter.createOrUpdateSticker(sticker);
        stickerAdded(sticker);
        return true;
      } catch (error: unknown) {
        log.error(
          `doDownloadStickerPack/downloadStickerJob error: ${Errors.toLogFormat(
            error
          )}`
        );
        return false;
      }
    };

    // Download the cover first
    await downloadStickerJob(coverProto);

    // Then the rest
    const jobResults = await pMap(nonCoverStickers, downloadStickerJob, {
      concurrency: 3,
    });

    const successfulStickerCount = jobResults.filter(item => item).length;
    if (successfulStickerCount === 0 && nonCoverStickers.length !== 0) {
      throw new Error('doDownloadStickerPack: All stickers failed to download');
    }

    // Allow for the user marking this pack as installed in the middle of our download;
    //   don't overwrite that status.
    const existingStatus = getStickerPackStatus(packId);
    if (existingStatus === 'installed') {
      // No-op
    } else if (finalStatus === 'installed') {
      installStickerPack(packId, packKey, {
        actionSource,
      });
    } else {
      // Mark the pack as complete
      await DataWriter.updateStickerPackStatus(packId, finalStatus);
      stickerPackUpdated(packId, {
        status: finalStatus,
      });
    }

    drop(safeResolveReferences(packId));
  } catch (error) {
    log.error(
      `Error downloading stickers for sticker pack ${redactPackId(packId)}:`,
      Errors.toLogFormat(error)
    );

    const errorStatus = 'error';
    await DataWriter.updateStickerPackStatus(packId, errorStatus);
    if (stickerPackUpdated) {
      stickerPackUpdated(
        packId,
        {
          attemptedStatus: finalStatus,
          status: errorStatus,
        },
        { suppressError }
      );
    }
  }
}

async function safeResolveReferences(packId: string): Promise<void> {
  try {
    await resolveReferences(packId);
  } catch (error) {
    const logId = `Stickers.resolveReferences(${redactPackId(packId)})`;
    log.error(`${logId}: failed`, Errors.toLogFormat(error));
  }
}

async function resolveReferences(packId: string): Promise<void> {
  const refs = await DataWriter.getUnresolvedStickerPackReferences(packId);
  if (refs.length === 0) {
    return;
  }

  const logId = `Stickers.resolveReferences(${redactPackId(packId)})`;
  log.info(`${logId}: resolving ${refs.length}`);

  const stickerIdToMessageIds = new Map<number, Array<string>>();
  for (const { stickerId, messageId } of refs) {
    let list = stickerIdToMessageIds.get(stickerId);
    if (list == null) {
      list = [];
      stickerIdToMessageIds.set(stickerId, list);
    }

    list.push(messageId);
  }

  await pMap(
    Array.from(stickerIdToMessageIds.entries()),
    ([stickerId, messageIds]) =>
      pMap(
        chunk(messageIds, RESOLVE_REFERENCES_BATCH_SIZE),
        async batch => {
          let attachments: Array<AttachmentType>;
          try {
            attachments = await pMap(
              messageIds,
              messageId =>
                copyStickerToAttachments({ packId, stickerId, messageId }),
              { concurrency: 3 }
            );
          } catch (error) {
            log.error(
              `${logId}: failed to copy sticker ${stickerId}`,
              Errors.toLogFormat(error)
            );
            return;
          }
          const messages = await getMessagesById(batch);

          const saves = new Array<Promise<unknown>>();
          for (const [index, message] of messages.entries()) {
            const data = attachments[index];
            strictAssert(data != null, 'Missing copied data');

            const { sticker, sent_at: sentAt } = message.attributes;
            if (!sticker) {
              log.info(`${logId}: ${sentAt} has no sticker`);
              continue;
            }

            if (sticker?.data?.path) {
              log.info(`${logId}: ${sentAt} already downloaded`);
              continue;
            }

            if (sticker.packId !== packId || sticker.stickerId !== stickerId) {
              log.info(`${logId}: ${sentAt} has different sticker`);
              continue;
            }

            message.set({
              sticker: {
                ...sticker,
                data,
              },
            });

            saves.push(window.MessageCache.saveMessage(message));
          }

          await Promise.all(saves);
        },
        { concurrency: 1 }
      ),
    { concurrency: 3 }
  );
}

export function getStickerPack(packId: string): StickerPackType | undefined {
  const state = window.reduxStore.getState();
  const { stickers } = state;
  const { packs } = stickers;
  if (!packs) {
    return undefined;
  }

  return packs[packId];
}

export function getStickerPackStatus(
  packId: string
): StickerPackStatusType | undefined {
  const pack = getStickerPack(packId);
  if (!pack) {
    return undefined;
  }

  return pack.status;
}

export function getSticker(
  packId: string,
  stickerId: number
): StickerFromDBType | undefined {
  const pack = getStickerPack(packId);

  if (!pack || !pack.stickers) {
    return undefined;
  }

  return pack.stickers[stickerId];
}

export async function copyStickerToAttachments({
  packId,
  stickerId,
  messageId,
}: {
  packId: string;
  stickerId: number;
  messageId: string;
}): Promise<AttachmentType> {
  const sticker = getSticker(packId, stickerId);
  if (!sticker) {
    throw new Error(
      `copyStickerToAttachments: Failed to find sticker ${packId}/${stickerId}`
    );
  }

  const data = await readStickerData(sticker);
  const size = data.byteLength;
  const plaintextHash = getPlaintextHashForInMemoryAttachment(data);

  let contentType: MIMEType;
  const sniffedMimeType = sniffImageMimeType(data);
  if (sniffedMimeType) {
    contentType = sniffedMimeType;
  } else {
    log.warn(
      'copyStickerToAttachments: Unable to sniff sticker MIME type; falling back to WebP'
    );
    contentType = IMAGE_WEBP;
  }

  const newSticker: AttachmentType = {
    width: sticker.width,
    height: sticker.height,
    version: sticker.version,
    size,
    contentType,
    plaintextHash,
  };

  const existingAttachmentData = await getExistingAttachmentDataForReuse({
    plaintextHash,
    contentType,
    messageId,
    logId: 'copyStickerToAttachments',
  });

  if (existingAttachmentData) {
    return {
      ...newSticker,
      ...existingAttachmentData,
    };
  }

  const newAttachmentData = await writeNewAttachmentData(data);

  return { ...newSticker, ...newAttachmentData };
}

// In the case where a sticker pack is uninstalled, we want to delete it if there are no
//   more references left. We'll delete a nonexistent reference, then check if there are
//   any references left, just like usual.
export async function maybeDeletePack(packId: string): Promise<void> {
  // This hardcoded string is fine because message ids are GUIDs
  await deletePackReference('NOT-USED', packId);
}

// We don't generally delete packs outright; we just remove references to them, and if
//   the last reference is deleted, we finally then remove the pack itself from database
//   and from disk.
export async function deletePackReference(
  messageId: string,
  packId: string
): Promise<void> {
  const isBlessed = Boolean(BLESSED_PACKS[packId]);
  if (isBlessed) {
    return;
  }

  // This call uses locking to prevent race conditions with other reference removals,
  //   or an incoming message creating a new message->pack reference
  const paths = await DataWriter.deleteStickerPackReference({
    messageId,
    packId,
  });

  // If we don't get a list of paths back, then the sticker pack was not deleted
  if (!paths) {
    return;
  }

  const { removeStickerPack } = getReduxStickerActions();
  removeStickerPack(packId);

  await pMap(paths, deleteSticker, {
    concurrency: 3,
  });
}

// The override; doesn't honor our ref-counting scheme - just deletes it all.
async function deletePack(packId: string): Promise<void> {
  const isBlessed = Boolean(BLESSED_PACKS[packId]);
  if (isBlessed) {
    return;
  }

  // This call uses locking to prevent race conditions with other reference removals,
  //   or an incoming message creating a new message->pack reference
  const paths = await DataWriter.deleteStickerPack(packId);

  const { removeStickerPack } = getReduxStickerActions();
  removeStickerPack(packId);

  await pMap(paths, deleteSticker, {
    concurrency: 3,
  });
}

export async function encryptLegacyStickers(): Promise<void> {
  const CONCURRENCY = 32;

  const all = await DataReader.getAllStickers();

  log.info(`encryptLegacyStickers: checking ${all.length}`);

  const updated = (
    await pMap(
      all,
      async sticker => {
        try {
          return await encryptLegacySticker(sticker);
        } catch (error) {
          log.error('encryptLegacyStickers: processing failed', error);
          return undefined;
        }
      },
      {
        concurrency: CONCURRENCY,
      }
    )
  ).filter(isNotNil);

  await DataWriter.createOrUpdateStickers(
    updated.map(({ sticker }) => sticker)
  );

  log.info(`encryptLegacyStickers: updated ${updated.length}`);

  await pMap(
    updated,
    async ({ cleanup }) => {
      try {
        await cleanup();
      } catch (error) {
        log.error('encryptLegacyStickers: cleanup failed', error);
      }
    },
    {
      concurrency: CONCURRENCY,
    }
  );

  log.info(`encryptLegacyStickers: cleaned up ${updated.length}`);
}

async function encryptLegacySticker(
  sticker: StickerFromDBType
): Promise<
  { sticker: StickerFromDBType; cleanup: () => Promise<void> } | undefined
> {
  const updated = await encryptLegacyAttachment(sticker, {
    logId: 'sticker',
    readAttachmentData: readStickerData,
    writeNewAttachmentData: writeNewStickerData,
    disposition: AttachmentDisposition.Sticker,
  });

  if (updated !== sticker && sticker.path) {
    return { sticker: updated, cleanup: () => deleteSticker(sticker.path) };
  }

  return undefined;
}
