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

import {
  ErrorCode,
  LibSignalErrorBase,
  PublicKey,
  usernames,
} from '@signalapp/libsignal-client';
import type {
  Request,
  E164Info,
} from '@signalapp/libsignal-client/dist/net/KeyTransparency.js';
import pTimeout from 'p-timeout';

import { keyTransparencyCheck } from '../textsecure/WebAPI.preload.ts';
import { signalProtocolStore } from '../SignalProtocolStore.preload.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import { toLogFormat } from '../types/errors.std.ts';
import { toAciObject } from '../util/ServiceId.node.ts';
import { TaskDeduplicator } from '../util/TaskDeduplicator.std.ts';
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff.std.ts';
import { sleep } from '../util/sleep.std.ts';
import { SECOND, MINUTE, DAY, WEEK } from '../util/durations/constants.std.ts';
import { CheckScheduler } from '../util/CheckScheduler.preload.ts';
import { strictAssert } from '../util/assert.std.ts';
import { isFeaturedEnabledNoRedux } from '../util/isFeatureEnabled.dom.ts';
import { explodePromise } from '../util/explodePromise.std.ts';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.std.ts';
import * as Bytes from '../Bytes.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { isEnabled } from '../RemoteConfig.dom.ts';
import { DataWriter } from '../sql/Client.preload.ts';
import { runStorageServiceSyncJob } from './storage.preload.ts';

const log = createLogger('KeyTransparency');

// Longer timeouts because request size is large (5 second minimum)
const KEY_TRANSPARENCY_TIMEOUTS = FIBONACCI_TIMEOUTS.slice(3);

const KNOWN_IDENTIFIER_CHANGE_DELAY = 5 * MINUTE;

const INTERMITTENT_ERROR_RETRY_DELAY = 1 * DAY;

const STORAGE_SERVICE_TIMEOUT = 1 * MINUTE;

export function isKeyTransparencyAvailable(): boolean {
  if (itemStorage.get('hasKeyTransparencyDisabled')) {
    return false;
  }
  return isFeaturedEnabledNoRedux({
    betaKey: 'desktop.keyTransparency.beta',
    prodKey: 'desktop.keyTransparency.prod',
  });
}

class KeyTransparency {
  #isRunning = false;
  readonly #scheduler = new CheckScheduler({
    name: 'KeyTransparency',
    interval: WEEK,
    storageKey: 'lastKeyTransparencySelfCheck',
    backOffTimeouts: KEY_TRANSPARENCY_TIMEOUTS,

