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

import type { Database } from '@signalapp/better-sqlite3';
import { omit } from 'lodash';

import type { LoggerType } from '../../types/Logging';
import type {
  ServiceIdString,
  AciString,
  PniString,
} from '../../types/ServiceId';
import { normalizeServiceId, normalizePni } from '../../types/ServiceId';
import { normalizeAci } from '../../util/normalizeAci';
import type { JSONWithUnknownFields } from '../../types/Util';
import { isNotNil } from '../../util/isNotNil';

//
// Main migration function that does the following:
//
// 1. Drop indexes/triggers/generated columns
// 2. Alter tables to change column names
// 3. Re-create indexes/triggers/generated columns
// 4. Call other functions to migrate column and json values in each
//    affected table.
//

export default function updateToSchemaVersion88(
  currentVersion: number,
  db: Database,
  logger: LoggerType
): void {
  if (currentVersion >= 88) {
    return;
  }

  // See updateToSchemaVersion84
  const selectMentionsFromMessages = `
    SELECT messages.id, bodyRanges.value ->> 'mentionAci' as mentionAci,
      bodyRanges.value ->> 'start' as start,
      bodyRanges.value ->> 'length' as length
    FROM messages, json_each(messages.json ->> 'bodyRanges') as bodyRanges
    WHERE bodyRanges.value ->> 'mentionAci' IS NOT NULL
  `;

  db.transaction(() => {
    // Rename all columns and re-create all indexes first.
    db.exec(`
      --
      -- conversations
      --

      DROP INDEX conversations_uuid;

      ALTER TABLE conversations
        RENAME COLUMN uuid TO serviceId;

      -- See: updateToSchemaVersion20
      CREATE INDEX conversations_serviceId ON conversations(serviceId);

      --
      -- sessions
      --

      ALTER TABLE sessions
        RENAME COLUMN ourUuid TO ourServiceId;
      ALTER TABLE sessions
        RENAME COLUMN uuid TO serviceId;

      --
      -- messages
      --

      DROP INDEX messages_sourceUuid;
      DROP INDEX messages_preview;
      DROP INDEX messages_preview_without_story;
      DROP INDEX messages_activity;

      ALTER TABLE messages
        DROP COLUMN isGroupLeaveEventFromOther;
      ALTER TABLE messages
        DROP COLUMN isGroupLeaveEvent;

      ALTER TABLE messages
        RENAME COLUMN sourceUuid TO sourceServiceId;

      -- See: updateToSchemaVersion47
      ALTER TABLE messages
        ADD COLUMN isGroupLeaveEvent INTEGER
        GENERATED ALWAYS AS (
          type IS 'group-v2-change' AND
          json_array_length(json_extract(json, '$.groupV2Change.details')) IS 1 AND
          json_extract(json, '$.groupV2Change.details[0].type') IS 'member-remove' AND
          json_extract(json, '$.groupV2Change.from') IS NOT NULL AND
          json_extract(json, '$.groupV2Change.from') IS json_extract(json, '$.groupV2Change.details[0].aci')
        );

      ALTER TABLE messages
        ADD COLUMN isGroupLeaveEventFromOther INTEGER
        GENERATED ALWAYS AS (
          isGroupLeaveEvent IS 1
          AND
          isChangeCreatedByUs IS 0
        );

      -- See: updateToSchemaVersion25
      CREATE INDEX messages_sourceServiceId on messages(sourceServiceId);

      -- See: updateToSchemaVersion81
      CREATE INDEX messages_preview ON messages
        (conversationId, shouldAffectPreview, isGroupLeaveEventFromOther,
         received_at, sent_at);
      CREATE INDEX messages_preview_without_story ON messages
        (conversationId, shouldAffectPreview, isGroupLeaveEventFromOther,
         received_at, sent_at) WHERE storyId IS NULL;
      CREATE INDEX messages_activity ON messages
        (conversationId, shouldAffectActivity, isTimerChangeFromSync,
         isGroupLeaveEventFromOther, received_at, sent_at);

      --
      -- reactions
      --

      DROP INDEX reaction_identifier;

      ALTER TABLE reactions
        RENAME COLUMN targetAuthorUuid TO targetAuthorAci;

      -- See: updateToSchemaVersion29
      CREATE INDEX reaction_identifier ON reactions (
        emoji,
        targetAuthorAci,
        targetTimestamp
      );

      --
      -- unprocessed
      --

      ALTER TABLE unprocessed
        RENAME COLUMN sourceUuid TO sourceServiceId;

      --
      -- sendLogRecipients
      --

      DROP INDEX sendLogRecipientsByRecipient;

      ALTER TABLE sendLogRecipients
        RENAME COLUMN recipientUuid TO recipientServiceId;

      -- See: updateToSchemaVersion37
      CREATE INDEX sendLogRecipientsByRecipient
        ON sendLogRecipients (recipientServiceId, deviceId);

      --
      -- storyDistributionMembers
      --

      ALTER TABLE storyDistributionMembers
        RENAME COLUMN uuid TO serviceId;

      --
      -- mentions
      --

      DROP TRIGGER messages_on_update;
      DROP TRIGGER messages_on_insert_insert_mentions;
      DROP TRIGGER messages_on_update_update_mentions;
      DROP INDEX mentions_uuid;

      ALTER TABLE mentions
        RENAME COLUMN mentionUuid TO mentionAci;

      -- See: updateToSchemaVersion84
      CREATE INDEX mentions_aci ON mentions (mentionAci);

      --
      -- preKeys
      --

      DROP INDEX preKeys_ourUuid;
      DROP INDEX signedPreKeys_ourUuid;
      DROP INDEX kyberPreKeys_ourUuid;

      ALTER TABLE preKeys
        RENAME COLUMN ourUuid TO ourServiceId;
      ALTER TABLE signedPreKeys
        RENAME COLUMN ourUuid TO ourServiceId;
      ALTER TABLE kyberPreKeys
        RENAME COLUMN ourUuid TO ourServiceId;

      -- See: updateToSchemaVersion64
      CREATE INDEX preKeys_ourServiceId ON preKeys (ourServiceId);
      CREATE INDEX signedPreKeys_ourServiceId ON signedPreKeys (ourServiceId);
      CREATE INDEX kyberPreKeys_ourServiceId ON kyberPreKeys (ourServiceId);
    `);

    // Migrate JSON fields
    const { identifierToServiceId } = migrateConversations(db, logger);
    const ourServiceIds = migrateItems(db, logger);
    migrateSessions(db, ourServiceIds, logger);
    migrateMessages(db, logger);
    migratePreKeys(db, 'preKeys', ourServiceIds, logger);
    migratePreKeys(db, 'signedPreKeys', ourServiceIds, logger);
    migratePreKeys(db, 'kyberPreKeys', ourServiceIds, logger);
    migrateJobs(db, identifierToServiceId, logger);

    // Re-create triggers after updating messages
    db.exec(`
      -- See: updateToSchemaVersion45
      CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
      WHEN
        (new.body IS NULL OR old.body IS NOT new.body) AND
         new.isViewOnce IS NOT 1 AND new.storyId IS NULL
      BEGIN
        DELETE FROM messages_fts WHERE rowid = old.rowid;
        INSERT INTO messages_fts
          (rowid, body)
        VALUES
          (new.rowid, new.body);
      END;

      -- See: updateToSchemaVersion84
      CREATE TRIGGER messages_on_insert_insert_mentions AFTER INSERT ON messages
      BEGIN
        INSERT INTO mentions (messageId, mentionAci, start, length)
        ${selectMentionsFromMessages}
        AND messages.id = new.id;
      END;

      CREATE TRIGGER messages_on_update_update_mentions AFTER UPDATE ON messages
      BEGIN
        DELETE FROM mentions WHERE messageId = new.id;
        INSERT INTO mentions (messageId, mentionAci, start, length)
        ${selectMentionsFromMessages}
        AND messages.id = new.id;
      END;
    `);

    db.pragma('user_version = 88');
  })();

  logger.info('updateToSchemaVersion88: success!');
}

