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

import { pipeline } from 'node:stream/promises';
import { PassThrough } from 'node:stream';
import type { Readable, Writable } from 'node:stream';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rm, stat, unlink, writeFile } from 'node:fs/promises';
import fsExtra from 'fs-extra';
import { join } from 'node:path';
import { createGzip, createGunzip } from 'node:zlib';
import { createCipheriv, createHmac, randomBytes } from 'node:crypto';
import lodash from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup.js';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys.js';
import lodashFp from 'lodash/fp.js';
import { ipcRenderer } from 'electron';

import { DataReader, DataWriter } from '../../sql/Client.preload.js';
import { createLogger } from '../../logging/log.std.js';
import * as Bytes from '../../Bytes.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { drop } from '../../util/drop.std.js';
import { TEMP_PATH } from '../../util/basePaths.preload.js';
import { getAbsoluteDownloadsPath } from '../../util/migrations.preload.js';
import { waitForAllBatchers } from '../../util/batcher.std.js';
import { flushAllWaitBatchers } from '../../util/waitBatcher.std.js';
import { DelimitedStream } from '../../util/DelimitedStream.node.js';
import { appendPaddingStream } from '../../util/logPadding.node.js';
import { prependStream } from '../../util/prependStream.node.js';
import { appendMacStream } from '../../util/appendMacStream.node.js';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac.node.js';
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { HOUR, SECOND } from '../../util/durations/index.std.js';
import type { ExplodePromiseResultType } from '../../util/explodePromise.std.js';
import { explodePromise } from '../../util/explodePromise.std.js';
import type { RetryBackupImportValue } from '../../state/ducks/installer.preload.js';
import { CipherType, HashType } from '../../types/Crypto.std.js';
import {
  InstallScreenBackupStep,
  InstallScreenBackupError,
} from '../../types/InstallScreen.std.js';
import * as Errors from '../../types/errors.std.js';
import {
  BackupCredentialType,
  type BackupsSubscriptionType,
  type BackupStatusType,
} from '../../types/backups.node.js';
import { HTTPError } from '../../types/HTTPError.std.js';
import { constantTimeEqual } from '../../Crypto.node.js';
import { measureSize, safeUnlink } from '../../AttachmentCrypto.node.js';
import { signalProtocolStore } from '../../SignalProtocolStore.preload.js';
import { isTestOrMockEnvironment } from '../../environment.std.js';
import { runStorageServiceSyncJob } from '../storage.preload.js';
import { BackupExportStream } from './export.preload.js';
import { BackupImportStream } from './import.preload.js';
import {
  getBackupId,
  getKeyMaterial,
  getLocalBackupMetadataKey,
} from './crypto.preload.js';
import { BackupCredentials } from './credentials.preload.js';
import { BackupAPI } from './api.preload.js';
import {
  validateBackup,
  validateBackupStream,
  ValidationType,
} from './validator.preload.js';
import type {
  BackupExportOptions,
  BackupImportOptions,
  ExportResultType,
  LocalBackupExportResultType,
  OnProgressCallback,
} from './types.std.js';
import {
  BackupInstallerError,
  BackupDownloadFailedError,
  BackupImportCanceledError,
  BackupProcessingError,
  RelinkRequestedError,
} from './errors.std.js';
import { FileStream } from './util/FileStream.node.js';
import { ToastType } from '../../types/Toast.dom.js';
import { isAdhoc, isNightly } from '../../util/version.std.js';
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.dom.js';
import type { ValidateLocalBackupStructureResultType } from './util/localBackup.node.js';
import {
  writeLocalBackupMetadata,
  verifyLocalBackupMetadata,
  writeLocalBackupFilesList,
  readLocalBackupFilesList,
  validateLocalBackupStructure,
  getAllPathsInLocalBackupFilesDirectory,
  getLocalBackupFilesDirectory,
} from './util/localBackup.node.js';
import {
  AttachmentPermanentlyMissingError,
  getJobIdForLogging,
  runAttachmentBackupJob,
} from '../../jobs/AttachmentLocalBackupManager.preload.js';
import { decipherWithAesKey } from '../../util/decipherWithAesKey.node.js';
import { areRemoteBackupsTurnedOn } from '../../util/isBackupEnabled.preload.js';
import {
  isOnline,
  unlink as unlinkAccount,
} from '../../textsecure/WebAPI.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { LOCAL_BACKUP_VERSION } from './constants.std.js';
import { getTimestampForFolder } from '../../util/timestamp.std.js';
import { MEBIBYTE } from '../../types/AttachmentSize.std.js';
import {
  NotEnoughStorageError,
  RanOutOfStorageError,
  StoragePermissionsError,
} from '../../types/LocalExport.std.js';
import { getFreeDiskSpace } from '../../util/getFreeDiskSpace.node.js';
import { isFeaturedEnabledNoRedux } from '../../util/isFeatureEnabled.dom.js';

const { ensureFile } = fsExtra;

const { throttle } = lodashFp;

const { isEqual, noop } = lodash;

const log = createLogger('backupsService');

const IV_LENGTH = 16;

