import { BaseModel } from "./../models/base.model.js"
import type { IDevice, Service } from "../interfaces/device.interface.js"
import type {
  ActiveCallback,
  ForceMeasurement,
  ForceUnit,
  NotifyCallback,
  WriteCallback,
} from "../interfaces/callback.interface.js"
import type { DownloadPacket } from "../interfaces/download.interface.js"
import type { Commands } from "../interfaces/command.interface.js"
import { convertForce, convertForceMeasurement } from "../utils.js"

export abstract class Device extends BaseModel implements IDevice {
  /**
   * Filters to identify the device during Bluetooth scanning.
   * Used to match devices that meet specific criteria such as name, service UUIDs, etc.
   * @type {BluetoothLEScanFilter[]}
   * @public
   * @readonly
   */
  readonly filters: BluetoothLEScanFilter[]

  /**
   * Array of services provided by the device.
   * Services represent functionalities that the device supports, such as weight measurement, battery information, or custom services.
   * @type {Service[]}
   * @public
   * @readonly
   */
  readonly services: Service[]

  /**
   * Reference to the `BluetoothDevice` object representing this device.
   * This is the actual device object obtained from the Web Bluetooth API after a successful connection.
   * @type {BluetoothDevice | undefined}
   * @public
   */
  bluetooth?: BluetoothDevice

  /**
   * Object representing the set of commands available for this device.
   * These commands allow communication with the device to perform various operations such as starting measurements, retrieving data, or calibrating the device.
   * @type {Commands}
   * @public
   * @readonly
   */
  readonly commands: Commands

  /**
   * The BluetoothRemoteGATTServer interface of the Web Bluetooth API represents a GATT Server on a remote device.
   * @type {BluetoothRemoteGATTServer | undefined}
   * @private
   */
  private server: BluetoothRemoteGATTServer | undefined

  /**
   * The last message written to the device.
   * @type {string | Uint8Array | null}
   * @protected
   */
  protected writeLast: string | Uint8Array | null = null
  /**
   * Indicates whether the device is currently active.
   * @type {boolean}
   */
  protected isActive = false
  /**
   * Configuration for threshold and duration.
   */
  private activeConfig: { threshold: number; duration: number } = {
    threshold: 2.5,
    duration: 1000,
  }

  /**
   * Highest instantaneous force (peak) recorded in the session; may be negative.
   * Initialized to Number.NEGATIVE_INFINITY so the first sample sets the peak.
   * @type {number}
   * @protected
   */
  protected peak: number

  /**
   * Mean (average) force over the session, initialized to 0.
   * @type {number}
   * @protected
   */
  protected mean: number

  /**
   * Lowest instantaneous force recorded in the session; may be negative.
   * Initialized to Number.POSITIVE_INFINITY so the first sample sets the min.
   * @type {number}
   * @protected
   */
  protected min: number

  /**
   * Display unit for force measurements (output unit for notify callbacks).
   * @type {ForceUnit}
   * @protected
   */
  protected unit: ForceUnit

  /**
   * Unit of the values streamed by the device (kg for most devices, lbs for ForceBoard).
   * @type {ForceUnit}
   * @protected
   */
  protected streamUnit: ForceUnit = "kg"

  /**
   * Optional sampling rate in Hz when known or calculated from notification timestamps.
   * @type {number | undefined}
   * @protected
   */
  protected samplingRateHz?: number

  /**
   * Timestamp (ms) of the previous BLE notification for notify-interval calculation.
   * @protected
   */
  protected lastPacketTimestamp = 0

  /**
   * Count of data packets received this session (one BLE notification = one packet).
   * @protected
   */
  protected packetCount = 0

  /**
   * Notify interval in ms for the current packet (set by recordPacketReceived).
   * @protected
   */
  protected currentNotifyIntervalMs: number | undefined = undefined

  /**
   * Samples in the current packet (set by device before buildForceMeasurement).
   * @protected
   */
  protected currentSamplesPerPacket: number | undefined = undefined

  /**
   * Call at the start of each BLE notification (packet). Updates notify interval and packet count.
   * @protected
   */
  protected recordPacketReceived(): void {
    const now = Date.now()
    this.currentNotifyIntervalMs = this.lastPacketTimestamp > 0 ? now - this.lastPacketTimestamp : undefined
    this.lastPacketTimestamp = now
    this.packetCount += 1
  }

  /**
   * Reset packet tracking (call when starting a new stream).
   * @protected
   */
  protected resetPacketTracking(): void {
    this.lastPacketTimestamp = 0
    this.packetCount = 0
    this.currentNotifyIntervalMs = undefined
    this.currentSamplesPerPacket = undefined
  }