//
// migrateConversation does the following:
//
// 1. Rename `uuid` to `serviceId`
// 2. Prefix the value of `pni` with `PNI:` if needed
// 3. Renames various `uuid` fields to either `serviceId` or `aci`.
//
// The result is a conversationId to serviceId map to be consumed in
// other functions.
//

type LegacyBodyRanges = JSONWithUnknownFields<
  Array<{
    mentionUuid?: string;
  }>
>;

type UpdatedBodyRanges = JSONWithUnknownFields<
  Array<{
    mentionAci: AciString | undefined;
  }>
>;

type MigrateConversationsResultType = Readonly<{
  identifierToServiceId: Map<string, ServiceIdString>;
}>;

type LegacyConversationData = JSONWithUnknownFields<{
  uuid?: string | null;
  pni?: string | null;
  bannedMembersV2?: Array<{
    uuid: string;
  }>;
  lastMessageBodyRanges?: LegacyBodyRanges;
  membersV2?: Array<{
    uuid: string;
  }>;
  pendingAdminApprovalV2?: Array<{
    uuid: string;
  }>;
  pendingMembersV2?: Array<{
    uuid: string;
  }>;
  senderKeyInfo?: {
    memberDevices: Array<{ identifier: string }>;
  };
}>;

type UpdatedConversationData = JSONWithUnknownFields<{
  serviceId: ServiceIdString | undefined;
  pni: PniString | undefined;
  bannedMembersV2:
    | Array<{
        serviceId: ServiceIdString;
      }>
    | undefined;
  lastMessageBodyRanges: UpdatedBodyRanges | undefined;
  membersV2:
    | Array<{
        aci: AciString;
      }>
    | undefined;
  pendingAdminApprovalV2:
    | Array<{
        aci: AciString;
      }>
    | undefined;
  pendingMembersV2:
    | Array<{
        serviceId: ServiceIdString;
      }>
    | undefined;
  senderKeyInfo:
    | {
        memberDevices: Array<{ serviceId: ServiceIdString }>;
      }
    | undefined;
}>;