    callback: async () => {
      try {
        await this.selfCheck();
      } catch {
        // Ignore exceptions
      }
    },
  });

  readonly #selfCheckDedup = new TaskDeduplicator(
    'KeyTransparency.selfCheck',
    abortSignal => this.#selfCheck(abortSignal)
  );

  public async disable(): Promise<void> {
    await Promise.all([
      DataWriter.removeAllKTAccountData(),
      itemStorage.remove('lastDistinguishedTreeHead'),
      itemStorage.remove('keyTransparencySelfHealth'),
      itemStorage.remove('lastKeyTransparencySelfCheck'),
    ]);
  }

  public start(): void {
    strictAssert(!this.#isRunning, 'Already running');

    this.#isRunning = true;
    this.#scheduler.start();
  }

  public async onKnownIdentifierChange(): Promise<void> {
    await this.#scheduler.delayBy(KNOWN_IDENTIFIER_CHANGE_DELAY);
  }

  public async onRegistrationDone(): Promise<void> {
    await this.#scheduler.runAt(Date.now() + KNOWN_IDENTIFIER_CHANGE_DELAY);
  }

  public async check(
    conversationId: string,
    abortSignal?: AbortSignal
  ): Promise<void> {
    if (!isKeyTransparencyAvailable()) {
      log.warn('not running, feature disabled');
      throw new Error('Not available');
    }

    const convo = window.ConversationController.get(conversationId);
    strictAssert(convo != null, `Conversation ${conversationId} not found`);

    const aci = convo.getAci();
    strictAssert(aci != null, `Conversation ${conversationId} has no ACI`);

    const identityKey = await signalProtocolStore.loadIdentityKey(aci);
    strictAssert(
      identityKey != null,
      `Conversation ${conversationId} has no identity key`
    );

    if (abortSignal?.aborted) {
      throw new Error('Aborted');
    }

    let e164Info: E164Info | undefined;

    convo.deriveAccessKeyIfNeeded();
    const e164 = convo.get('e164');
    const accessKey = convo.get('accessKey');
    if (e164 != null && accessKey != null) {
      e164Info = {
        e164,
        unidentifiedAccessKey: Bytes.fromBase64(accessKey),
      };
    }

    await this.#check(
      {
        mode: 'contact',
        aciInfo: {
          aci: toAciObject(aci),
          identityKey: PublicKey.deserialize(identityKey),
        },
        e164Info,
      },
      abortSignal
    );

    if (abortSignal?.aborted) {
      throw new Error('Aborted');
    }

    const selfHealth = itemStorage.get('keyTransparencySelfHealth');
    if (selfHealth == null) {
      await this.selfCheck(abortSignal);
    } else {
      strictAssert(selfHealth === 'ok', 'Self KT check failed');
    }

    if (abortSignal?.aborted) {
      throw new Error('Aborted');
    }
  }

  async selfCheck(abortSignal?: AbortSignal): Promise<void> {
    return this.#selfCheckDedup.run(abortSignal);
  }

  async #selfCheck(abortSignal: AbortSignal): Promise<void> {
    if (!isKeyTransparencyAvailable()) {
      log.info('not running, feature disabled');
      return;
    }

    const ourAci = itemStorage.user.getAci();
    if (ourAci == null) {
      log.info('not running, no aci');
      return;
    }

    const keyPair = signalProtocolStore.getIdentityKeyPair(ourAci);
    if (keyPair == null) {
      log.error('not running, no identity key pair');
      return;
    }

    log.info('running self check');

    const me = window.ConversationController.getOurConversationOrThrow();

    const isE164Discoverable =
      itemStorage.get('phoneNumberDiscoverability') ===
      PhoneNumberDiscoverability.Discoverable;

    const ourE164 = itemStorage.user.getNumber();
    strictAssert(ourE164 != null, 'missing our e164');

    me.deriveAccessKeyIfNeeded();
    const ourAccessKey = me.get('accessKey');
    strictAssert(ourAccessKey != null, 'missing our access key');

    let usernameHash: Uint8Array<ArrayBuffer> | undefined;

    const username = me.get('username');
    if (username != null && !itemStorage.get('usernameCorrupted')) {
      usernameHash = usernames.hash(username);
    }

    if (itemStorage.get('keyTransparencySelfHealth') === 'intermittent') {
      runStorageServiceSyncJob({ reason: 'keyTransparency' });

      const { promise: once, resolve } = explodePromise<void>();

      // This makes sure that we are both on empty websocket queue and fully
      // up-to-date on storage service.
      window.Whisper.events.once('storageService:syncComplete', () =>
        resolve()
      );

      await pTimeout(once, { milliseconds: STORAGE_SERVICE_TIMEOUT });
    }

    try {
      await this.#check(
        {
          mode: 'self',
          isE164Discoverable,
          aciInfo: {
            aci: toAciObject(ourAci),
            identityKey: keyPair.publicKey,
          },
          e164Info: {
            e164: ourE164,
            unidentifiedAccessKey: Bytes.fromBase64(ourAccessKey),
          },
          usernameHash,
        },
        abortSignal
      );

      if (abortSignal?.aborted) {
        throw new Error('Aborted');
      }

      await itemStorage.put('keyTransparencySelfHealth', 'ok');
      log.info('self check success');
    } catch (error) {
      if (abortSignal?.aborted) {
        throw new Error('Aborted');
      }

      const oldResult = itemStorage.get('keyTransparencySelfHealth');
      let newResult: 'fail' | 'intermittent' | undefined;

      if (error instanceof LibSignalErrorBase) {
        if (error.is(ErrorCode.KeyTransparencyVerificationFailed)) {
          if (oldResult === 'intermittent' || oldResult === 'fail') {
            newResult = 'fail';
          } else {
            newResult = 'intermittent';
          }
        } else if (
          error.is(ErrorCode.KeyTransparencyError) ||
          error.is(ErrorCode.ChatServiceInactive) ||
          error.is(ErrorCode.IoError) ||
          error.is(ErrorCode.RateLimitedError)
        ) {
          if (oldResult === 'intermittent' || oldResult === 'fail') {
            // Keep failed state until successful retry
            newResult = oldResult;
          } else {
            // Update status to "unknown"
            newResult = undefined;
          }
        } else {
          // Unknown error
          newResult = 'fail';
        }
      }

      log.warn(
        'failed to check our own records',
        toLogFormat(error),
        'changing state to',
        newResult
      );

      await itemStorage.put('keyTransparencySelfHealth', newResult);
      if (
        (oldResult !== 'fail' || isEnabled('desktop.internalUser')) &&
        newResult === 'fail'
      ) {
        window.reduxActions.globalModals.showKeyTransparencyErrorDialog();
      }

      if (newResult === 'intermittent') {
        await this.#scheduler.runAt(
          Date.now() + INTERMITTENT_ERROR_RETRY_DELAY
        );
      }

      throw error;
    }
  }

  async #check(
    request: Request,
    abortSignal?: AbortSignal,
    backOff = new BackOff(KEY_TRANSPARENCY_TIMEOUTS)
  ): Promise<void> {
    try {
      if (abortSignal?.aborted) {
        throw new Error('Aborted');
      }
      await keyTransparencyCheck(request, abortSignal);
    } catch (error) {
      if (abortSignal?.aborted) {
        throw new Error('Aborted');
      }

      if (backOff.isFull() || !(error instanceof LibSignalErrorBase)) {
        throw error;
      }

      let timeout = backOff.getAndIncrement();

      if (
        error.is(ErrorCode.ChatServiceInactive) ||
        error.is(ErrorCode.IoError)
      ) {
        // Use default timeout
      } else if (error.is(ErrorCode.RateLimitedError)) {
        timeout = error.retryAfterSecs * SECOND;
      } else {
        // KeyTransparencyError, KeyTransparencyVerificationFailed, etc
        throw error;
      }

      log.warn(
        `retriable error error=${toLogFormat(error)} retrying in ` +
          `${timeout}ms`
      );
      await sleep(timeout, abortSignal);

      if (abortSignal?.aborted) {
        throw new Error('Aborted');
      }

      return this.#check(request, abortSignal, backOff);
    }
  }
}

export const keyTransparency = new KeyTransparency();