  /**
   * Start time of the current rate measurement interval.
   * @type {number}
   * @private
   */
  private rateIntervalStart = 0

  /**
   * Number of samples in the current rate measurement interval.
   * @type {number}
   * @private
   */
  private rateIntervalSamples = 0

  /**
   * Running sum of force values for the session.
   * Used to calculate mean (average) force.
   * @type {number}
   * @protected
   */
  protected sum: number

  /**
   * Number of data points received from the device.
   * Used to calculate the average mass.
   * @type {number}
   * @protected
   */
  protected dataPointCount: number

  /**
   * Array of DownloadPacket entries.
   * This array holds packets that contain data downloaded from the device.
   * @type {DownloadPacket[]}
   * @protected
   */
  protected downloadPackets: DownloadPacket[] = [] // Initialize an empty array of DownloadPacket entries

  /**
   * Represents the current tare value for calibration.
   * @type {number}
   */
  private tareCurrent = 0

  /**
   * Indicates whether the tare calibration process is active.
   * @type {boolean}
   */
  private tareActive = false

  /**
   * Timestamp when the tare calibration process started.
   * @type {number | null}
   */
  private tareStartTime: number | null = null

  /**
   * Array holding the samples collected during tare calibration.
   * @type {number[]}
   */
  private tareSamples: number[] = []

  /**
   * Duration time for the tare calibration process.
   * @type {number}
   */
  private tareDuration = 5000

  /**
   * Optional callback for handling mass/force data notifications.
   * @callback NotifyCallback
   * @param {ForceMeasurement} data - The force measurement passed to the callback.
   * @type {NotifyCallback | undefined}
   * @protected
   */
  protected notifyCallback: NotifyCallback = (data: ForceMeasurement) => console.log(data)

  /**
   * Optional callback for handling write operations.
   * @callback WriteCallback
   * @param {string} data - The data passed to the callback.
   * @type {WriteCallback | undefined}
   * @protected
   */
  protected writeCallback: WriteCallback = (data: string) => console.log(data)

  /**
   * Optional callback for handling write operations.
   * @callback ActiveCallback
   * @param {string} data - The data passed to the callback.
   * @type {ActiveCallback | undefined}
   * @protected
   */
  protected activeCallback: ActiveCallback = (data: boolean) => console.log(data)

  /**
   * Event listener for handling the 'gattserverdisconnected' event.
   * This listener delegates the event to the `onDisconnected` method.
   *
   * @private
   * @type {(event: Event) => void}
   */
  private onDisconnectedListener = (event: Event) => this.onDisconnected(event)

  /**
   * A map that stores notification event listeners keyed by characteristic UUIDs.
   * This allows for proper addition and removal of event listeners associated with each characteristic.
   *
   * @private
   * @type {Map<string, EventListener>}
   */
  private notificationListeners = new Map<string, EventListener>()

  constructor(device: Partial<IDevice>) {
    super(device)

    this.filters = device.filters || []
    this.services = device.services || []
    this.commands = device.commands || {}
    if (device.bluetooth !== undefined) {
      this.bluetooth = device.bluetooth
    }

    this.peak = Number.NEGATIVE_INFINITY
    this.mean = 0
    this.min = Number.POSITIVE_INFINITY
    this.sum = 0
    this.dataPointCount = 0
    this.unit = "kg"

    // Reset sampling rate calculation state
    this.rateIntervalStart = 0
    this.rateIntervalSamples = 0

    this.createdAt = new Date()
    this.updatedAt = new Date()
  }

  /**
   * Builds a ForceMeasurement for a single zone (e.g. left/center/right).
   * With one argument, current/peak/mean are all set to that value.
   * With three arguments, uses the given current, peak, and mean for the zone.
   * @param valueOrCurrent - Force value, or current force for this zone
   * @param peak - Optional peak for this zone (required if mean is provided)
   * @param mean - Optional mean for this zone
   * @returns ForceMeasurement (no nested distribution)
   * @protected
   */
  protected buildZoneMeasurement(valueOrCurrent: number, peak?: number, mean?: number): ForceMeasurement {
    const useFullStats = peak !== undefined && mean !== undefined
    const current = valueOrCurrent
    const zonePeak = useFullStats ? (peak === 0 && current < 0 ? current : peak) : valueOrCurrent
    const zoneMean = useFullStats ? mean : valueOrCurrent
    const zoneMin = useFullStats ? Math.min(zonePeak, current) : current
    return {
      unit: this.unit,
      timestamp: Date.now(),
      current,
      peak: zonePeak,
      mean: zoneMean,
      min: zoneMin,
    }
  }