function migrateConversations(
  db: Database,
  logger: LoggerType
): MigrateConversationsResultType {
  const convos: Array<{
    id: string;
    e164?: string;
    serviceId?: string;
    json: string;
  }> = db.prepare('SELECT id, e164, serviceId, json FROM conversations').all();

  const updateStmt = db.prepare(
    'UPDATE conversations SET json = $json WHERE id IS $id'
  );

  logger.info(
    `updateToSchemaVersion88: updating ${convos.length} conversations`
  );

  // Build lookup map for senderKeyInfo
  const identifierToServiceId = new Map<string, ServiceIdString>();
  for (const { id, e164, serviceId: rawServiceId } of convos) {
    if (!rawServiceId) {
      continue;
    }

    const serviceId = normalizeServiceId(
      rawServiceId,
      'legacyConvo.serviceId',
      logger
    );
    identifierToServiceId.set(id, serviceId);
    if (e164) {
      identifierToServiceId.set(e164, serviceId);
    }
    identifierToServiceId.set(serviceId, serviceId);
  }

  for (const { id, json } of convos) {
    try {
      const legacy: LegacyConversationData = JSON.parse(json);

      const {
        uuid: serviceId,
        pni,
        bannedMembersV2,
        membersV2,
        pendingAdminApprovalV2,
        pendingMembersV2,
        lastMessageBodyRanges,
        senderKeyInfo,
        ...restOfConvo
      } = legacy;

      const modern: UpdatedConversationData = {
        ...restOfConvo,
        serviceId: normalizeServiceId(
          serviceId,
          'legacyConvo.serviceId',
          logger
        ),
        pni: prefixPni(pni, 'legacyConvo.pni', logger),
        bannedMembersV2: bannedMembersV2?.map(
          ({ uuid: memberServiceId, ...rest }) => {
            return {
              ...rest,
              serviceId: normalizeServiceId(
                memberServiceId,
                'legacyConvo.bannedMembersV2',
                logger
              ),
            };
          }
        ),
        membersV2: membersV2?.map(({ uuid: aci, ...rest }) => {
          return {
            ...rest,
            aci: normalizeAci(aci, 'legacyConvo.membersV2', logger),
          };
        }),
        pendingAdminApprovalV2: pendingAdminApprovalV2?.map(
          ({ uuid: aci, ...rest }) => {
            return {
              ...rest,
              aci: normalizeAci(
                aci,
                'legacyConvo.pendingAdminApprovalV2',
                logger
              ),
            };
          }
        ),
        pendingMembersV2: pendingMembersV2?.map(
          ({ uuid: memberServiceId, ...rest }) => {
            return {
              ...rest,
              serviceId: normalizeServiceId(
                memberServiceId,
                'legacyConvo.pendingMembersV2',
                logger
              ),
            };
          }
        ),
        lastMessageBodyRanges: migrateBodyRanges(
          lastMessageBodyRanges,
          'lastMessageBodyRanges',
          logger
        ),
        senderKeyInfo: senderKeyInfo
          ? {
              ...senderKeyInfo,
              memberDevices: senderKeyInfo.memberDevices
                .map(({ identifier, ...rest }) => {
                  const deviceServiceId = identifierToServiceId.get(identifier);
                  if (!deviceServiceId) {
                    logger.warn(
                      `updateToSchemaVersion88: failed to resolve identifier ${identifier}`
                    );
                    return undefined;
                  }

                  return { ...rest, serviceId: deviceServiceId };
                })
                .filter(isNotNil),
            }
          : undefined,
      };

      updateStmt.run({ id, json: JSON.stringify(modern) });
    } catch (error) {
      logger.warn(
        `updateToSchemaVersion88: failed to parse convo ${id} json`,
        error
      );
      continue;
    }
  }

  return { identifierToServiceId };
}

