import { EMPTY, merge } from "rxjs";
import type { Observable } from "rxjs";
import { catchError } from "rxjs/operators";
import type { DeviceModel } from "@ledgerhq/types-devices";
import Transport from "@ledgerhq/hw-transport";
import { TraceContext, trace } from "@ledgerhq/logs";
import { CantOpenDevice } from "@ledgerhq/errors";

export const LOG_TYPE = "hw";

export type DeviceEvent = {
  type: "add" | "remove";
  id: string;
  name: string;
  deviceModel?: DeviceModel | null;
  wired: boolean;
};

export type Discovery = Observable<DeviceEvent>;

// NB open/close/disconnect semantic will have to be refined...
export type TransportModule = {
  // unique transport name that identify the transport module
  id: string;
  /*
   * Opens a device by an id, this id must be unique across all modules
   * you can typically prefix it with `something|` that identify it globally
   * returns falsy if the transport module can't handle this id
   * here, open means we want to START doing something with the transport
   *
   * @param id
   * @param timeoutMs Optional timeout that can be used by the Transport implementation when opening a communication channel
   * @param context Optional context to be used in logs/tracing
   */
  open: (
    id: string,
    timeoutMs?: number,
    context?: TraceContext,
    matchDeviceByName?: string,
  ) => Promise<Transport> | null | undefined;
  // here, close means we want to STOP doing something with the transport
  close?: (transport: Transport, id: string) => Promise<void> | null | undefined;
  // disconnect/interrupt a device connection globally
  // returns falsy if the transport module can't handle this id
  disconnect: (id: string) => Promise<void> | null | undefined;

  // optional observable that allows to discover a transport
  discovery?: Discovery;
};

const modules: TransportModule[] = [];

export const registerTransportModule = (module: TransportModule): void => {
  modules.push(module);
};

export const unregisterTransportModule = (moduleId: string): void => {
  modules.splice(
    modules.findIndex(m => m.id === moduleId),
    1,
  );
};

export const unregisterAllTransportModules = (): void => {
  modules.length = 0;
};

export const discoverDevices = (
  accept: (module: TransportModule) => boolean = () => true,
): Discovery => {
  const all: Discovery[] = [];

  for (let i = 0; i < modules.length; i++) {
    const m = modules[i];

    if (m.discovery && accept(m)) {
      all.push(m.discovery);
    }
  }

  return merge(
    ...all.map(o =>
      o.pipe(
        catchError(error => {
          trace({ type: LOG_TYPE, message: "One Transport provider failed", data: { error } });
          return EMPTY;
        }),
      ),
    ),
  );
};

export type OpenOptions = {
  openTimeoutMs?: number;
  matchDeviceByName?: string;
};

/**
 * Tries to call `open` on the 1st matching registered transport implementation
 *
 * An optional timeout `timeoutMs` can be set. It is/can be used in 2 different places:
 * - A `timeoutMs` timeout is applied directly in this function: racing between the matching Transport opening and this timeout
 * - And the `timeoutMs` parameter is also passed to the `open` method of the transport module so each transport implementation
 *  can make use of that parameter and implement their timeout mechanism internally
 *
 * Why using it in 2 places ?
 * As there is no easy way to abort a Promise (returned by `open`), the Transport will continue to try connecting to the device
 * even if this function timeout was reached. But certain Transport implementations can also use this timeout to try to stop
 * the connection attempt internally.
 *
 * @param deviceId
 * @param timeoutMs Optional timeout that limits in time the open attempt of the matching registered transport.
 * @param context Optional context to be used in logs
 * @returns a Promise that resolves to a Transport instance, and rejects with a `CantOpenDevice`
 *   if no transport implementation can open the device
 */
export const open = (
  deviceId: string,
  options?: OpenOptions,
  context?: TraceContext,
): Promise<Transport> => {
  // The first registered Transport (TransportModule) accepting the given device will be returned.
  // The open is not awaited, the check on the device is done synchronously.
  // A TransportModule can check the prefix of the device id to guess if it should use USB or not on LLM for ex.
  for (let i = 0; i < modules.length; i++) {
    const m = modules[i];
    const p = m.open(deviceId, options?.openTimeoutMs, context, options?.matchDeviceByName);
    if (p) {
      trace({
        type: LOG_TYPE,
        message: `Found a matching Transport: ${m.id}`,
        context,
        data: { options },
      });

      if (!options?.openTimeoutMs) {
        return p;
      }

      let timer: ReturnType<typeof setTimeout> | null = null;

      // Throws CantOpenDevice on timeout, otherwise returns the transport.
      // Important: with Javascript Promise, when one promise finishes,
      // the other will still continue, even if its return value will be discarded.
      return Promise.race([
        p.then(transport => {
          // Necessary to stop the ongoing timeout
          if (timer) {
            clearTimeout(timer);
          }

          return transport;
        }),
        new Promise<never>((_resolve, reject) => {
          timer = setTimeout(() => {
            trace({
              type: LOG_TYPE,
              message: `Could not open registered transport ${m.id} on ${deviceId}, timed out after ${options?.openTimeoutMs}ms`,
              context,
            });

            return reject(new CantOpenDevice(`Timeout while opening device on transport ${m.id}`));
          }, options?.openTimeoutMs);
        }),
      ]);
    }
  }
  return Promise.reject(new CantOpenDevice(`Cannot find registered transport to open ${deviceId}`));
};

export const close = (
  transport: Transport,
  deviceId: string,
  context?: TraceContext,
): Promise<void> => {
  trace({ type: LOG_TYPE, message: "Trying to close transport", context });

  // Tries to call close on the registered TransportModule implementation first
  for (let i = 0; i < modules.length; i++) {
    const m = modules[i];
    const p = m.close && m.close(transport, deviceId);
    if (p) {
      trace({
        type: LOG_TYPE,
        message: `Closing transport via registered module: ${m.id}`,
        context,
      });
      return p;
    }
  }

  trace({ type: LOG_TYPE, message: `Closing transport via the transport implementation`, context });
  // Otherwise fallbacks on the transport implementation of close directly
  return transport.close();
};

export const disconnect = (deviceId: string): Promise<void> => {
  for (let i = 0; i < modules.length; i++) {
    const dis = modules[i].disconnect;
    const p = dis(deviceId);

    if (p) {
      return p;
    }
  }

  return Promise.reject(new Error(`Can't find handler to disconnect ${deviceId}`));
};