const BACKUP_REFRESH_INTERVAL = 24 * HOUR;

const MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT = 200 * MEBIBYTE;

export type DownloadOptionsType = Readonly<{
  onProgress?: (
    backupStep: InstallScreenBackupStep,
    currentBytes: number,
    totalBytes: number
  ) => void;
  abortSignal?: AbortSignal;
}>;

type DoDownloadOptionsType = Readonly<{
  downloadPath: string;
  ephemeralKey?: Uint8Array;
  onProgress?: (
    backupStep: InstallScreenBackupStep,
    currentBytes: number,
    totalBytes: number
  ) => void;
}>;

export type ValidationResultType = Readonly<
  | {
      result: ExportResultType | LocalBackupExportResultType;
    }
  | {
      error: string;
    }
>;

export class BackupsService {
  #isStarted = false;
  #isRunning: 'import' | 'export' | false = false;
  #importController: AbortController | undefined;
  #downloadController: AbortController | undefined;

  #downloadRetryPromise:
    | ExplodePromiseResultType<RetryBackupImportValue>
    | undefined;

  #localBackupSnapshotDir: string | undefined;

  public readonly credentials = new BackupCredentials();
  public readonly api = new BackupAPI(this.credentials);
  public readonly throttledFetchCloudBackupStatus = throttle(30 * SECOND, () =>
    this.#fetchCloudBackupStatus()
  );
  public readonly throttledFetchSubscriptionStatus = throttle(30 * SECOND, () =>
    this.#fetchSubscriptionStatus()
  );

  public start(): void {
    if (!areRemoteBackupsTurnedOn()) {
      log.warn('remote backups are not turned on; not starting');
      return;
    }

    if (this.#isStarted) {
      log.warn('already started');
      return;
    }

    this.#isStarted = true;
    log.info('starting...');

    setInterval(() => {
      drop(this.#runPeriodicRefresh());
    }, BACKUP_REFRESH_INTERVAL);

    drop(this.#runPeriodicRefresh());
    this.credentials.start();

    window.Whisper.events.on('userChanged', async () => {
      await this.resetCachedData();
    });
  }
  public async downloadAndImport(
    options: DownloadOptionsType
  ): Promise<{ wasBackupImported: boolean }> {
    const backupDownloadPath = itemStorage.get('backupDownloadPath');
    if (!backupDownloadPath) {
      log.warn('backups.downloadAndImport: no backup download path, skipping');
      return { wasBackupImported: false };
    }

    log.info('backups.downloadAndImport: downloading...');

    const ephemeralKey = itemStorage.get('backupEphemeralKey');

    const absoluteDownloadPath = getAbsoluteDownloadsPath(backupDownloadPath);
    let hasBackup = false;

    // eslint-disable-next-line no-constant-condition
    while (true) {
      try {
        // eslint-disable-next-line no-await-in-loop
        hasBackup = await this.#doDownloadAndImport({
          downloadPath: absoluteDownloadPath,
          onProgress: options.onProgress,
          ephemeralKey,
        });

        if (!hasBackup) {
          // If the primary cancels sync on their end, then we can link without sync
          log.info('backups.downloadAndImport: missing backup');
          window.reduxActions.installer.handleMissingBackup();
        }
      } catch (error) {
        this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();

        let installerError: InstallScreenBackupError;
        if (error instanceof BackupInstallerError) {
          log.error(
            'backups.downloadAndImport: got installer error',
            Errors.toLogFormat(error)
          );
          ({ installerError } = error);
        } else {
          log.error(
            'backups.downloadAndImport: unknown error, prompting user to retry'
          );
          installerError = InstallScreenBackupError.Retriable;
        }

        window.reduxActions.installer.updateBackupImportProgress({
          error: installerError,
        });

        // For download errors, wait for user confirmation to retry or unlink
        const nextStep =
          error instanceof BackupImportCanceledError
            ? 'cancel'
            : // eslint-disable-next-line no-await-in-loop
              await this.#downloadRetryPromise.promise;
        if (nextStep === 'retry') {
          log.warn('backups.downloadAndImport: retrying');
          continue;
        }

        if (nextStep !== 'cancel') {
          throw missingCaseError(nextStep);
        }

        // If we are here: the user has either canceled manually, or after
        // getting an error (potentially fatal).
        log.warn('backups.downloadAndImport: unlinking');

        // eslint-disable-next-line no-await-in-loop
        await this.#unlinkAndDeleteAllData();

        try {
          // eslint-disable-next-line no-await-in-loop
          await unlink(absoluteDownloadPath);
        } catch {
          // Best-effort
        }

        // Make sure to fail the backup import process so that background.ts
        // will not wait for the syncs.
        throw error;
      }
      break;
    }

    await itemStorage.remove('backupDownloadPath');
    await itemStorage.remove('backupEphemeralKey');
    await itemStorage.remove('backupTransitArchive');
    await itemStorage.put('isRestoredFromBackup', hasBackup);

    log.info('backups.downloadAndImport: done');

    return { wasBackupImported: hasBackup };
  }

  public retryDownload(): void {
    if (!this.#downloadRetryPromise) {
      return;
    }

    this.#downloadRetryPromise.resolve('retry');
  }