//
// migrateItems does:
//
// 1. Migrate `pni` storage item to a prefixed value
// 2. Return `aci` and `pni` (and their legacy values) to be used in other
//    migrations.

type OurServiceIds = Readonly<{
  legacyAci?: string;
  legacyPni?: string;
  aci?: AciString;
  pni?: PniString;
}>;

function migrateItems(db: Database, logger: LoggerType): OurServiceIds {
  // Get our ACI and PNI
  const uuidIdJson = db
    .prepare(
      `
        SELECT json
        FROM items
        WHERE id IS 'uuid_id'
      `
    )
    .pluck()
    .get();
  const pniJson = db
    .prepare(
      `
        SELECT json
        FROM items
        WHERE id IS 'pni'
      `
    )
    .pluck()
    .get();

  let legacyAci: string | undefined;
  try {
    [legacyAci] = JSON.parse(uuidIdJson).value.split('.', 2);
  } catch (error) {
    if (uuidIdJson) {
      logger.warn(
        'updateToSchemaVersion88: failed to parse uuid_id item',
        error
      );
    } else {
      logger.info('updateToSchemaVersion88: Our UUID not found');
    }
  }

  let legacyPni: string | undefined;
  try {
    legacyPni = JSON.parse(pniJson).value;
  } catch (error) {
    if (pniJson) {
      logger.warn('updateToSchemaVersion88: failed to parse pni item', error);
    } else {
      logger.info('updateToSchemaVersion88: Our PNI not found');
    }
  }

  const aci = normalizeAci(legacyAci, 'uuid_id', logger);
  const pni = prefixPni(legacyPni, 'pni', logger);

  const maps: Array<{ id: string; json: string }> = db
    .prepare(
      `
      SELECT id, json
      FROM items
      WHERE id IN ('identityKeyMap', 'registrationIdMap');
    `
    )
    .all();

  const updateStmt = db.prepare(
    'UPDATE items SET json = $json WHERE id IS $id'
  );

  if (pni) {
    updateStmt.run({
      id: 'pni',
      json: JSON.stringify({ id: 'pni', value: pni }),
    });
  }

  for (const { id, json } of maps) {
    try {
      const data: { id: string; value: Record<string, unknown> } =
        JSON.parse(json);

      const aciValue = legacyAci && data.value[legacyAci];
      if (legacyAci && aci && aciValue) {
        delete data.value[legacyAci];
        data.value[aci] = aciValue;
      }
      const pniValue = legacyPni && data.value[legacyPni];
      if (legacyPni && pni && pniValue) {
        delete data.value[legacyPni];
        data.value[pni] = pniValue;
      }

      updateStmt.run({ id, json: JSON.stringify(data) });
    } catch (error) {
      logger.warn(`updateToSchemaVersion88: failed to parse ${id} item`, error);
    }
  }
  return { aci, pni, legacyAci, legacyPni };
}

//
// migrateSessions does:
//
// 1. Update `ourServiceId` to a normalized ACI or (prefixed) PNI in both
//    json and column
// 2. Update the `session.id` to use new `ourServiceId`
//    (the schema is `${ourServiceId}:${theirServiceId}.${theirDevice}`
//