  /**
   * Interval duration (ms) for sampling rate calculation.
   * @private
   * @readonly
   */
  private static readonly RATE_INTERVAL_MS = 1000

  /**
   * Calculates sampling rate: samples per second.
   * Uses fixed intervals to avoid sliding window edge effects.
   * @private
   */
  private updateSamplingRate(): void {
    const now = Date.now()

    if (this.rateIntervalStart === 0) {
      this.rateIntervalStart = now
    }

    this.rateIntervalSamples++

    const elapsed = now - this.rateIntervalStart
    if (elapsed >= Device.RATE_INTERVAL_MS) {
      this.samplingRateHz = Math.round((this.rateIntervalSamples / elapsed) * 1000)
      this.rateIntervalStart = now
      this.rateIntervalSamples = 0
    }
  }

  /**
   * Shared base for ForceMeasurement/DownloadPacket payload construction.
   * @private
   */
  private buildForcePayload(
    current: number,
    overrides?: {
      timestamp?: number
      sampleIndex?: number
      distribution?: { left?: ForceMeasurement; center?: ForceMeasurement; right?: ForceMeasurement }
    },
  ): ForceMeasurement {
    const timestamp = overrides?.timestamp ?? Date.now()
    const payload: ForceMeasurement = {
      unit: this.unit,
      timestamp,
      current: convertForce(current, this.streamUnit, this.unit),
      peak: convertForce(this.peak, this.streamUnit, this.unit),
      mean: convertForce(this.mean, this.streamUnit, this.unit),
      min: Number.isFinite(this.min)
        ? convertForce(this.min, this.streamUnit, this.unit)
        : convertForce(0, this.streamUnit, this.unit),
      performance: {
        packetIndex: this.packetCount,
        ...(overrides?.sampleIndex != null && { sampleIndex: overrides.sampleIndex }),
        ...(this.currentNotifyIntervalMs != null && { notifyIntervalMs: this.currentNotifyIntervalMs }),
        ...(this.currentSamplesPerPacket != null && { samplesPerPacket: this.currentSamplesPerPacket }),
        ...(this.samplingRateHz != null && { samplingRateHz: this.samplingRateHz }),
      },
    }

    const distribution = overrides?.distribution
    if (distribution && (distribution.left != null || distribution.center != null || distribution.right != null)) {
      payload.distribution = {}
      if (distribution.left != null) {
        payload.distribution.left = convertForceMeasurement(distribution.left, this.streamUnit, this.unit)
      }
      if (distribution.center != null) {
        payload.distribution.center = convertForceMeasurement(distribution.center, this.streamUnit, this.unit)
      }
      if (distribution.right != null) {
        payload.distribution.right = convertForceMeasurement(distribution.right, this.streamUnit, this.unit)
      }
    }

    return payload
  }

  /**
   * Builds a ForceMeasurement payload for notify callbacks.
   * @param current - Current force at this sample
   * @param distribution - Optional per-zone measurements (e.g. from buildZoneMeasurement)
   * @returns ForceMeasurement
   * @protected
   */
  protected buildForceMeasurement(
    current: number,
    distribution?: {
      left?: ForceMeasurement
      center?: ForceMeasurement
      right?: ForceMeasurement
    },
  ): ForceMeasurement {
    this.updateSamplingRate()
    return this.buildForcePayload(
      current,
      distribution != null ? { sampleIndex: this.dataPointCount, distribution } : { sampleIndex: this.dataPointCount },
    )
  }

  /**
   * Builds a DownloadPacket for export (CSV, JSON, XML).
   * Converts force values from streamUnit to display unit.
   * @param current - Current force at this sample (stream unit)
   * @param samples - Raw sensor/ADC values from device
   * @param options - Optional timestamp, battRaw, sampleIndex, distribution (for multi-zone)
   * @returns DownloadPacket
   * @protected
   */
  protected buildDownloadPacket(
    current: number,
    samples: number[],
    options?: {
      timestamp?: number
      battRaw?: number
      sampleIndex?: number
      distribution?: { left?: ForceMeasurement; center?: ForceMeasurement; right?: ForceMeasurement }
    },
  ): DownloadPacket {
    const overrides: {
      timestamp?: number
      sampleIndex?: number
      distribution?: { left?: ForceMeasurement; center?: ForceMeasurement; right?: ForceMeasurement }
    } = {}
    if (options?.timestamp != null) overrides.timestamp = options.timestamp
    if (options?.sampleIndex != null) overrides.sampleIndex = options.sampleIndex
    if (options?.distribution != null) overrides.distribution = options.distribution
    const packet = this.buildForcePayload(
      current,
      Object.keys(overrides).length > 0 ? overrides : undefined,
    ) as DownloadPacket
    packet.samples = samples
    if (options?.battRaw != null) packet.battRaw = options.battRaw
    return packet
  }

