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

import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';

import type { StateType as RootStateType } from '../reducer';
import {
  type InstallScreenBackupError,
  InstallScreenBackupStep,
  InstallScreenStep,
  InstallScreenError,
  InstallScreenQRCodeError,
} from '../../types/InstallScreen';
import * as Errors from '../../types/errors';
import { type Loadable, LoadingState } from '../../util/loadable';
import { isRecord } from '../../util/isRecord';
import { strictAssert } from '../../util/assert';
import * as Registration from '../../util/registration';
import { isBackupEnabled } from '../../util/isBackupEnabled';
import { missingCaseError } from '../../util/missingCaseError';
import { HTTPError } from '../../textsecure/Errors';
import {
  Provisioner,
  EventKind as ProvisionEventKind,
  type EnvelopeType as ProvisionEnvelopeType,
} from '../../textsecure/Provisioner';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import * as log from '../../logging/log';
import { backupsService } from '../../services/backups';
import OS from '../../util/os/osMain';

export type BatonType = ReadonlyDeep<{ __installer_baton: never }>;

const cancelByBaton = new WeakMap<BatonType, () => void>();
let provisioner: Provisioner | undefined;

export type InstallerStateType = ReadonlyDeep<
  | {
      step: InstallScreenStep.NotStarted;
    }
  | {
      step: InstallScreenStep.QrCodeNotScanned;
      provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
      baton: BatonType;
    }
  | {
      step: InstallScreenStep.ChoosingDeviceName;
      deviceName: string;
      backupFile?: File;
      envelope: ProvisionEnvelopeType;
      baton: BatonType;
    }
  | {
      step: InstallScreenStep.Error;
      error: InstallScreenError;
    }
  | {
      step: InstallScreenStep.LinkInProgress;
    }
  | ({
      step: InstallScreenStep.BackupImport;
      backupStep: InstallScreenBackupStep;
      error?: InstallScreenBackupError;
    } & (
      | {
          backupStep:
            | InstallScreenBackupStep.Download
            | InstallScreenBackupStep.Process;
          currentBytes: number;
          totalBytes: number;
        }
      | {
          backupStep: InstallScreenBackupStep.WaitForBackup;
        }
    ))
>;

export type RetryBackupImportValue = ReadonlyDeep<'retry' | 'cancel'>;

export const START_INSTALLER = 'installer/START_INSTALLER';
const SET_PROVISIONING_URL = 'installer/SET_PROVISIONING_URL';
const SET_QR_CODE_ERROR = 'installer/SET_QR_CODE_ERROR';
const SET_ERROR = 'installer/SET_ERROR';
const QR_CODE_SCANNED = 'installer/QR_CODE_SCANNED';
const RETRY_BACKUP_IMPORT = 'installer/RETRY_BACKUP_IMPORT';
const SHOW_LINK_IN_PROGRESS = 'installer/SHOW_LINK_IN_PROGRESS';
export const SHOW_BACKUP_IMPORT = 'installer/SHOW_BACKUP_IMPORT';
const UPDATE_BACKUP_IMPORT_PROGRESS = 'installer/UPDATE_BACKUP_IMPORT_PROGRESS';

export type StartInstallerActionType = ReadonlyDeep<{
  type: typeof START_INSTALLER;
  payload: BatonType;
}>;

type SetProvisioningUrlActionType = ReadonlyDeep<{
  type: typeof SET_PROVISIONING_URL;
  payload: string;
}>;

type SetQRCodeErrorActionType = ReadonlyDeep<{
  type: typeof SET_QR_CODE_ERROR;
  payload: InstallScreenQRCodeError;
}>;

type SetErrorActionType = ReadonlyDeep<{
  type: typeof SET_ERROR;
  payload: InstallScreenError;
}>;

type QRCodeScannedActionType = ReadonlyDeep<{
  type: typeof QR_CODE_SCANNED;
  payload: {
    deviceName: string;
    baton: BatonType;
    envelope: ProvisionEnvelopeType;
  };
}>;

type RetryBackupImportActionType = ReadonlyDeep<{
  type: typeof RETRY_BACKUP_IMPORT;
}>;

type ShowLinkInProgressActionType = ReadonlyDeep<{
  type: typeof SHOW_LINK_IN_PROGRESS;
}>;