function migrateSessions(
  db: Database,
  ourServiceIds: OurServiceIds,
  logger: LoggerType
): void {
  const sessions: Array<{
    id: string;
    serviceId: string;
    ourServiceId: string;
    json: string;
  }> = db
    .prepare('SELECT id, serviceId, ourServiceId, json FROM sessions')
    .all();

  const updateStmt = db.prepare(
    `
      UPDATE sessions
      SET id = $newId, serviceId = $newServiceId,
        ourServiceId = $newOurServiceId, json = $newJson
      WHERE id IS $id
    `
  );

  logger.info(`updateToSchemaVersion88: updating ${sessions.length} sessions`);
  for (const { id, serviceId, ourServiceId, json } of sessions) {
    const match = id.match(/^(.*):(.*)\.(.*)$/);
    if (!match) {
      logger.warn(`updateToSchemaVersion88: invalid session id ${id}`);
      continue;
    }
    let legacyData: JSONWithUnknownFields<Record<string, unknown>>;
    try {
      legacyData = JSON.parse(json);
    } catch (error) {
      logger.warn(
        `updateToSchemaVersion88: failed to parse session ${id}`,
        error
      );
      continue;
    }

    const [, from, to, device] = match;

    const newId =
      `${migrateServiceId(from, ourServiceIds, logger)}:` +
      `${migrateServiceId(to, ourServiceIds, logger)}.${device}`;
    const newServiceId = migrateServiceId(serviceId, ourServiceIds, logger);
    const newOurServiceId = migrateServiceId(
      ourServiceId,
      ourServiceIds,
      logger
    );
    if (!newServiceId || !newOurServiceId) {
      logger.warn(
        'updateToSchemaVersion88: failed to normalize session service ids',
        serviceId,
        ourServiceId
      );
      continue;
    }

    const newData: JSONWithUnknownFields<{
      id: string;
      serviceId: ServiceIdString;
      ourServiceId: ServiceIdString;
    }> = {
      ...omit(legacyData, 'uuid', 'ourUuid'),
      id: newId,
      serviceId: newServiceId,
      ourServiceId: newOurServiceId,
    };

    updateStmt.run({
      id,
      newId,
      newServiceId,
      newOurServiceId,
      newJson: JSON.stringify(newData),
    });
  }
}

//
// Migrate messages processes messages in page by page and does:
//
// 1. Update all json fields from `*uuid` to `*serviceId`/`*aci` depending
//    on context.

type LegacyBodyRangesAndQuote = JSONWithUnknownFields<{
  bodyRanges?: LegacyBodyRanges;
  quote?: {
    authorUuid?: string;
    bodyRanges?: LegacyBodyRanges;
  };
}>;

type UpdatedBodyRangesAndQuote = JSONWithUnknownFields<{
  bodyRanges: UpdatedBodyRanges | undefined;
  quote:
    | {
        authorAci: AciString | undefined;
        bodyRanges: UpdatedBodyRanges | undefined;
      }
    | undefined;
}>;

type LegacyMessage = JSONWithUnknownFields<
  LegacyBodyRangesAndQuote & {
    id: string;
    sourceUuid?: string;
    expirationTimerUpdate?: {
      sourceUuid?: string;
    };
    reactions?: Array<LegacyReaction>;
    storyReaction?: LegacyReaction;
    storyReplyContext?: {
      authorUuid?: string;
    };
    editHistory?: Array<LegacyBodyRangesAndQuote>;
    groupV2Change?: {
      details?: Array<LegacyGroupChange>;
    };
  }
>;

type UpdatedMessage = JSONWithUnknownFields<
  UpdatedBodyRangesAndQuote & {
    id: string;
    sourceServiceId: ServiceIdString | undefined;
    expirationTimerUpdate:
      | {
          sourceServiceId: ServiceIdString | undefined;
        }
      | undefined;
    reactions: Array<UpdatedReaction> | undefined;
    storyReaction: UpdatedReaction | undefined;
    storyReplyContext:
      | {
          authorAci: AciString | undefined;
        }
      | undefined;
    editHistory: Array<UpdatedBodyRangesAndQuote> | undefined;
    groupV2Change:
      | {
          details: Array<UpdatedGroupChange> | undefined;
        }
      | undefined;
  }
>;