  /**
   * Sets the callback function to be called when the activity status changes,
   * and optionally sets the configuration for threshold and duration.
   *
   * This function allows you to specify a callback that will be invoked whenever
   * the activity status changes, indicating whether the device is currently active.
   * It also allows optionally configuring the threshold and duration used to determine activity.
   *
   * @param {ActiveCallback} callback - The callback function to be set. This function
   *                                      receives a boolean value indicating the new activity status.
   * @param {object} [options] - Optional configuration object containing the threshold and duration.
   * @param {number} [options.threshold=2.5] - The threshold value for determining activity.
   * @param {number} [options.duration=1000] - The duration (in milliseconds) to monitor the input for activity.
   * @returns {void}
   * @public
   *
   * @example
   * device.active((isActive) => {
   *   console.log(`Device is ${isActive ? 'active' : 'inactive'}`);
   * }, { threshold: 3.0, duration: 1500 });
   */
  active = (callback: ActiveCallback, options?: { threshold?: number; duration?: number }): void => {
    this.activeCallback = callback

    // Update the config values only if provided, otherwise use defaults
    this.activeConfig = {
      threshold: options?.threshold ?? this.activeConfig.threshold, // Use new threshold if provided, else use default
      duration: options?.duration ?? this.activeConfig.duration, // Use new duration if provided, else use default
    }
  }

  /**
   * Checks if a dynamic value is active based on a threshold and duration.
   *
   * This function assesses whether a given dynamic value surpasses a specified threshold
   * and remains active for a specified duration. If the activity status changes from
   * the previous state, the callback function is called with the updated activity status.
   *
   * @param {number} input - The dynamic value to check for activity status.
   * @returns {Promise<void>} A promise that resolves once the activity check is complete.
   *
   * @example
   * await device.activityCheck(5.0);
   */
  protected activityCheck = async (input: number): Promise<void> => {
    const startValue = input
    const { threshold, duration } = this.activeConfig
    // After waiting for `duration`, check if still active.
    await new Promise((resolve) => setTimeout(resolve, duration))
    const activeNow = startValue > threshold
    if (this.isActive !== activeNow) {
      this.isActive = activeNow
      this.activeCallback?.(activeNow)
    }
  }

  /**
   * Connects to a Bluetooth device.
   * @param {Function} [onSuccess] - Optional callback function to execute on successful connection. Default logs success.
   * @param {Function} [onError] - Optional callback function to execute on error. Default logs the error.
   * @public
   *
   * @example
   * device.connect(
   *   () => console.log("Connected successfully"),
   *   (error) => console.error("Connection failed:", error)
   * );
   */
  connect = async (
    onSuccess: () => void = () => console.log("Connected successfully"),
    onError: (error: Error) => void = (error) => console.error(error),
  ): Promise<void> => {
    try {
      // Request device and set up connection
      const deviceServices = this.getAllServiceUUIDs()

      const bluetooth = await this.getBluetooth()

      // Experiment: Reconnect to known devices, enable these Chrome flags:
      // - chrome://flags/#enable-experimental-web-platform-features → enables getDevices() API
      // - chrome://flags/#enable-web-bluetooth-new-permissions-backend → ensures it returns all permitted devices, not just connected ones
      // let reconnectDevice: BluetoothDevice | undefined
      // if (typeof bluetooth.getDevices === "function") {
      //   const devices: BluetoothDevice[] = await bluetooth.getDevices()
      //   if (devices.length > 0 && this.filters.length > 0) {
      //     reconnectDevice = devices.find((device) => {
      //       if (!device.name) return false
      //       const d = device
      //       return this.filters.some(
      //         (f) => (f.name && d.name === f.name) || (f.namePrefix && d.name?.startsWith(f.namePrefix)),
      //       )
      //     })
      //   }
      //   if (reconnectDevice) {
      //     this.bluetooth = reconnectDevice
      //     // It's currently impossible to call this.bluetooth.gatt.connect() here.
      //     // After restarting the Browser, it will always give: "Bluetooth Device is no longer in range."
      //   }
      // }

      this.bluetooth = await bluetooth.requestDevice({
        filters: this.filters,
        optionalServices: deviceServices,
      })

      if (!this.bluetooth.gatt) {
        throw new Error("GATT is not available on this device")
      }

      this.bluetooth.addEventListener("gattserverdisconnected", this.onDisconnectedListener)

      this.server = await this.bluetooth.gatt.connect()

      if (this.server.connected) {
        await this.onConnected(onSuccess)
      }
    } catch (error) {
      onError(error as Error)
    }
  }