export type ShowBackupImportActionType = ReadonlyDeep<{
  type: typeof SHOW_BACKUP_IMPORT;
}>;

type UpdateBackupImportProgressActionType = ReadonlyDeep<{
  type: typeof UPDATE_BACKUP_IMPORT_PROGRESS;
  payload:
    | {
        backupStep: InstallScreenBackupStep;
        currentBytes: number;
        totalBytes: number;
      }
    | {
        error: InstallScreenBackupError;
      };
}>;

export type InstallerActionType = ReadonlyDeep<
  | StartInstallerActionType
  | SetProvisioningUrlActionType
  | SetQRCodeErrorActionType
  | SetErrorActionType
  | QRCodeScannedActionType
  | RetryBackupImportActionType
  | ShowLinkInProgressActionType
  | ShowBackupImportActionType
  | UpdateBackupImportProgressActionType
>;

export const actions = {
  startInstaller,
  finishInstall,
  updateBackupImportProgress,
  retryBackupImport,
  showBackupImport,
  handleMissingBackup,
  showLinkInProgress,
};

export const useInstallerActions = (): BoundActionCreatorsMapObject<
  typeof actions
> => useBoundActions(actions);

function startInstaller(): ThunkAction<
  void,
  RootStateType,
  unknown,
  InstallerActionType
> {
  return async (dispatch, getState) => {
    // WeakMap key
    const baton = {} as BatonType;

    window.IPC.addSetupMenuItems();

    dispatch({
      type: START_INSTALLER,
      payload: baton,
    });
    const { installer: state } = getState();
    strictAssert(
      state.step === InstallScreenStep.QrCodeNotScanned,
      'Unexpected step after START_INSTALLER'
    );

    const { server } = window.textsecure;
    strictAssert(server, 'Expected a server');

    if (!provisioner) {
      provisioner = new Provisioner({
        server,
        appVersion: window.getVersion(),
      });
    }

    const cancel = provisioner.subscribe(event => {
      if (event.kind === ProvisionEventKind.MaxRotationsError) {
        log.warn('InstallScreen/getQRCode: max rotations reached');
        dispatch({
          type: SET_QR_CODE_ERROR,
          payload: InstallScreenQRCodeError.MaxRotations,
        });
      } else if (event.kind === ProvisionEventKind.TimeoutError) {
        if (event.canRetry) {
          log.warn('InstallScreen/getQRCode: timed out');
          dispatch({
            type: SET_QR_CODE_ERROR,
            payload: InstallScreenQRCodeError.Timeout,
          });
        } else {
          log.error('InstallScreen/getQRCode: too many tries');
          dispatch({
            type: SET_ERROR,
            payload: InstallScreenError.QRCodeFailed,
          });
        }
      } else if (event.kind === ProvisionEventKind.ConnectError) {
        const { error } = event;

        log.error(
          'installer: got an error while waiting for QR code',
          Errors.toLogFormat(error)
        );

        if (
          error instanceof HTTPError &&
          error.code === -1 &&
          isRecord(error.cause) &&
          error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN'
        ) {
          dispatch({
            type: SET_QR_CODE_ERROR,
            payload: InstallScreenQRCodeError.NetworkIssue,
          });
          return;
        }

        dispatch({
          type: SET_ERROR,
          payload: InstallScreenError.ConnectionFailed,
        });
      } else if (event.kind === ProvisionEventKind.EnvelopeError) {
        log.error(
          'installer: got an error while waiting for envelope',
          Errors.toLogFormat(event.error)
        );

        dispatch({
          type: SET_QR_CODE_ERROR,
          payload: InstallScreenQRCodeError.Unknown,
        });
      } else if (event.kind === ProvisionEventKind.URL) {
        window.SignalCI?.setProvisioningURL(event.url);
        dispatch({
          type: SET_PROVISIONING_URL,
          payload: event.url,
        });
      } else if (event.kind === ProvisionEventKind.Envelope) {
        const { envelope } = event;

        if (event.isLinkAndSync) {
          const deviceName = OS.getName() || 'Signal Desktop';
          dispatch(
            finishInstall({
              envelope,
              deviceName,
              isLinkAndSync: true,
            })
          );
        } else {
          const deviceName =
            window.textsecure.storage.user.getDeviceName() ||
            window.getHostName() ||
            '';

          // Show screen to choose device name
          dispatch({
            type: QR_CODE_SCANNED,
            payload: {
              deviceName,
              envelope,
              baton,
            },
          });

          // And feed it the CI data if present
          const { SignalCI } = window;
          if (SignalCI != null) {
            dispatch(
              finishInstall({
                envelope,
                deviceName: SignalCI.deviceName,
                isLinkAndSync: false,
              })
            );
          }
        }
      } else {
        throw missingCaseError(event);
      }
    });

    cancelByBaton.set(baton, cancel);
  };
}