function migrateMessages(db: Database, logger: LoggerType): void {
  const PAGE_SIZE = 10000;
  const getPage = db.prepare(`
    SELECT rowid, id, json
    FROM messages
    LIMIT $limit
    OFFSET $offset
  `);

  const updateStmt = db.prepare(`
    UPDATE messages
    SET json = $json
    WHERE rowid = $rowid
  `);

  logger.info('updateToSchemaVersion88: updating messages');

  let totalMessages = 0;
  // eslint-disable-next-line no-constant-condition
  for (let offset = 0; true; offset += PAGE_SIZE) {
    const messages: Array<{ id: string; rowid: number; json: string }> =
      getPage.all({
        limit: PAGE_SIZE,
        offset,
      });
    if (messages.length === 0) {
      break;
    }

    totalMessages += messages.length;

    for (const { rowid, id, json } of messages) {
      try {
        const legacy: LegacyMessage = JSON.parse(json);

        const {
          sourceUuid,
          expirationTimerUpdate,
          reactions,
          storyReaction,
          storyReplyContext,
          editHistory,
          groupV2Change,
          ...restOfMessage
        } = legacy;

        const updatedMessage: UpdatedMessage = {
          ...restOfMessage,
          ...omit(
            migrateBodyRangesAndQuote(legacy, 'message', logger),
            'sourceUuid'
          ),
          sourceServiceId: normalizeServiceId(sourceUuid, 'sourceUuid'),
          expirationTimerUpdate: expirationTimerUpdate
            ? {
                ...omit(expirationTimerUpdate, 'sourceUuid'),
                sourceServiceId: normalizeServiceId(
                  expirationTimerUpdate.sourceUuid,
                  'expirationTimerUpdate.sourceUuid'
                ),
              }
            : undefined,
          reactions: reactions?.map(r => migrateReaction(r)),
          storyReaction: storyReaction
            ? migrateReaction(storyReaction)
            : undefined,
          storyReplyContext: storyReplyContext
            ? {
                ...omit(storyReplyContext, 'authorUuid'),
                authorAci: normalizeAci(
                  storyReplyContext.authorUuid,
                  'storyReplyContext.authorUuid',
                  logger
                ),
              }
            : undefined,
          editHistory: editHistory?.map(h =>
            migrateBodyRangesAndQuote(h, 'editHistory', logger)
          ),
          groupV2Change: groupV2Change
            ? {
                ...groupV2Change,
                details: groupV2Change.details?.map(d =>
                  migrateGroupChange(d, logger)
                ),
              }
            : undefined,
        };

        updateStmt.run({
          rowid,
          json: JSON.stringify(updatedMessage),
        });
      } catch (error) {
        logger.warn(
          `updateToSchemaVersion88: failed to parse message ${id} json`,
          error
        );
      }
    }
  }

  logger.info(`updateToSchemaVersion88: updated ${totalMessages} messages`);
}

// migratePreKeys works similarly to migrateSessions and does:
//
// 1. Update `ourServiceId` to ACI or (prefixed) PNI
// 2. Update `id` to use new `ourServiceId` value
//    (the schema is `${ourServiceId}:${keyId}`)
//

function migratePreKeys(
  db: Database,
  table: string,
  ourServiceIds: OurServiceIds,
  logger: LoggerType
): void {
  const preKeys = db.prepare(`SELECT id, json FROM ${table}`).all();

  const updateStmt = db.prepare(`
    UPDATE ${table}
    SET id = $newId, json = $newJson
    WHERE id = $id
  `);

  logger.info(`updateToSchemaVersion88: updating ${preKeys.length} ${table}`);
  for (const { id, json } of preKeys) {
    const match = id.match(/^(.*):(.*)$/);
    if (!match) {
      logger.warn(`updateToSchemaVersion88: invalid ${table} id ${id}`);
      continue;
    }

    let legacyData: JSONWithUnknownFields<Record<string, unknown>>;
    try {
      legacyData = JSON.parse(json);
    } catch (error) {
      logger.warn(
        `updateToSchemaVersion88: failed to parse ${table} ${id}`,
        error
      );
      continue;
    }

    const [, ourUuid, keyId] = match;

    const ourServiceId = migrateServiceId(ourUuid, ourServiceIds, logger);
    const newId = `${ourServiceId}:${keyId}`;

    const newData: JSONWithUnknownFields<{
      id: string;
      ourServiceId: ServiceIdString;
    }> = {
      ...omit(legacyData, 'ourUuid'),
      id: newId,
      ourServiceId,
    };

    updateStmt.run({
      id,
      newId,
      newJson: JSON.stringify(newData),
    });
  }
}

//
// migrateJobs does:
//
// 1. Update conversation jobs to use `serviceId` instead of `uuid`
//   1.1. `DeleteStoryForEveryone`
//   1.2. `ResendRequest`
//   1.3. `Receipts`
// 2. Update `read sync`/`view sync`/`view once open sync` to use service ids
// 3. Update `single proto` job queue to use `serviceId`

type LegacyConversationJob = JSONWithUnknownFields<
  | {
      type: 'DeleteStoryForEveryone';
      updatedStoryRecipients: Array<{
        destinationUuid?: string;
        legacyDestinationUuid?: string;
        destinationAci?: string;
        destinationPni?: string;
      }>;
    }
  | {
      type: 'ResendRequest';
      senderUuid: string;
    }
  | {
      type: 'Receipts';
      receipts: Array<{
        senderUuid?: string;
      }>;
    }
>;

type UpdatedConversationJob = JSONWithUnknownFields<
  | {
      type: 'DeleteStoryForEveryone';
      updatedStoryRecipients: Array<{
        destinationServiceId: ServiceIdString | undefined;
      }>;
    }
  | {
      type: 'ResendRequest';
      senderAci: AciString;
    }
  | {
      type: 'Receipts';
      receipts: Array<{
        senderAci: AciString | undefined;
      }>;
    }