  /**
   * Disconnects the device if it is currently connected.
   * - Removes all notification listeners from the device's characteristics.
   * - Removes the 'gattserverdisconnected' event listener.
   * - Attempts to gracefully disconnect the device's GATT server.
   * - Resets relevant properties to their initial states.
   * @returns {void}
   * @public
   *
   * @example
   * device.disconnect();
   */
  disconnect = (): void => {
    const isConnected = this.isConnected()
    if (isConnected) {
      this.updateTimestamp()
    }

    // Remove all notification listeners and stop notifications if possible.
    this.services.forEach((service) => {
      service.characteristics.forEach((char) => {
        if (!char.characteristic || char.id !== "rx") return

        if (isConnected) {
          // Best effort only: avoid unhandled rejections when the device already disconnected.
          void char.characteristic.stopNotifications().catch(() => undefined)
        }

        const listener = this.notificationListeners.get(char.uuid)
        if (listener) {
          char.characteristic.removeEventListener("characteristicvaluechanged", listener)
          this.notificationListeners.delete(char.uuid)
        }
      })
    })

    // Remove disconnect listener
    this.bluetooth?.removeEventListener("gattserverdisconnected", this.onDisconnectedListener)
    // Safely attempt to disconnect the device's GATT server, if available
    if (this.bluetooth?.gatt?.connected) {
      this.bluetooth.gatt.disconnect()
    }
    // Reset properties
    this.server = undefined
    this.writeLast = null
    this.isActive = false
  }

