import { Mutex } from "async-mutex";
import { ConnectionInfo, NiimbotAbstractClient } from ".";
import { ConnectResult, NiimbotPacket, ResponseCommandId } from "../packets";
import { Utils } from "../utils";
import {
  ConnectEvent,
  DisconnectEvent,
  PacketReceivedEvent,
  RawPacketReceivedEvent,
  RawPacketSentEvent,
} from "./events";

class BleConfiguration {
  public static readonly SERVICE: string = "e7810a71-73ae-499d-8c15-faa9aef0c3f2";
  public static readonly CHARACTERISTIC: string = "bef8d6c9-9c21-4c9e-b632-bd58c1009f9f";
  public static readonly FILTER: BluetoothLEScanFilter[] = [
    { namePrefix: "D" },
    { namePrefix: "B" },
    { services: [BleConfiguration.SERVICE] },
  ];
}

export class NiimbotBluetoothClient extends NiimbotAbstractClient {
  private gattServer?: BluetoothRemoteGATTServer = undefined;
  private channel?: BluetoothRemoteGATTCharacteristic = undefined;
  private mutex: Mutex = new Mutex();

  public async connect(): Promise<ConnectionInfo> {
    this.disconnect();

    const options: RequestDeviceOptions = {
      filters: BleConfiguration.FILTER,
    };
    const device: BluetoothDevice = await navigator.bluetooth.requestDevice(options);

    if (device.gatt === undefined) {
      throw new Error("Device has no Bluetooth Generic Attribute Profile");
    }

    const disconnectListener = async () => {
      this.gattServer = undefined;
      this.channel = undefined;
      this.info = {};
      this.stopHeartbeat();
      this.dispatchTypedEvent("disconnect", new DisconnectEvent());
      device.removeEventListener("gattserverdisconnected", disconnectListener);
    };

    device.addEventListener("gattserverdisconnected", disconnectListener);

    const gattServer: BluetoothRemoteGATTServer = await device.gatt.connect();

    const service: BluetoothRemoteGATTService = await gattServer.getPrimaryService(BleConfiguration.SERVICE);

    const channel: BluetoothRemoteGATTCharacteristic = await service.getCharacteristic(BleConfiguration.CHARACTERISTIC);

    channel.addEventListener("characteristicvaluechanged", async (event: Event) => {
      const target = event.target as BluetoothRemoteGATTCharacteristic;
      const packet = NiimbotPacket.fromBytes(target.value!);

      this.dispatchTypedEvent("rawpacketreceived", new RawPacketReceivedEvent(target.value!));
      this.dispatchTypedEvent("packetreceived", new PacketReceivedEvent(packet));

      if (!(packet.command in ResponseCommandId)) {
        console.warn(`Unknown response command: 0x${Utils.numberToHex(packet.command)}`);
      }
    });

    await channel.startNotifications();

    this.gattServer = gattServer;
    this.channel = channel;

    try {
      await this.initialNegotiate();
      await this.fetchPrinterInfo();
    } catch (e) {
      console.error(e);
    }

    const result: ConnectionInfo = {
      deviceName: device.name,
      result: this.info.connectResult ?? ConnectResult.FirmwareErrors
    };

    this.dispatchTypedEvent("connect", new ConnectEvent(result));

    return result;
  }

  public isConnected(): boolean {
    return this.gattServer !== undefined && this.channel !== undefined;
  }

  public async disconnect() {
    this.stopHeartbeat();
    this.gattServer?.disconnect();
    this.gattServer = undefined;
    this.channel = undefined;
    this.info = {};
  }

  /**
   * Send packet and wait for response.
   * If packet.responsePacketCommandId is defined, it will wait for packet with this command id.
   */
  public async sendPacketWaitResponse(packet: NiimbotPacket, timeoutMs?: number): Promise<NiimbotPacket> {
    return this.mutex.runExclusive(async () => {
      await this.sendPacket(packet, true);

      if (packet.oneWay) {
        return new NiimbotPacket(-1, []); // or undefined is better?
      }

      // what if response received at this point?

      return new Promise((resolve) => {
        let timeout: NodeJS.Timeout | undefined = undefined;

        const listener = (evt: PacketReceivedEvent) => {
          if (
            packet.validResponseIds.length === 0 ||
            packet.validResponseIds.includes(evt.packet.command)
          ) {
            clearTimeout(timeout);
            this.removeEventListener("packetreceived", listener);
            resolve(evt.packet);
          }
        };

        timeout = setTimeout(() => {
          this.removeEventListener("packetreceived", listener);
          throw new Error(
            `Timeout waiting response (waited for ${Utils.bufToHex(packet.validResponseIds, ", ")})`
          );
        }, timeoutMs ?? 1000);

        this.addEventListener("packetreceived", listener);
      });
    });
  }

  public async sendRaw(data: Uint8Array, force?: boolean) {
    const send = async () => {
      if (this.channel === undefined) {
        throw new Error("Channel is closed");
      }
      await this.channel.writeValueWithoutResponse(data);
      this.dispatchTypedEvent("rawpacketsent", new RawPacketSentEvent(data));
    };

    if (force) {
      return send();
    } else {
      await this.mutex.runExclusive(send);
    }
  }
}