type FinishInstallOptionsType = ReadonlyDeep<{
  isLinkAndSync: boolean;
  deviceName: string;
  envelope?: ProvisionEnvelopeType;
  backupFile?: Uint8Array;
}>;

function finishInstall({
  isLinkAndSync,
  envelope: providedEnvelope,
  deviceName,
  backupFile,
}: FinishInstallOptionsType): ThunkAction<
  void,
  RootStateType,
  unknown,
  | SetQRCodeErrorActionType
  | SetErrorActionType
  | ShowLinkInProgressActionType
  | ShowBackupImportActionType
> {
  return async (dispatch, getState) => {
    const state = getState();
    strictAssert(
      provisioner != null,
      'Provisioner is not waiting for device info'
    );

    let envelope: ProvisionEnvelopeType;
    if (state.installer.step === InstallScreenStep.QrCodeNotScanned) {
      strictAssert(isLinkAndSync, 'Can only skip device naming if link & sync');
      strictAssert(
        providedEnvelope != null,
        'finishInstall: missing required envelope'
      );
      envelope = providedEnvelope;
    } else if (state.installer.step === InstallScreenStep.ChoosingDeviceName) {
      ({ envelope } = state.installer);
    } else {
      throw new Error('Wrong step');
    }

    // Cleanup
    const { baton } = state.installer;
    cancelByBaton.get(baton)?.();
    cancelByBaton.delete(baton);

    const accountManager = window.getAccountManager();
    strictAssert(accountManager, 'Expected an account manager');

    if (isBackupEnabled() || isLinkAndSync) {
      dispatch({ type: SHOW_BACKUP_IMPORT });
    } else {
      dispatch({ type: SHOW_LINK_IN_PROGRESS });
    }

    try {
      await accountManager.registerSecondDevice(
        Provisioner.prepareLinkData({
          envelope,
          deviceName,
          backupFile,
        })
      );
      window.IPC.removeSetupMenuItems();
    } catch (error) {
      if (error instanceof HTTPError) {
        switch (error.code) {
          case 409:
            dispatch({
              type: SET_ERROR,
              payload: InstallScreenError.TooOld,
            });
            return;
          case 411:
            dispatch({
              type: SET_ERROR,
              payload: InstallScreenError.TooManyDevices,
            });
            return;
          default:
            break;
        }
      }

      dispatch({
        type: SET_QR_CODE_ERROR,
        payload: InstallScreenQRCodeError.Unknown,
      });
      return;
    }

    // Delete all data from the database unless we're in the middle of a re-link.
    //   Without this, the app restarts at certain times and can cause weird things to
    //   happen, like data from a previous light import showing up after a new install.
    const shouldRetainData = Registration.everDone();
    if (!shouldRetainData) {
      try {
        await window.textsecure.storage.protocol.removeAllData();
      } catch (error) {
        log.error(
          'installer/finishInstall: error clearing database',
          Errors.toLogFormat(error)
        );
      }
    }
  };
}

function showBackupImport(): ShowBackupImportActionType {
  return { type: SHOW_BACKUP_IMPORT };
}

function showLinkInProgress(): ShowLinkInProgressActionType {
  return { type: SHOW_LINK_IN_PROGRESS };
}

function handleMissingBackup(): ShowLinkInProgressActionType {
  // If backup is missing, go to normal link-in-progress view
  return { type: SHOW_LINK_IN_PROGRESS };
}

function updateBackupImportProgress(
  payload: UpdateBackupImportProgressActionType['payload']
): UpdateBackupImportProgressActionType {
  return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload };
}

function retryBackupImport(): ThunkAction<
  void,
  RootStateType,
  unknown,
  RetryBackupImportActionType
> {
  return dispatch => {
    dispatch({ type: RETRY_BACKUP_IMPORT });
    backupsService.retryDownload();
  };
}