  /**
   * Converts the `downloadPackets` array into a CSV formatted string.
   * @returns {string} A CSV string representation of the `downloadPackets` data, with each packet on a new line.
   * @private
   *
   * @example
   * const csvData = device.downloadToCSV();
   * console.log(csvData);
   */
  protected downloadToCSV = (): string => {
    const packets = [...this.downloadPackets]
    if (packets.length === 0) {
      return ""
    }
    return packets
      .map((packet) => {
        const forceValues =
          packet.distribution != null
            ? [
                packet.distribution.left?.current ?? "",
                packet.distribution.center?.current ?? "",
                packet.distribution.right?.current ?? "",
              ].map((v) => (v !== "" ? String(v) : ""))
            : [packet.current.toString()]
        return [
          packet.timestamp.toString(),
          packet.current.toString(),
          packet.peak.toString(),
          packet.mean.toString(),
          packet.min.toString(),
          (packet.performance?.sampleIndex ?? "").toString(),
          (packet.battRaw ?? "").toString(),
          ...packet.samples.map(String),
          ...forceValues,
        ]
          .map((v) => v.replace(/"/g, '""'))
          .map((v) => `"${v}"`)
          .join(",")
      })
      .join("\r\n")
  }

  /**
   * Converts an array of DownloadPacket objects to a JSON string.
   * @returns {string} JSON string representation of the data.
   * @private
   *
   * @example
   * const jsonData = device.downloadToJSON();
   * console.log(jsonData);
   */
  protected downloadToJSON = (): string => {
    // Pretty print JSON with 2-space indentation
    return JSON.stringify(this.downloadPackets, null, 2)
  }

  /**
   * Converts an array of DownloadPacket objects to an XML string.
   * @returns {string}  XML string representation of the data.
   * @private
   *
   * @example
   * const xmlData = device.downloadToXML();
   * console.log(xmlData);
   */
  protected downloadToXML = (): string => {
    const xmlPackets = this.downloadPackets
      .map((packet) => {
        const samples = packet.samples.map((sample) => `<sample>${sample}</sample>`).join("")
        const distributionElements =
          packet.distribution != null
            ? [
                packet.distribution.left?.current != null ? `<left>${packet.distribution.left.current}</left>` : "",
                packet.distribution.center?.current != null
                  ? `<center>${packet.distribution.center.current}</center>`
                  : "",
                packet.distribution.right?.current != null ? `<right>${packet.distribution.right.current}</right>` : "",
              ].join("")
            : ""
        return `
          <packet>
            <timestamp>${packet.timestamp}</timestamp>
            <current>${packet.current}</current>
            <peak>${packet.peak}</peak>
            <mean>${packet.mean}</mean>
            <min>${packet.min}</min>
            ${packet.performance?.sampleIndex != null ? `<sampleIndex>${packet.performance.sampleIndex}</sampleIndex>` : ""}
            ${packet.battRaw != null ? `<battRaw>${packet.battRaw}</battRaw>` : ""}
            <samples>${samples}</samples>
            ${distributionElements}
          </packet>
        `
      })
      .join("")
    return `<DownloadPackets>${xmlPackets}</DownloadPackets>`
  }

  /**
   * Exports the data in the specified format (CSV, JSON, XML) with a filename format:
   * 'data-export-YYYY-MM-DD-HH-MM-SS.{format}'.
   *
   * @param {('csv' | 'json' | 'xml')} [format='csv'] - The format in which to download the data.
   * Defaults to 'csv'. Accepted values are 'csv', 'json', and 'xml'.
   *
   * @returns {Promise<void>} Resolves when the data has been downloaded/written
   * @public
   *
   * @example
   * await device.download('json');
   */
  download = async (format: "csv" | "json" | "xml" = "csv"): Promise<void> => {
    let content = ""

    if (format === "csv") {
      content = this.downloadToCSV()
    } else if (format === "json") {
      content = this.downloadToJSON()
    } else if (format === "xml") {
      content = this.downloadToXML()
    }

    const now = new Date()
    // YYYY-MM-DD
    const date = now.toISOString().split("T")[0]
    // HH-MM-SS
    const time = now.toTimeString().split(" ")[0].replace(/:/g, "-")

    const fileName = `data-export-${date}-${time}.${format}`

    const mimeTypes = {
      csv: "text/csv",
      json: "application/json",
      xml: "application/xml",
    }

    // Create a Blob object containing the data
    const blob = new Blob([content], { type: mimeTypes[format] })
    // Create a URL for the Blob
    const url = globalThis.URL.createObjectURL(blob)

    // Create a link element
    const link = document.createElement("a")

    // Set link attributes
    link.href = url
    link.setAttribute("download", fileName)

    // Append link to document body
    document.body.appendChild(link)

    // Programmatically click the link to trigger the download
    link.click()

    // Clean up: remove the link and revoke the URL
    document.body.removeChild(link)
    globalThis.URL.revokeObjectURL(url)
  }

  /**
   * Returns UUIDs of all services associated with the device.
   * @returns {string[]} Array of service UUIDs.
   * @protected
   *
   * @example
   * const serviceUUIDs = device.getAllServiceUUIDs();
   * console.log(serviceUUIDs);
   */
  protected getAllServiceUUIDs = (): string[] => {
    return this.services.filter((service) => service?.uuid).map((service) => service.uuid)
  }

  /**
   * Returns the Bluetooth instance available for the current environment.
   * In browsers, it returns the native Web Bluetooth API (i.e. `navigator.bluetooth`).
   * In a Node, Bun, or Deno environment, it dynamically imports the `webbluetooth` package.
   * {@link https://github.com/thegecko/webbluetooth}
   *
   * @returns {Promise<Bluetooth>} A promise that resolves to the Bluetooth instance.
   * @throws {Error} If Web Bluetooth is not available in the current environment.
   */
  protected async getBluetooth(): Promise<Bluetooth> {
    // If running in a browser with native Web Bluetooth support:
    if (typeof navigator !== "undefined" && navigator.bluetooth) {
      return navigator.bluetooth
    }
    // If none of the above conditions are met, throw an error.
    throw new Error("Bluetooth not available.")
  }

  /**
   * Handles notifications received from a characteristic.
   * @param {DataView} value - The notification event.
   *
   * @example
   * device.handleNotifications(someCharacteristic);
   */
  protected handleNotifications = (value: DataView): void => {
    if (!value) return

    this.updateTimestamp()
    // Received notification data
    console.log(value)
  }

  /**
   * Checks if a Bluetooth device is connected.
   * @returns {boolean} A boolean indicating whether the device is connected.
   * @public
   *
   * @example
   * if (device.isConnected()) {
   *   console.log('Device is connected');
   * } else {
   *   console.log('Device is not connected');
   * }
   */
  isConnected = (): boolean => {
    // Check if the device is defined and available
    if (!this.bluetooth) {
      return false
    }
    // Check if the device is connected
    return !!this.bluetooth.gatt?.connected
  }

  /**
   * Sets the callback function to be called when notifications are received.
   * @param {NotifyCallback} callback - The callback function to be set.
   * @param {ForceUnit} [unit="kg"] - Optional display unit for force values in the callback payload.
   * @returns {void}
   * @public
   *
   * @example
   * device.notify((data) => {
   *   console.log('Received notification:', data);
   * });
   * device.notify((data) => { ... }, 'lbs');
   */
  notify = (callback: NotifyCallback, unit?: ForceUnit): void => {
    this.unit = unit ?? "kg"
    this.notifyCallback = callback
  }

  /**
   * Handles the 'connected' event.
   * @param {Function} onSuccess - Callback function to execute on successful connection.
   * @public
   *
   * @example
   * device.onConnected(() => {
   *   console.log('Device connected successfully');
   * });
   */
  protected onConnected = async (onSuccess: () => void): Promise<void> => {
    this.updateTimestamp()

    if (!this.server) {
      throw new Error("GATT server is not available")
    }
    // Connect to GATT server and set up characteristics
    const services: BluetoothRemoteGATTService[] = await this.server.getPrimaryServices()

    if (!services || services.length === 0) {
      throw new Error("No services found")
    }

    for (const service of services) {
      const matchingService = this.services.find(
        (boardService) => boardService.uuid.toLowerCase() === service.uuid.toLowerCase(),
      )

      if (matchingService) {
        // Android bug: Add a small delay before getting characteristics
        await new Promise((resolve) => setTimeout(resolve, 100))

        const characteristics = await service.getCharacteristics()

        for (const characteristic of matchingService.characteristics) {
          const matchingCharacteristic = characteristics.find(
            (char) => char.uuid.toLowerCase() === characteristic.uuid.toLowerCase(),
          )

          if (matchingCharacteristic) {
            // Find the corresponding characteristic descriptor in the service's characteristics array
            const descriptor = matchingService.characteristics.find(
              (char) => char.uuid.toLowerCase() === matchingCharacteristic.uuid.toLowerCase(),
            )
            if (descriptor) {
              // Assign the actual Bluetooth characteristic object to the descriptor so it can be used later
              descriptor.characteristic = matchingCharacteristic
              // Look for the "rx" characteristic id that accepts notifications
              if (descriptor.id === "rx") {
                // Start receiving notifications for changes on this characteristic
                matchingCharacteristic.startNotifications()
                // Triggered when the characteristic's value changes
                const listener = (event: Event) => {
                  // Cast the event's target to a BluetoothRemoteGATTCharacteristic to access its properties
                  const target = event.target as BluetoothRemoteGATTCharacteristic
                  if (target && target.value) {
                    // Delegate the data to handleNotifications method
                    this.handleNotifications(target.value)
                  }
                }
                // Attach the event listener to listen for changes in the characteristic's value
                matchingCharacteristic.addEventListener("characteristicvaluechanged", listener)
                // Store the listener so it can be referenced (for later removal)
                this.notificationListeners.set(descriptor.uuid, listener)
              }
            }
          } else {
            throw new Error(`Characteristic ${characteristic.uuid} not found in service ${service.uuid}`)
          }
        }
      }
    }
    // Call the onSuccess callback after successful connection and setup
    onSuccess()
  }

  /**
   * Handles the 'disconnected' event.
   * @param {Event} event - The 'disconnected' event.
   * @public
   *
   * @example
   * device.onDisconnected(event);
   */
  protected onDisconnected = (event: Event): void => {
    console.warn(`Device ${(event.target as BluetoothDevice).name} is disconnected.`)
    this.disconnect()
  }

  /**
   * Reads the value of the specified characteristic from the device.
   * @param {string} serviceId - The service ID where the characteristic belongs.
   * @param {string} characteristicId - The characteristic ID to read from.
   * @param {number} [duration=0] - The duration to wait before resolving the promise, in milliseconds.
   * @returns {Promise<string | undefined>} A promise that resolves when the read operation is completed.
   * @public
   *
   * @example
   * const value = await device.read('battery', 'level', 1000);
   * console.log('Battery level:', value);
   */
  read = async (serviceId: string, characteristicId: string, duration = 0): Promise<string | undefined> => {
    if (!this.isConnected()) {
      return undefined
    }
    // Get the characteristic from the service
    const characteristic = this.services
      .find((service) => service.id === serviceId)
      ?.characteristics.find((char) => char.id === characteristicId)?.characteristic

    if (!characteristic) {
      throw new Error(`Characteristic "${characteristicId}" not found in service "${serviceId}"`)
    }
    this.updateTimestamp()
    // Decode the value based on characteristicId and serviceId
    let decodedValue: string
    const decoder = new TextDecoder("utf-8")
    // Read the value from the characteristic
    const value = await characteristic.readValue()

    if (
      (serviceId === "battery" || serviceId === "humidity" || serviceId === "temperature") &&
      characteristicId === "level"
    ) {
      // This is battery-specific; return the first byte as the level
      decodedValue = value.getUint8(0).toString()
    } else {
      // Otherwise use a UTF-8 decoder
      decodedValue = decoder.decode(value)
    }
    // Wait for the specified duration before returning the result
    if (duration > 0) {
      await new Promise((resolve) => setTimeout(resolve, duration))
    }

    return decodedValue
  }

  /**
   * Initiates the tare calibration process.
   * @param {number} duration - The duration time for tare calibration.
   * @returns {boolean} A boolean indicating whether the tare calibration was successful.
   * @public
   *
   * @example
   * const success = device.tare(5000);
   * if (success) {
   *   console.log('Tare calibration started');
   * } else {
   *   console.log('Tare calibration failed to start');
   * }
   */
  tare(duration = 5000): boolean {
    if (this.tareActive) return false
    this.updateTimestamp()
    this.tareActive = true
    this.tareDuration = duration
    this.tareSamples = []
    this.tareStartTime = Date.now()
    return true
  }

  /**
   * Clears the software tare offset and related state.
   * Used by devices that implement hardware tare so applyTare does not double-adjust.
   * @protected
   */
  protected clearTareOffset(): void {
    this.tareCurrent = 0
    this.tareActive = false
    this.tareStartTime = null
    this.tareSamples = []
  }

  /**
   * Apply tare calibration to the provided sample.
   * @param {number} sample - The sample to calibrate.
   * @returns {number} The calibrated tare value.
   * @protected
   *
   * @example
   * const calibratedSample = device.applyTare(rawSample);
   * console.log('Calibrated sample:', calibratedSample);
   */
  protected applyTare(sample: number): number {
    if (this.tareActive && this.tareStartTime) {
      // Add current sample to the tare samples array
      this.tareSamples.push(sample)

      // Check if the tare calibration duration has passed
      if (Date.now() - this.tareStartTime >= this.tareDuration) {
        // Calculate the average of the tare samples
        const total = this.tareSamples.reduce((acc, sample) => acc + sample, 0)
        this.tareCurrent = total / this.tareSamples.length

        // Reset the tare calibration process
        this.tareActive = false
        this.tareStartTime = null
        this.tareSamples = []
      }
    }
    // Return the current tare-adjusted value
    return this.tareCurrent
  }

  /**
   * Updates the timestamp of the last device interaction.
   * This method sets the updatedAt property to the current date and time.
   * @protected
   *
   * @example
   * device.updateTimestamp();
   * console.log('Last updated:', device.updatedAt);
   */
  protected updateTimestamp = (): void => {
    this.updatedAt = new Date()
  }

  /**
   * Writes a message to the specified characteristic of a Bluetooth device and optionally provides a callback to handle responses.
   * @param {string} serviceId - The service UUID of the Bluetooth device containing the target characteristic.
   * @param {string} characteristicId - The characteristic UUID where the message will be written.
   * @param {string | Uint8Array | undefined} message - The message to be written to the characteristic. It can be a string or a Uint8Array.
   * @param {number} [duration=0] - Optional. The time in milliseconds to wait before resolving the promise. Defaults to 0 for immediate resolution.
   * @param {WriteCallback} [callback=writeCallback] - Optional. A custom callback to handle the response after the write operation is successful.
   * @returns {Promise<void>} A promise that resolves once the write operation is complete.
   * @public
   * @throws {Error} Throws an error if the characteristic is undefined.
   *
   * @example
   * // Example usage of the write function with a custom callback
   * await Progressor.write("progressor", "tx", ProgressorCommands.GET_BATTERY_VOLTAGE, 250, (data) => {
   *   console.log(`Battery voltage: ${data}`);
   * });
   */
  write = async (
    serviceId: string,
    characteristicId: string,
    message: string | Uint8Array | undefined,
    duration = 0,
    callback: WriteCallback = this.writeCallback,
  ): Promise<void> => {
    // Check if not connected or no message is provided
    if (!this.isConnected() || message === undefined) {
      return Promise.resolve()
    }
    // Get the characteristic from the service
    const characteristic = this.services
      .find((service) => service.id === serviceId)
      ?.characteristics.find((char) => char.id === characteristicId)?.characteristic

    if (!characteristic) {
      throw new Error(`Characteristic "${characteristicId}" not found in service "${serviceId}"`)
    }
    this.updateTimestamp()
    // Convert the message to Uint8Array if it's a string
    const valueToWrite =
      typeof message === "string" ? new Uint8Array(new TextEncoder().encode(message)) : new Uint8Array(message)
    // Write the value to the characteristic
    await characteristic.writeValue(valueToWrite)
    // Update the last written message
    this.writeLast = message
    // Assign the provided callback to `writeCallback`
    this.writeCallback = callback
    // If a duration is specified, resolve the promise after the duration
    if (duration > 0) {
      await new Promise<void>((resolve) => setTimeout(resolve, duration))
    }
  }
}
