import Transport from "@ledgerhq/hw-transport";
import { LocalTracer, TraceContext } from "@ledgerhq/logs";
import WS from "isomorphic-ws";
import { Observable, Subject } from "rxjs";
import {
  WebsocketConnectionError,
  DeviceSocketFail,
  DisconnectedDeviceDuringOperation,
  TransportStatusError,
  UserRefusedAllowManager,
  ManagerDeviceLockedError,
  StatusCodes,
} from "@ledgerhq/errors";
import { cancelDeviceAction } from "../hw/deviceAccess";
import { getEnv } from "@ledgerhq/live-env";
import type { SocketEvent } from "@ledgerhq/types-live";
import { sha3_256 } from "@noble/hashes/sha3";
import { DeviceId } from "@ledgerhq/client-ids/ids";

const LOG_TYPE = "socket";
const ALLOW_SECURE_CHANNEL_DELAY = 500;

const warningsSubject = new Subject<string>();
export const warnings: Observable<string> = warningsSubject.asObservable();

/**
 * use Ledger WebSocket API to exchange data with the device
 * Returns an Observable of the final result
 */
export function createDeviceSocket(
  transport: Transport,
  {
    url,
    unresponsiveExpectedDuringBulk,
    context,
  }: {
    url: string;
    unresponsiveExpectedDuringBulk?: boolean;
    context?: TraceContext;
  },
): Observable<SocketEvent> {
  const tracer = new LocalTracer(LOG_TYPE, {
    ...context,
    function: "createDeviceSocket",
    transportContext: transport.getTraceContext(),
  });
  tracer.trace("Starting web socket communication", { url, unresponsiveExpectedDuringBulk });

  return new Observable(o => {
    let deviceError: Error | null = null; // error originating from device (connection/response/rejection...)
    let unsubscribed = false; // subscriber wants to stops everything
    let bulkSubscription: null | { unsubscribe: () => void } = null; // subscription to the bulk observable
    let correctlyFinished = false; // the socket logic reach a normal termination
    let inBulkMode = false; // we have an array of apdus to exchange, without the need of more WS messages.
    let allowSecureChannelTimeout: NodeJS.Timeout | null = null; // allows to delay/cancel the user confirmation event
    let deviceIdCaptured = false; // track if we've already captured the device id
    const ws = new WS(url);

    ws.onopen = () => {
      tracer.trace("Socket opened", { url });
      o.next({
        type: "opened",
      });
    };

    ws.onerror = e => {
      tracer.trace("Socket error", { e });
      if (inBulkMode) return; // in bulk case, we ignore any network events because we just need to unroll APDUs with the device

      o.error(
        new WebsocketConnectionError(e.message, {
          url,
        }),
      );
    };

    ws.onclose = () => {
      tracer.trace("Socket closed", { url, inBulkMode, correctlyFinished });
      if (inBulkMode) return; // in bulk case, we ignore any network events because we just need to unroll APDUs with the device

      if (correctlyFinished) {
        o.complete();
      } else {
        tracer.trace(`Socket closed, not correctly finished, device error: ${deviceError}`, {
          deviceError,
          inBulkMode,
        });
        // Nb Give priority to the cached error from a device connection, since websocket closes give
        // us no information on what caused the close.
        o.error(deviceError || new WebsocketConnectionError("closed"));
      }
    };

    ws.onmessage = async (e: any) => {
      if (unsubscribed) return;
      deviceError = null; // If we continue to receive messages, the cached error is obsolete.

      try {
        const input = JSON.parse(e.data);
        tracer.trace("Socket in", { type: input.query });

        switch (input.query) {
          case "exchange": {
            tracer.trace("Socket in: exchange", {
              nonce: input?.nonce,
              uuid: input?.uuid,
              session: input?.session,
            });
            // A single ping-pong apdu with the HSM
            const { nonce } = input;
            const apdu = Buffer.from(input.data, "hex");
            o.next({
              type: "exchange-before",
              nonce,
              apdu,
            });

            // Detect the specific exchange that triggers the allow secure channel request.
            let pendingUserAllowSecureChannel = false;

            if (apdu.slice(0, 2).toString("hex") === "e051") {
              pendingUserAllowSecureChannel = true;
              allowSecureChannelTimeout = setTimeout(() => {
                if (unsubscribed) return;
                o.next({
                  type: "device-permission-requested",
                });
                // Nb Permission is only requested once per reboot, delaying the event
                // prevents the UI from flashing the rendering for allowing.
              }, ALLOW_SECURE_CHANNEL_DELAY);
            }

            // Detect GetCertificate APDU to extract device ID
            const shouldCaptureDeviceId =
              !deviceIdCaptured && apdu.slice(0, 2).toString("hex") === "e052";

            const r = await transport.exchange(apdu);

            if (allowSecureChannelTimeout) {
              clearTimeout(allowSecureChannelTimeout);
              allowSecureChannelTimeout = null;
            }

            if (unsubscribed) return;
            const status = r.readUInt16BE(r.length - 2);

            let response;
            switch (status) {
              case StatusCodes.OK:
                response = "success";
                break;

              case StatusCodes.LOCKED_DEVICE:
                o.error(new TransportStatusError(status));
                return;

              case StatusCodes.USER_REFUSED_ON_DEVICE:
              case StatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED:
                if (pendingUserAllowSecureChannel) {
                  o.error(new UserRefusedAllowManager());
                  return;
                }
              // Fallthrough is literally what we want when not allowing a secure channel.
              // eslint-disable-next-line no-fallthrough
              default:
                // Nb Other errors may not throw directly, we will instead keep track of
                // them and throw them if the next event from the ws connection is a disconnect
                // otherwise, we clear them.
                response = "error";
                deviceError = new TransportStatusError(status);
                break;
            }

            if (pendingUserAllowSecureChannel) {
              o.next({
                type: "device-permission-granted",
              });
            }

            // Extract device ID from GetCertificate response
            if (shouldCaptureDeviceId && status === StatusCodes.OK) {
              try {
                const responseData = r.slice(0, r.length - 2);
                const headerLength = responseData[0];
                const publicKeyLengthOffset = 1 + headerLength;
                const publicKeyLength = responseData[publicKeyLengthOffset];
                const publicKeyOffset = publicKeyLengthOffset + 1;
                const publicKey = responseData.slice(
                  publicKeyOffset,
                  publicKeyOffset + publicKeyLength,
                );

                // Compute device ID as SHA3-256 hash of the public key
                const deviceIdHash = sha3_256(publicKey);
                const deviceIdString = Buffer.from(deviceIdHash).toString("hex");
                const deviceId = DeviceId.fromString(deviceIdString);
                deviceIdCaptured = true;

                o.next({
                  type: "device-id",
                  deviceId,
                });
              } catch (err) {
                tracer.trace("Failed to extract device ID from GetCertificate response", { err });
              }
            }

            const data = r.slice(0, r.length - 2);
            o.next({
              type: "exchange",
              nonce,
              apdu,
              status,
              data,
            });

            const msg = {
              nonce,
              response,
              data: data.toString("hex"),
            };

            tracer.trace("Socket out", { response: msg.response });
            const strMsg = JSON.stringify(msg);
            ws.send(strMsg);
            break;
          }

          case "bulk": {
            tracer.trace("Socket in: bulk", {
              apduCount: input?.data?.length,
              nonce: input?.nonce,
              uuid: input?.uuid,
              session: input?.session,
            });

            // In bulk, a lot of APDUs will be unrolled, and the web socket is no longer needed
            inBulkMode = true;
            ws.close();

            const { data } = input;

            const notify = index =>
              o.next({
                type: "bulk-progress",
                progress: index / data.length,
                index,
                total: data.length,
              });

            // we use a promise to wait for the bulk to finish
            await new Promise((resolve, reject) => {
              let i = 0;
              notify(0);
              // if the bulk payload includes trailing empty strings we end up
              // sending empty data to the device and causing a disconnect.
              const cleanData = data
                .map(d => (d !== "" ? Buffer.from(d, "hex") : null))
                .filter(Boolean);

              // we also use a subscription to be able to cancel the bulk if the user unsubscribes
              bulkSubscription = transport.exchangeBulk(cleanData, {
                next: () => {
                  notify(++i);
                },
                error: e => reject(e),
                complete: () => resolve(null),
              });
            });
            if (unsubscribed) {
              tracer.trace("unsubscribed before end of bulk");
              return;
            }

            correctlyFinished = true;
            o.complete();
            break;
          }

          case "success": {
            // A final success event with some data payload
            const payload = input.result || input.data;

            tracer.trace("Socket in: success", {
              payload,
              inBulkMode,
              nonce: input?.nonce,
              uuid: input?.uuid,
              session: input?.session,
            });

            // Once entered in bulk mode, we close the websocket and don't react to any other messages
            if (inBulkMode) break;

            if (payload) {
              o.next({
                type: "result",
                payload,
              });
            }
            correctlyFinished = true;
            o.complete();
            break;
          }

          case "error": {
            tracer.trace("Socket in: error", {
              errorData: input?.data,
              inBulkMode,
              nonce: input?.nonce,
              uuid: input?.uuid,
              session: input?.session,
            });

            // Once entered in bulk mode, we close the websocket and don't react to any other messages
            if (inBulkMode) break;

            // An error from HSM
            throw new DeviceSocketFail(input.data, {
              url,
            });
          }

          case "warning": {
            tracer.trace("Socket in: warning", {
              warningData: input?.data,
              inBulkMode,
              nonce: input?.nonce,
              uuid: input?.uuid,
              session: input?.session,
            });

            // Once entered in bulk mode, we close the websocket and don't react to any other messages
            if (inBulkMode) break;

            // A warning from HSM
            o.next({
              type: "warning",
              message: input.data,
            });
            warningsSubject.next(input.data);
            break;
          }

          default:
            tracer.trace("Socket in: cannot handle msg of type", { input });
        }
      } catch (err: any) {
        deviceError = err;
        tracer.trace("Socket message error", { err });
        o.error(err);
      }
    };

    const onDisconnect = e => {
      transport.off("disconnect", onDisconnect);

      tracer.trace(
        `Socket disconnected. Emitting a DisconnectedDeviceDuringOperation. Error: ${e}`,
        { error: e },
      );
      const error = new DisconnectedDeviceDuringOperation((e && e.message) || "");
      deviceError = error;
      o.error(error);
    };

    const onUnresponsiveDevice = () => {
      tracer.trace(`Device unresponsive`, {
        inBulkMode,
        unresponsiveExpectedDuringBulk,
        allowSecureChannelTimeout,
        unsubscribed,
      });

      // Nb Don't consider the device as locked if we are in a blocking apdu exchange, ie
      // one that requires user confirmation to complete.
      if (inBulkMode && unresponsiveExpectedDuringBulk) return;
      if (allowSecureChannelTimeout) return;

      if (unsubscribed) return;
      o.error(new ManagerDeviceLockedError());
    };

    transport.on("disconnect", onDisconnect);
    transport.on("unresponsive", onUnresponsiveDevice);
    return () => {
      unsubscribed = true;
      if (bulkSubscription) {
        bulkSubscription.unsubscribe();
      }
      transport.off("disconnect", onDisconnect);
      transport.off("unresponsive", onUnresponsiveDevice);

      if (!correctlyFinished && !deviceError && inBulkMode) {
        // if it was not normally ended, we might have to flush it
        if (getEnv("DEVICE_CANCEL_APDU_FLUSH_MECHANISM")) {
          cancelDeviceAction(transport);
        }
      }

      if (ws.readyState === 1) {
        // connection still active. we close it from client (e.g. user unsubscribe)
        ws.close();
      }
    };
  });
}