>;

type LegacyReadSyncJob = JSONWithUnknownFields<{
  readSyncs: Array<{
    senderUuid: string;
  }>;
}>;

type UpdatedReadSyncJob = JSONWithUnknownFields<{
  readSyncs: Array<{
    senderAci: AciString;
  }>;
}>;

type LegacyViewSyncJob = JSONWithUnknownFields<{
  viewSyncs: Array<{
    senderUuid: string;
  }>;
}>;

type UpdatedViewSyncJob = JSONWithUnknownFields<{
  viewSyncs: Array<{
    senderAci: AciString;
  }>;
}>;

type LegacyViewOnceSyncJob = JSONWithUnknownFields<{
  viewOnceOpens: Array<{
    senderUuid: string;
  }>;
}>;

type UpdatedViewOnceSyncJob = JSONWithUnknownFields<{
  viewOnceOpens: Array<{
    senderAci: AciString;
  }>;
}>;

type LegacySingleProtoJob = JSONWithUnknownFields<{
  identifier: string;
}>;

type UpdatedSingleProtoJob = JSONWithUnknownFields<{
  serviceId: ServiceIdString;
}>;

function migrateJobs(
  db: Database,
  identifierToServiceId: Map<string, ServiceIdString>,
  logger: LoggerType
): void {
  const jobs = db.prepare('SELECT id, queueType, data FROM jobs').all();
  const updateStmt = db.prepare('UPDATE jobs SET data = $data WHERE id IS $id');

  let updatedCount = 0;
  for (const { id, queueType, data } of jobs) {
    try {
      const parsedData: unknown = JSON.parse(data);

      let updatedData: unknown | undefined;
      if (queueType === 'conversation') {
        const convoJob = parsedData as LegacyConversationJob;
        let updatedJob: UpdatedConversationJob | undefined;

        if (convoJob.type === 'DeleteStoryForEveryone') {
          updatedJob = {
            ...convoJob,
            updatedStoryRecipients: convoJob.updatedStoryRecipients.map(
              ({
                destinationUuid,
                legacyDestinationUuid,
                destinationAci,
                destinationPni,
                ...rest
              }) => {
                return {
                  ...rest,
                  destinationServiceId: normalizeServiceId(
                    destinationUuid ||
                      destinationAci ||
                      destinationPni ||
                      legacyDestinationUuid,
                    'DeleteStoryForEveryone',
                    logger
                  ),
                };
              }
            ),
          };
        } else if (convoJob.type === 'ResendRequest') {
          updatedJob = {
            ...omit(convoJob, 'senderUuid'),
            senderAci: normalizeAci(
              convoJob.senderUuid,
              'ResendRequest',
              logger
            ),
          };
        } else if (convoJob.type === 'Receipts') {
          updatedJob = {
            ...convoJob,
            receipts: convoJob.receipts.map(({ senderUuid, ...rest }) => {
              return {
                ...rest,
                senderAci: normalizeAci(senderUuid, 'Receipts', logger),
              };
            }),
          };
        }

        updatedData = updatedJob;
      } else if (queueType === 'read sync') {
        const syncJob = parsedData as LegacyReadSyncJob;
        const updatedJob: UpdatedReadSyncJob = {
          ...syncJob,
          readSyncs: syncJob.readSyncs.map(({ senderUuid, ...rest }) => {
            return {
              ...rest,
              senderAci: normalizeAci(senderUuid, 'read sync'),
            };
          }),
        };

        updatedData = updatedJob;
      } else if (queueType === 'view sync') {
        const syncJob = parsedData as LegacyViewSyncJob;
        const updatedJob: UpdatedViewSyncJob = {
          ...syncJob,
          viewSyncs: syncJob.viewSyncs.map(({ senderUuid, ...rest }) => {
            return {
              ...rest,
              senderAci: normalizeAci(senderUuid, 'read sync'),
            };
          }),
        };

        updatedData = updatedJob;
      } else if (queueType === 'view once open sync') {
        const syncJob = parsedData as LegacyViewOnceSyncJob;
        const updatedJob: UpdatedViewOnceSyncJob = {
          ...syncJob,
          viewOnceOpens: syncJob.viewOnceOpens.map(
            ({ senderUuid, ...rest }) => {
              return {
                ...rest,
                senderAci: normalizeAci(senderUuid, 'read sync'),
              };
            }
          ),
        };

        updatedData = updatedJob;
      } else if (queueType === 'single proto') {
        const { identifier, ...syncJob } = parsedData as LegacySingleProtoJob;
        const serviceId = identifierToServiceId.get(identifier);
        if (!serviceId) {
          logger.warn(
            `updateToSchemaVersion88: failed to resolve identifier ${identifier} ` +
              `for job ${id}/${queueType}`
          );
          continue;
        }

        const updatedJob: UpdatedSingleProtoJob = {
          ...syncJob,
          serviceId,
        };

        updatedData = updatedJob;
      }

      if (updatedData !== undefined) {
        updatedCount += 1;
        updateStmt.run({ id, data: JSON.stringify(updatedData) });
      }
    } catch (error) {
      logger.warn(
        `updateToSchemaVersion88: failed to migrate job ${id}/${queueType} json`,
        error
      );
    }
  }

  logger.info(`updateToSchemaVersion88: updated ${updatedCount} jobs`);
}