  public async upload(): Promise<void> {
    await this.#waitForEmptyQueues('backups.upload');

    const fileName = `backup-${randomBytes(32).toString('hex')}`;
    const filePath = join(TEMP_PATH, fileName);

    const backupLevel = await this.credentials.getBackupLevel(
      BackupCredentialType.Media
    );
    log.info(`exportBackup: starting, backup level: ${backupLevel}...`);

    try {
      const { totalBytes } = await this.exportToDisk(filePath, {
        type: 'remote',
        level: backupLevel,
        abortSignal: new AbortController().signal,
      });

      await this.api.upload(filePath, totalBytes);
    } finally {
      try {
        await unlink(filePath);
      } catch {
        // Ignore
      }
    }
  }

  public async exportLocalBackup(options: {
    backupsBaseDir: string;
    abortSignal: AbortSignal;
    onProgress: OnProgressCallback;
  }): Promise<LocalBackupExportResultType> {
    strictAssert(isLocalBackupsEnabled(), 'Local backups must be enabled');
    const fnLog = log.child('exportLocalBackup');
    fnLog.info('starting...');

    if (isOnline()) {
      await this.#waitForEmptyQueues('backups.exportLocalBackup');
    } else {
      fnLog.info('offline; skipping wait for empty queues');
    }

    const snapshotDir = join(
      options.backupsBaseDir,
      `signal-backup-${getTimestampForFolder()}`
    );

    const freeSpaceBytes = await getFreeDiskSpace(options.backupsBaseDir);
    const bytesNeeded = MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT - freeSpaceBytes;
    if (bytesNeeded > 0) {
      fnLog.info(
        `Not enough storage; only ${freeSpaceBytes} available, ${MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT} is minimum needed`
      );
    }

    const filesDir = getLocalBackupFilesDirectory({
      backupsBaseDir: options.backupsBaseDir,
    });

    await mkdir(filesDir, { recursive: true });

    const attachmentPathsWrittenBeforeThisBackup =
      await getAllPathsInLocalBackupFilesDirectory({
        backupsBaseDir: options.backupsBaseDir,
      });

    try {
      await mkdir(snapshotDir, { recursive: true });

      const exportResult = await this.exportToDisk(join(snapshotDir, 'main'), {
        type: 'local-encrypted',
        snapshotDir,
        abortSignal: options.abortSignal,
      });

      const metadataArgs = {
        snapshotDir,
        backupId: getBackupId(),
        metadataKey: getLocalBackupMetadataKey(),
      };
      await writeLocalBackupMetadata(metadataArgs);
      await verifyLocalBackupMetadata(metadataArgs);
      await this.#runLocalAttachmentBackupJobs({
        attachmentBackupJobs: exportResult.attachmentBackupJobs,
        baseDir: options.backupsBaseDir,
        onProgress: options.onProgress,
        abortSignal: options.abortSignal,
      });