// Reducer

export function getEmptyState(): InstallerStateType {
  return {
    step: InstallScreenStep.NotStarted,
  };
}

export function reducer(
  state: Readonly<InstallerStateType> = getEmptyState(),
  action: Readonly<InstallerActionType>
): InstallerStateType {
  if (action.type === START_INSTALLER) {
    // Abort previous install
    if (state.step === InstallScreenStep.QrCodeNotScanned) {
      const cancel = cancelByBaton.get(state.baton);
      cancel?.();
    } else {
      // Reset qr code fetch attempt count when starting from scratch
      provisioner?.reset();
    }

    return {
      step: InstallScreenStep.QrCodeNotScanned,
      provisioningUrl: {
        loadingState: LoadingState.Loading,
      },
      baton: action.payload,
    };
  }

  if (action.type === SET_PROVISIONING_URL) {
    if (
      state.step !== InstallScreenStep.QrCodeNotScanned ||
      (state.provisioningUrl.loadingState !== LoadingState.Loading &&
        // Rotating
        state.provisioningUrl.loadingState !== LoadingState.Loaded)
    ) {
      log.warn('ducks/installer: not setting provisioning url', state.step);
      return state;
    }

    return {
      ...state,
      provisioningUrl: {
        loadingState: LoadingState.Loaded,
        value: action.payload,
      },
    };
  }

  if (action.type === SET_QR_CODE_ERROR) {
    if (
      state.step !== InstallScreenStep.QrCodeNotScanned ||
      !(
        state.provisioningUrl.loadingState === LoadingState.Loading ||
        // Rotating
        state.provisioningUrl.loadingState === LoadingState.Loaded
      )
    ) {
      log.warn('ducks/installer: not setting qr code error', state.step);
      return state;
    }

    return {
      ...state,
      provisioningUrl: {
        loadingState: LoadingState.LoadFailed,
        error: action.payload,
      },
    };
  }

  if (action.type === SET_ERROR) {
    return {
      step: InstallScreenStep.Error,
      error: action.payload,
    };
  }

  if (action.type === QR_CODE_SCANNED) {
    if (
      state.step !== InstallScreenStep.QrCodeNotScanned ||
      state.provisioningUrl.loadingState !== LoadingState.Loaded
    ) {
      log.warn('ducks/installer: not setting qr code scanned', state.step);
      return state;
    }

    return {
      step: InstallScreenStep.ChoosingDeviceName,
      deviceName: action.payload.deviceName,
      envelope: action.payload.envelope,
      baton: action.payload.baton,
    };
  }

  if (action.type === SHOW_LINK_IN_PROGRESS) {
    if (
      // Backups not supported
      state.step !== InstallScreenStep.ChoosingDeviceName &&
      // No backup available
      state.step !== InstallScreenStep.BackupImport
    ) {
      log.warn('ducks/installer: not setting link in progress', state.step);
      return state;
    }

    return {
      step: InstallScreenStep.LinkInProgress,
    };
  }

  if (action.type === SHOW_BACKUP_IMPORT) {
    if (
      // Downloading backup after linking
      state.step !== InstallScreenStep.QrCodeNotScanned &&
      // Restarting backup download on startup
      state.step !== InstallScreenStep.NotStarted
    ) {
      log.warn('ducks/installer: not setting backup import', state.step);
      return state;
    }

    return {
      step: InstallScreenStep.BackupImport,
      backupStep: InstallScreenBackupStep.WaitForBackup,
    };
  }

  if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) {
    if (state.step !== InstallScreenStep.BackupImport) {
      log.warn(
        'ducks/installer: not updating backup import progress',
        state.step
      );
      return state;
    }

    if ('error' in action.payload) {
      return {
        ...state,
        error: action.payload.error,
      };
    }

    return {
      ...state,
      backupStep: action.payload.backupStep,
      currentBytes: action.payload.currentBytes,
      totalBytes: action.payload.totalBytes,
    };
  }

  if (action.type === RETRY_BACKUP_IMPORT) {
    if (state.step !== InstallScreenStep.BackupImport) {
      log.warn(
        'ducks/installer: wrong step, not retrying backup import',
        state.step
      );
      return state;
    }

    return {
      ...state,
      error: undefined,
    };
  }

  return state;
}