//
// Various utility methods below.
//

function migrateBodyRangesAndQuote(
  { bodyRanges, quote, ...rest }: LegacyBodyRangesAndQuote,
  context: string,
  logger: LoggerType
): UpdatedBodyRangesAndQuote {
  return {
    ...rest,
    bodyRanges: bodyRanges
      ? migrateBodyRanges(bodyRanges, `${context}.bodyRanges`, logger)
      : undefined,
    quote: quote
      ? {
          ...quote,
          authorAci: normalizeAci(
            quote.authorUuid,
            `${context}.quote.authorUuid`,
            logger
          ),
          bodyRanges: quote.bodyRanges
            ? migrateBodyRanges(
                quote.bodyRanges,
                `${context}.quote.bodyRanges`,
                logger
              )
            : undefined,
        }
      : undefined,
  };
}

function migrateServiceId(
  legacyId: string,
  ourServiceIds: OurServiceIds,
  logger: LoggerType
): ServiceIdString;

function migrateServiceId(
  legacyId: string | null | undefined,
  { legacyAci, legacyPni, aci, pni }: OurServiceIds,
  logger: LoggerType
): ServiceIdString | undefined {
  if (legacyId == null) {
    return undefined;
  }
  if (legacyId === legacyAci) {
    return aci;
  }
  if (legacyId === legacyPni) {
    return pni;
  }
  return normalizeServiceId(legacyId, `migrateServiceId(${legacyId})`, logger);
}

function prefixPni(
  legacyPni: string | null | undefined,
  context: string,
  logger: LoggerType
): PniString | undefined {
  if (legacyPni == null) {
    return undefined;
  }

  if (legacyPni.toLowerCase().startsWith('pni:')) {
    return normalizePni(legacyPni, context, logger);
  }

  return normalizePni(`PNI:${legacyPni}`, context, logger);
}

function migrateBodyRanges(
  legacy: LegacyBodyRanges | undefined | null,
  context: string,
  logger: LoggerType
): UpdatedBodyRanges | undefined {
  if (legacy == null) {
    return undefined;
  }
  return legacy?.map(({ mentionUuid: mentionAci, ...rest }) => {
    return {
      ...rest,
      mentionAci: normalizeAci(mentionAci, context, logger),
    };
  });
}

type LegacyReaction = JSONWithUnknownFields<{
  targetAuthorUuid?: string;
}>;

type UpdatedReaction = JSONWithUnknownFields<Record<string, unknown>>;

function migrateReaction(legacy: LegacyReaction): UpdatedReaction {
  return omit(legacy, 'targetAuthorUuid');
}

type LegacyGroupChange = JSONWithUnknownFields<{
  type: string;
  uuid?: string;
}>;

type UpdatedGroupChange = JSONWithUnknownFields<{
  type: string;
  serviceId: ServiceIdString | undefined;
  aci: AciString | undefined;
}>;

const GROUP_CHANGES_WITH_SERVICE_ID = new Set([
  'pending-add-one',
  'pending-remove-one',
]);

function migrateGroupChange(
  { type, uuid, ...rest }: LegacyGroupChange,
  logger: LoggerType
): UpdatedGroupChange {
  let aci: AciString | undefined;
  let serviceId: ServiceIdString | undefined;
  if (GROUP_CHANGES_WITH_SERVICE_ID.has(type)) {
    serviceId = normalizeServiceId(uuid, `migrateGroupChange(${type})`, logger);
  } else {
    aci = normalizeAci(uuid, `migrateGroupChange(${type})`, logger);
  }

  return {
    ...rest,
    type,
    aci,
    serviceId,
  };
}