      return { ...exportResult, snapshotDir };
    } catch (e) {
      if (options.abortSignal.aborted) {
        log.warn('exportLocalBackup aborted', Errors.toLogFormat(e));
      } else {
        log.error('exportLocalBackup encountered error', Errors.toLogFormat(e));
      }

      log.info('Deleting snapshot directory');
      await rm(snapshotDir, { recursive: true, force: true });
      log.info('Deleted Snapshot directory');

      const attachmentPathsAfterBackup =
        await getAllPathsInLocalBackupFilesDirectory({
          backupsBaseDir: options.backupsBaseDir,
        });

      const pathsAdded = new Set(attachmentPathsAfterBackup).difference(
        new Set(attachmentPathsWrittenBeforeThisBackup)
      );

      if (pathsAdded.size > 0) {
        log.info(`Deleting ${pathsAdded.size} newly written files`);
        await Promise.all([...pathsAdded].map(safeUnlink));
        log.info(`Deleted ${pathsAdded.size} files`);
      }

      throw e;
    }
  }

  async #runLocalAttachmentBackupJobs({
    attachmentBackupJobs,
    baseDir,
    onProgress,
    abortSignal,
  }: {
    attachmentBackupJobs: ExportResultType['attachmentBackupJobs'];
    baseDir: string;
    onProgress: OnProgressCallback;
    abortSignal: AbortSignal;
  }) {
    let totalAttachmentBytes = 0;
    let currentBytes = 0;

    log.info(
      `runLocalExportAttachmentBackupJobs: About to process ${attachmentBackupJobs.length} jobs`
    );

    for (const job of attachmentBackupJobs) {
      strictAssert(job.type === 'local', 'must be local');
      totalAttachmentBytes += job.data.size;
    }

    const freeSpaceBytes = await getFreeDiskSpace(baseDir);
    const bufferBytes = 100 * MEBIBYTE;
    const bytesNeeded = totalAttachmentBytes + bufferBytes - freeSpaceBytes;

    if (bytesNeeded > 0) {
      log.info(
        `exportLocalBackup: Not enough storage; only ${freeSpaceBytes} available, ${totalAttachmentBytes} of attachments to export`
      );
      throw new NotEnoughStorageError(bytesNeeded);
    }

    for (const job of attachmentBackupJobs) {
      strictAssert(job.type === 'local', 'must be local');

      if (abortSignal.aborted) {
        log.info(
          'exportLocalBackup: Aborted; exiting before processing all attachment jobs'
        );
        throw new Error('User aborted the export!');
      }

      try {
        // eslint-disable-next-line no-await-in-loop
        await runAttachmentBackupJob(job, baseDir);

        currentBytes += job.data.size;
        onProgress(currentBytes, totalAttachmentBytes);
      } catch (error) {
        if (error instanceof AttachmentPermanentlyMissingError) {
          log.error(
            `${getJobIdForLogging(job)}: Attachment was not found; continuing with export`
          );
          currentBytes += job.data.size;
          continue;
        }

        const stillToExportBytes = totalAttachmentBytes - currentBytes;
        if (error.code === 'ENOSPC') {
          throw new RanOutOfStorageError(stillToExportBytes);
        }
        if (error.code === 'EPERM' || error.code === 'EACCES') {
          throw new StoragePermissionsError();
        }

        throw error;
      }
    }
  }

  public async stageLocalBackupForImport(
    snapshotDir: string
  ): Promise<ValidateLocalBackupStructureResultType> {
    const result = await validateLocalBackupStructure(snapshotDir);
    const { success, error } = result;
    if (success) {
      this.#localBackupSnapshotDir = snapshotDir;
      log.info(
        `stageLocalBackupForImport: Staged ${snapshotDir} for import. Please link to perform import.`
      );
    } else {
      this.#localBackupSnapshotDir = undefined;
      log.info(
        `stageLocalBackupForImport: Invalid snapshot ${snapshotDir}. Error: ${error}.`
      );
    }
    return result;
  }

  public isLocalBackupStaged(): boolean {
    return Boolean(this.#localBackupSnapshotDir);
  }

  public async importLocalBackup(): Promise<void> {
    strictAssert(
      this.#localBackupSnapshotDir,
      'importLocalBackup: Staged backup is required, use stageLocalBackupForImport()'
    );

    log.info(`importLocalBackup: Importing ${this.#localBackupSnapshotDir}`);

    const backupFile = join(this.#localBackupSnapshotDir, 'main');
    await this.importFromDisk(backupFile, {
      type: 'local-encrypted',
      localBackupSnapshotDir: this.#localBackupSnapshotDir,
    });

    await verifyLocalBackupMetadata({
      snapshotDir: this.#localBackupSnapshotDir,
      backupId: getBackupId(),
      metadataKey: getLocalBackupMetadataKey(),
    });

    this.#localBackupSnapshotDir = undefined;

    log.info('importLocalBackup: Done');
  }

  // Test harness
  public async exportBackupData(
    options: BackupExportOptions
  ): Promise<{ data: Uint8Array } & ExportResultType> {
    const sink = new PassThrough();

    const chunks = new Array<Uint8Array>();
    sink.on('data', chunk => chunks.push(chunk));
    const result = await this.#exportBackup(sink, options);

    return {
      ...result,
      data: Bytes.concatenate(chunks),
    };
  }

  public async exportToDisk(
    path: string,
    options: BackupExportOptions
  ): Promise<ExportResultType> {
    const exportResult = await this.#exportBackup(
      createWriteStream(path),
      options
    );

    if (options.type === 'local-encrypted' || options.type === 'remote') {
      await validateBackup(
        () => new FileStream(path),
        exportResult.totalBytes,
        isTestOrMockEnvironment()
          ? ValidationType.Internal
          : ValidationType.Export
      );
    }

    return exportResult;
  }

  public async exportPlaintext({
    abortSignal,
    onProgress,
    shouldIncludeMedia,
    targetPath,
  }: {
    abortSignal: AbortSignal;
    onProgress: OnProgressCallback;
    shouldIncludeMedia: boolean;
    targetPath: string;
  }): Promise<LocalBackupExportResultType> {
    let exportDir: string | undefined;
    const fnLog = log.child('exportPlaintext');
    try {
      fnLog.info('starting...');

      const freeSpaceBytes = await getFreeDiskSpace(targetPath);
      const bytesNeeded = MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT - freeSpaceBytes;
      if (bytesNeeded > 0) {
        fnLog.info(
          `Not enough storage; only ${freeSpaceBytes} available, ${MIMINUM_DISK_SPACE_FOR_LOCAL_EXPORT} is minimum needed`
        );
        throw new NotEnoughStorageError(bytesNeeded);
      }

      exportDir = join(targetPath, `signal-export-${getTimestampForFolder()}`);

      await mkdir(exportDir, { recursive: true });

      strictAssert(
        isFeaturedEnabledNoRedux({
          betaKey: 'desktop.plaintextExport.beta',
          prodKey: 'desktop.plaintextExport.prod',
        }),
        'Plaintext export must be enabled'
      );

      if (isOnline()) {
        await this.#waitForEmptyQueues('backups.exportPlaintext');
      } else {
        fnLog.info('exportPlaintext: Offline; skipping wait for empty queues');
      }

      fnLog.info('exportPlaintext: starting...');

      await mkdir(exportDir, { recursive: true });

      const exportResult = await this.exportToDisk(
        join(exportDir, 'main.jsonl'),
        {
          type: 'plaintext-export',
          abortSignal,
        }
      );

      fnLog.info('exportPlaintext: writing metadata');

      const metadataPath = join(exportDir, 'metadata.json');
      await writeFile(
        metadataPath,
        JSON.stringify({
          version: LOCAL_BACKUP_VERSION,
        })
      );

      if (shouldIncludeMedia) {
        await this.#runLocalAttachmentBackupJobs({
          attachmentBackupJobs: exportResult.attachmentBackupJobs,
          baseDir: exportDir,
          onProgress,
          abortSignal,
        });
      }

      fnLog.info('finished');

      return {
        ...exportResult,
        snapshotDir: exportDir,
      };
    } catch (error) {
      fnLog.warn('encountered error', Errors.toLogFormat(error));
      if (exportDir) {
        fnLog.info('Deleting export directory');
        await rm(exportDir, { recursive: true, force: true });
        fnLog.info('Export directory deleted');
      }

      if (error.code === 'EPERM' || error.code === 'EACCES') {
        throw new StoragePermissionsError();
      }

      throw error;
    }
  }

  public async _internalStageLocalBackupForImport(): Promise<ValidateLocalBackupStructureResultType> {
    const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
      'show-open-folder-dialog'
    );
    if (canceled || !snapshotDir) {
      return {
        success: false,
        error: 'File dialog canceled',
        snapshotDir: undefined,
      };
    }

    return this.stageLocalBackupForImport(snapshotDir);
  }

  // Test harness
  public async _internalValidate(): Promise<ValidationResultType> {
    try {
      const start = Date.now();

      const recordStream = new BackupExportStream({
        type: 'remote',
        level: BackupLevel.Free,
        abortSignal: new AbortController().signal,
      });

      recordStream.run();

      const totalBytes = await validateBackupStream(recordStream);

      const duration = Date.now() - start;

      return {
        result: {
          attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
          duration,
          stats: recordStream.getStats(),
          totalBytes,
        },
      };
    } catch (error) {
      return { error: Errors.toLogFormat(error) };
    }
  }

  public async importFromDisk(
    backupFile: string,
    options: BackupImportOptions
  ): Promise<void> {
    return this.importBackup(() => createReadStream(backupFile), options);
  }

  public cancelDownloadAndImport(): void {
    if (!this.#downloadController && !this.#importController) {
      log.error(
        'cancelDownloadAndImport: not canceling, download or import is not running'
      );
      return;
    }

    if (this.#downloadController) {
      log.warn('cancelDownloadAndImport: canceling download');
      this.#downloadController.abort();
      this.#downloadController = undefined;
      if (this.#downloadRetryPromise) {
        this.#downloadRetryPromise.resolve('cancel');
      }
    }

    if (this.#importController) {
      log.warn('cancelDownloadAndImport: canceling import processing');
      this.#importController.abort();
      this.#importController = undefined;
    }
  }

  public async importBackup(
    createBackupStream: () => Readable,
    options: BackupImportOptions
  ): Promise<void> {
    strictAssert(!this.#isRunning, 'BackupService is already running');

    window.IPC.startTrackingQueryStats();

    log.info(`importBackup: starting ${options.type}...`);
    this.#isRunning = 'import';
    const importStart = Date.now();

    await DataWriter.disableMessageInsertTriggers();
    await DataWriter.disableFSync();

    try {
      const controller = new AbortController();

      this.#importController?.abort();
      this.#importController = controller;

      window.ConversationController.setReadOnly(true);

      const importStream = await BackupImportStream.create(options);
      if (options.type === 'remote' || options.type === 'local-encrypted') {
        const { aesKey, macKey } = getKeyMaterial(
          options.ephemeralKey ? new BackupKey(options.ephemeralKey) : undefined
        );

        // First pass - don't decrypt, only verify mac
        let hmac = createHmac(HashType.size256, macKey);
        let theirMac: Uint8Array | undefined;
        let totalBytes = 0;

        const sink = new PassThrough();
        sink.on('data', chunk => {
          totalBytes += chunk.byteLength;
        });
        // Discard the data in the first pass
        sink.resume();

        await pipeline(
          createBackupStream(),
          getMacAndUpdateHmac(hmac, theirMacValue => {
            theirMac = theirMacValue;
          }),
          sink
        );

        if (controller.signal.aborted) {
          throw new BackupImportCanceledError();
        }

        options.onProgress?.(0, totalBytes);

        strictAssert(theirMac != null, 'importBackup: Missing MAC');
        strictAssert(
          constantTimeEqual(hmac.digest(), theirMac),
          'importBackup: Bad MAC'
        );

        // Second pass - decrypt (but still check the mac at the end)
        hmac = createHmac(HashType.size256, macKey);

        const progressReporter = new PassThrough();
        progressReporter.pause();

        let currentBytes = 0;
        progressReporter.on('data', chunk => {
          currentBytes += chunk.byteLength;
          options.onProgress?.(currentBytes, totalBytes);
        });

        await pipeline(
          createBackupStream(),
          getMacAndUpdateHmac(hmac, noop),
          progressReporter,
          decipherWithAesKey(aesKey),
          createGunzip(),
          new DelimitedStream(),
          importStream,
          { signal: controller.signal }
        );

        strictAssert(
          constantTimeEqual(hmac.digest(), theirMac),
          'importBackup: Bad MAC, second pass'
        );
      } else if (options.type === 'cross-client-integration-test') {
        strictAssert(
          isTestOrMockEnvironment(),
          'Plaintext backups can be imported only in test harness'
        );
        strictAssert(
          options.ephemeralKey == null,
          'Plaintext backups cannot have ephemeral key'
        );
        await pipeline(
          createBackupStream(),
          new DelimitedStream(),
          importStream
        );
      } else {
        throw missingCaseError(options.type);
      }

      log.info('importBackup: finished...');
    } catch (error) {
      if (error.name === 'AbortError') {
        log.info('importBackup: canceled by user');
        throw new BackupImportCanceledError();
      }

      log.error(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);

      if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) {
        window.reduxActions.toast.showToast({
          toastType: ToastType.FailedToImportBackup,
        });
      }

      throw error;
    } finally {
      window.ConversationController.setReadOnly(false);
      this.#isRunning = false;
      this.#importController = undefined;

      await DataWriter.enableMessageInsertTriggersAndBackfill();
      await DataWriter.enableFSyncAndCheckpoint();

      window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
      if (window.SignalCI) {
        window.SignalCI.handleEvent('backupImportComplete', {
          duration: Date.now() - importStart,
        });
      }
    }
  }

  public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
    log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
    await DataWriter.clearAllBackupCdnObjectMetadata();

    strictAssert(
      areRemoteBackupsTurnedOn(),
      'Remote backups must be turned on to fetch cdn metadata'
    );

    let cursor: string | undefined;
    const PAGE_SIZE = 1000;
    let numObjects = 0;
    do {
      log.info('fetchAndSaveBackupCdnObjectMetadata: fetching next page');
      // eslint-disable-next-line no-await-in-loop
      const listResult = await this.api.listMedia({ cursor, limit: PAGE_SIZE });

      // eslint-disable-next-line no-await-in-loop
      await DataWriter.saveBackupCdnObjectMetadata(
        listResult.storedMediaObjects.map(object => ({
          mediaId: object.mediaId,
          cdnNumber: object.cdn,
          sizeOnBackupCdn: object.objectLength,
        }))
      );
      numObjects += listResult.storedMediaObjects.length;

      cursor = listResult.cursor ?? undefined;
    } while (cursor);

    log.info(
      `fetchAndSaveBackupCdnObjectMetadata: finished fetching metadata for ${numObjects} objects`
    );
  }

  public async getBackupCdnInfo(
    mediaId: string
  ): Promise<
    { isInBackupTier: true; cdnNumber: number } | { isInBackupTier: false }
  > {
    const storedInfo = await DataReader.getBackupCdnObjectMetadata(mediaId);
    if (!storedInfo) {
      return { isInBackupTier: false };
    }

    return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber };
  }

  async #doDownloadAndImport({
    downloadPath,
    ephemeralKey,
    onProgress,
  }: DoDownloadOptionsType): Promise<boolean> {
    const controller = new AbortController();

    // Abort previous download
    this.#downloadController?.abort();
    this.#downloadController = controller;

    let downloadOffset = 0;
    try {
      ({ size: downloadOffset } = await stat(downloadPath));
    } catch (error) {
      if (error.code !== 'ENOENT') {
        throw error;
      }

      // File is missing - start from the beginning
    }

    const onDownloadProgress = (
      currentBytes: number,
      totalBytes: number
    ): void => {
      onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes);
    };

    await ensureFile(downloadPath);
    if (controller.signal.aborted) {
      throw new BackupImportCanceledError();
    }

    let stream: Readable;

    try {
      if (ephemeralKey == null) {
        stream = await this.api.download({
          downloadOffset,
          onProgress: onDownloadProgress,
          abortSignal: controller.signal,
        });
      } else {
        let archive = itemStorage.get('backupTransitArchive');
        if (archive == null) {
          const response = await this.api.getTransferArchive(controller.signal);
          if ('error' in response) {
            switch (response.error) {
              case 'RELINK_REQUESTED':
                throw new RelinkRequestedError();

              // Primary decided to abort syncing process; continue on with no backup
              case 'CONTINUE_WITHOUT_UPLOAD':
                log.error(
                  'backups.doDownloadAndImport: primary requested to continue without syncing'
                );
                return false;
              default:
                throw missingCaseError(response.error);
            }
          }

          archive = {
            cdn: response.cdn,
            key: response.key,
          };
          await itemStorage.put('backupTransitArchive', archive);
        }

        stream = await this.api.downloadEphemeral({
          archive,
          downloadOffset,
          onProgress: onDownloadProgress,
          abortSignal: controller.signal,
        });
      }
    } catch (error) {
      if (controller.signal.aborted) {
        throw new BackupImportCanceledError();
      }

      // No backup on the server
      if (error instanceof HTTPError && error.code === 404) {
        return false;
      }

      if (error instanceof BackupInstallerError) {
        throw error;
      }

      log.error(
        'backups.doDownloadAndImport: error downloading backup file',
        Errors.toLogFormat(error)
      );
      throw new BackupDownloadFailedError();
    }

    if (controller.signal.aborted) {
      throw new BackupImportCanceledError();
    }

    try {
      await pipeline(
        stream,
        createWriteStream(downloadPath, {
          flags: 'a',
          start: downloadOffset,
        })
      );

      if (controller.signal.aborted) {
        throw new BackupImportCanceledError();
      }

      this.#downloadController = undefined;

      try {
        // Import and start writing to the DB. Make sure we are unlinked
        // if the import process is aborted due to error or restart.
        const password = itemStorage.get('password');
        strictAssert(password != null, 'Must be registered to import backup');

        await itemStorage.remove('password');

        await this.importFromDisk(downloadPath, {
          type: 'remote',
          ephemeralKey,
          onProgress: (currentBytes, totalBytes) => {
            onProgress?.(
              InstallScreenBackupStep.Process,
              currentBytes,
              totalBytes
            );
          },
        });

        // Restore password on success
        await itemStorage.put('password', password);
      } catch (e) {
        // Error or manual cancel during import; this is non-retriable
        if (e instanceof BackupInstallerError) {
          throw e;
        } else {
          throw new BackupProcessingError(e);
        }
      } finally {
        await unlink(downloadPath);
      }
    } catch (error) {
      // Download canceled
      if (error.name === 'AbortError') {
        throw new BackupImportCanceledError();
      }

      // Other errors bubble up and can be retried
      throw error;
    }

    return true;
  }

  async #exportBackup(
    sink: Writable,
    options: BackupExportOptions
  ): Promise<ExportResultType> {
    strictAssert(!this.#isRunning, 'BackupService is already running');

    log.info('exportBackup: starting...');
    this.#isRunning = 'export';

    const start = Date.now();
    try {
      if (options.type === 'remote') {
        strictAssert(
          areRemoteBackupsTurnedOn(),
          'Remote backups must be turned on for a remote export'
        );
      }

      // TODO (DESKTOP-7168): Update mock-server to support this endpoint
      if (window.SignalCI || options.type === 'cross-client-integration-test') {
        strictAssert(
          isTestOrMockEnvironment(),
          'exportBackup: cross-client-integration tests must only be run in test harness'
        );
      }

      switch (options.type) {
        case 'remote':
          log.info('exportBackup: Fetching latest backup CDN metadata');
          await this.fetchAndSaveBackupCdnObjectMetadata();
          break;
        case 'cross-client-integration-test':
        case 'local-encrypted':
        case 'plaintext-export':
          // no need to fetch what's on backup CDN
          break;
        default:
          throw missingCaseError(options);
      }

      const { aesKey, macKey } = getKeyMaterial();
      const recordStream = new BackupExportStream(options);

      recordStream.run();

      const iv = randomBytes(IV_LENGTH);

      let totalBytes = 0;

      const { type } = options;
      switch (type) {
        case 'remote':
        case 'local-encrypted':
          await pipeline(
            recordStream,
            createGzip(),
            appendPaddingStream(),
            createCipheriv(CipherType.AES256CBC, aesKey, iv),
            prependStream(iv),
            appendMacStream(macKey),
            measureSize({
              onComplete: size => {
                totalBytes = size;
              },
            }),
            sink,
            { signal: options.abortSignal }
          );
          break;
        case 'cross-client-integration-test':
          strictAssert(
            isTestOrMockEnvironment(),
            'exportBackup: Plaintext backups can be exported only in test harness'
          );
          await pipeline(
            recordStream,
            measureSize({
              onComplete: size => {
                totalBytes = size;
              },
            }),
            sink,
            { signal: options.abortSignal }
          );
          break;
        case 'plaintext-export':
          await pipeline(
            recordStream,
            measureSize({
              onComplete: size => {
                totalBytes = size;
              },
            }),
            sink,
            { signal: options.abortSignal }
          );
          break;
        default:
          throw missingCaseError(type);
      }

      if (type === 'local-encrypted') {
        log.info('exportBackup: writing local backup files list');
        const filesWritten = await writeLocalBackupFilesList({
          snapshotDir: options.snapshotDir,
          mediaNamesIterator: recordStream.getMediaNamesIterator(),
        });
        const filesRead = await readLocalBackupFilesList(options.snapshotDir);
        strictAssert(
          isEqual(filesWritten, filesRead),
          'exportBackup: Local backup files proto must match files written'
        );
      }

      const duration = Date.now() - start;
      return {
        attachmentBackupJobs: recordStream.getAttachmentBackupJobs(),
        totalBytes,
        stats: recordStream.getStats(),
        duration,
      };
    } finally {
      log.info('exportBackup: finished...');
      this.#isRunning = false;
    }
  }

  async #runPeriodicRefresh(): Promise<void> {
    try {
      await this.api.refresh();
      log.info('Backup: refreshed');
    } catch (error) {
      log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
    }
    await this.refreshBackupAndSubscriptionStatus();
  }

  async #unlinkAndDeleteAllData() {
    window.reduxActions.installer.updateBackupImportProgress({
      error: InstallScreenBackupError.Canceled,
    });

    try {
      await unlinkAccount();
    } catch (e) {
      log.warn(
        'Error while unlinking; this may be expected for the unlink operation',
        Errors.toLogFormat(e)
      );
    }

    try {
      log.info('backups.unlinkAndDeleteAllData: deleting all data');
      await signalProtocolStore.removeAllData();
      log.info('backups.unlinkAndDeleteAllData: all data deleted successfully');
    } catch (e) {
      log.error(
        'backups.unlinkAndDeleteAllData: unable to remove all data',
        Errors.toLogFormat(e)
      );
    }

    // The QR code should be regenerated only after all data is cleared to prevent
    // a race where the QR code doesn't show the backup capability
    window.reduxActions.installer.startInstaller();
  }

  async #waitForEmptyQueues(
    reason:
      | 'backups.upload'
      | 'backups.exportPlaintext'
      | 'backups.exportLocalBackup'
  ) {
    // Make sure we are up-to-date on storage service
    {
      const { promise: storageService, resolve } = explodePromise<void>();
      window.Whisper.events.once('storageService:syncComplete', resolve);

      runStorageServiceSyncJob({ reason });
      runStorageServiceSyncJob.flush();
      await storageService;
    }

    // Clear message queue
    await window.waitForEmptyEventQueue();

    // Make sure all batches are flushed
    await Promise.all([waitForAllBatchers(), flushAllWaitBatchers()]);
  }

  public isImportRunning(): boolean {
    return this.#isRunning === 'import';
  }
  public isExportRunning(): boolean {
    return this.#isRunning === 'export';
  }

  #getBackupTierFromStorage(): BackupLevel | null {
    const backupTier = itemStorage.get('backupTier');
    switch (backupTier) {
      case BackupLevel.Free:
        return BackupLevel.Free;
      case BackupLevel.Paid:
        return BackupLevel.Paid;
      case undefined:
        return null;
      default:
        log.error('Unknown backupTier in storage', backupTier);
        return null;
    }
  }

  async #fetchCloudBackupStatus(): Promise<BackupStatusType | undefined> {
    let result: BackupStatusType | undefined;
    const backupProtoInfo = await this.api.getBackupProtoInfo();

    if (backupProtoInfo.backupExists) {
      const { createdAt, size: protoSize } = backupProtoInfo;
      result = {
        createdTimestamp: createdAt.getTime(),
        protoSize,
      };
    }

    await itemStorage.put('cloudBackupStatus', result);
    return result;
  }

  async #fetchSubscriptionStatus(): Promise<
    BackupsSubscriptionType | undefined
  > {
    const cachedBackupSubscriptionStatus = itemStorage.get(
      'backupSubscriptionStatus'
    );
    const backupTier = this.#getBackupTierFromStorage();
    let result: BackupsSubscriptionType | undefined;
    switch (backupTier) {
      case null:
      case undefined:
      case BackupLevel.Free:
        result = { status: 'not-found' };
        break;
      case BackupLevel.Paid:
        await itemStorage.put('backupSubscriptionStatus', {
          ...(cachedBackupSubscriptionStatus ?? { status: 'not-found' }),
          isFetching: true,
        });
        result = await this.api.getSubscriptionInfo();
        break;
      default:
        throw missingCaseError(backupTier);
    }

    await itemStorage.put('backupSubscriptionStatus', {
      ...result,
      lastFetchedAtMs: Date.now(),
      isFetching: false,
    });
    return result;
  }

  async refreshBackupAndSubscriptionStatus(): Promise<void> {
    await Promise.all([
      this.#fetchSubscriptionStatus(),
      this.#fetchCloudBackupStatus(),
    ]);
  }

  async resetCachedData(): Promise<void> {
    this.api.clearCache();
    await this.credentials.clearCache();
    await itemStorage.remove('backupSubscriptionStatus');
    await itemStorage.remove('cloudBackupStatus');
    await this.refreshBackupAndSubscriptionStatus();
  }

  hasMediaBackups(): boolean {
    return itemStorage.get('backupTier') === BackupLevel.Paid;
  }

  getCachedCloudBackupStatus(): BackupStatusType | undefined {
    return itemStorage.get('cloudBackupStatus');
  }

  async pickLocalBackupFolder(): Promise<string | undefined> {
    const { canceled, dirPath: backupsParentDir } = await ipcRenderer.invoke(
      'show-open-folder-dialog'
    );
    if (canceled || !backupsParentDir) {
      return;
    }

    const localBackupsBaseDir = join(backupsParentDir, 'SignalBackups');

    await mkdir(localBackupsBaseDir, { recursive: true });

    await itemStorage.put('localBackupFolder', localBackupsBaseDir);
    return localBackupsBaseDir;
  }
}

export const backupsService = new BackupsService();
