Source: core/MIDIConnection.js

import { EventEmitter } from "./EventEmitter.js"
import { MIDIAccessError, MIDIConnectionError, MIDIDeviceError, MIDIValidationError } from "./errors.js"

/**
 * Connection event constants for device connection/disconnection events.
 * Use these constants when listening for MIDI device state changes.
 *
 * @namespace CONNECTION_EVENTS
 * @property {string} DEVICE_CHANGE="device-change" - Emitted when any MIDI device state changes (connect/disconnect)
 * @property {string} IN_DEV_CONNECTED="in-dev-connected" - Emitted when an input device is connected
 * @property {string} IN_DEV_DISCONNECTED="in-dev-disconnected" - Emitted when an input device is disconnected
 * @property {string} OUT_DEV_CONNECTED="out-dev-connected" - Emitted when an output device is connected
 * @property {string} OUT_DEV_DISCONNECTED="out-dev-disconnected" - Emitted when an output device is disconnected
 *
 * @example
 * connection.on(CONNECTION_EVENTS.DEVICE_CHANGE, ({ port, state, type, device }) => {
 *   console.log(`${device.name} ${state}`);
 * });
 *
 * connection.on(CONNECTION_EVENTS.OUT_DEV_CONNECTED, ({ device }) => {
 *   console.log(`Output connected: ${device.name}`);
 * });
 *
 * @example
 * // Using the short alias (CONN)
 * connection.on(CONN.DEVICE_CHANGE, ({ device, state }) => {
 *   console.log(`${device.name} ${state}`);
 * });
 */
export const CONNECTION_EVENTS = {
  DEVICE_CHANGE: "device-change",
  IN_DEV_CONNECTED: "in-dev-connected",
  IN_DEV_DISCONNECTED: "in-dev-disconnected",
  OUT_DEV_CONNECTED: "out-dev-connected",
  OUT_DEV_DISCONNECTED: "out-dev-disconnected",
}

/**
 * Low-level Web MIDI API connection handler. Manages:
 * - MIDI device access/request
 * - Device enumeration (inputs/outputs)
 * - Device connection/disconnection
 * - Hotplug detection and events
 * - Raw MIDI message sending/receiving
 * - SysEx message handling
 * - Connection state tracking
 *
 * NOTE: Typically used internally by MIDIController. Most applications
 * should use MIDIController instead for higher-level APIs.
 * @extends EventEmitter
 *
 * @example
 * // Basic usage
 * const connection = new MIDIConnection({ sysex: true });
 * await connection.requestAccess();
 * await connection.connect("My MIDI Device");
 * connection.send([0x90, 60, 100]); // Note on
 *
 * @example
 * // Listening for device changes
 * connection.on(CONNECTION_EVENTS.DEVICE_CHANGE, ({ device, state }) => {
 *   console.log(`${device.name} is now ${state}`);
 * });
 */
export class MIDIConnection extends EventEmitter {
  /**
   * @param {Object} options
   * @param {boolean} [options.sysex=false] - Request SysEx access
   */
  constructor(options = {}) {
    super()
    this.options = {
      sysex: false,
      ...options,
    }

    this.midiAccess = null
    this.output = null
    this.input = null
  }

  /**
   * Request MIDI access from the browser. Sets up hotplug detection and
   * emits events when devices connect/disconnect.
   *
   * @returns {Promise<void>}
   * @throws {MIDIAccessError} If MIDI is not supported or access is denied
   *
   * @emits CONNECTION_EVENTS.DEVICE_CHANGE - When any MIDI device state changes
   * @emits CONNECTION_EVENTS.IN_DEV_CONNECTED - When an input device connects
   * @emits CONNECTION_EVENTS.IN_DEV_DISCONNECTED - When an input device disconnects
   * @emits CONNECTION_EVENTS.OUT_DEV_CONNECTED - When an output device connects
   * @emits CONNECTION_EVENTS.OUT_DEV_DISCONNECTED - When an output device disconnects
   *
   * @example
   * // Request basic MIDI access
   * await connection.requestAccess();
   *
   * @example
   * // Request with SysEx support
   * const connection = new MIDIConnection({ sysex: true });
   * await connection.requestAccess();
   */
  async requestAccess() {
    if (!navigator.requestMIDIAccess) {
      throw new MIDIAccessError("Web MIDI API is not supported in this browser", "unsupported")
    }

    try {
      this.midiAccess = await navigator.requestMIDIAccess({
        sysex: this.options.sysex,
      })

      this.midiAccess.onstatechange = (event) => {
        const port = event.port
        const state = event.port.state

        this.emit(CONNECTION_EVENTS.DEVICE_CHANGE, {
          port,
          state,
          type: port.type,
          device: {
            id: port.id,
            name: port.name,
            manufacturer: port.manufacturer || "Unknown",
          },
        })

        if (state === "disconnected") {
          if (port.type === "input") {
            this.emit(CONNECTION_EVENTS.IN_DEV_DISCONNECTED, { device: port })
            if (this.input && this.input.id === port.id) {
              this.input = null
            }
          } else if (port.type === "output") {
            this.emit(CONNECTION_EVENTS.OUT_DEV_DISCONNECTED, { device: port })
            if (this.output && this.output.id === port.id) {
              this.output = null
            }
          }
        } else if (state === "connected") {
          if (port.type === "input") {
            this.emit(CONNECTION_EVENTS.IN_DEV_CONNECTED, { device: port })
          } else if (port.type === "output") {
            this.emit(CONNECTION_EVENTS.OUT_DEV_CONNECTED, { device: port })
          }
        }
      }
    } catch (err) {
      if (err.name === "SecurityError") {
        throw new MIDIAccessError("MIDI access denied. SysEx requires user permission.", "denied")
      }
      throw new MIDIAccessError(`Failed to get MIDI access: ${err.message}`, "failed")
    }
  }

  /**
   * Connect to a MIDI output device. Can connect by index, name, or ID.
   *
   * @param {string|number} [device] - Device name, ID, or index (defaults to first available)
   * @returns {Promise<void>}
   * @throws {MIDIConnectionError} If MIDI access not initialized
   * @throws {MIDIDeviceError} If device not found or index out of range
   *
   * @example
   * // Connect to first available device
   * await connection.connect();
   *
   * @example
   * // Connect by index
   * await connection.connect(0);
   *
   * @example
   * // Connect by name
   * await connection.connect("My MIDI Keyboard");
   *
   * @example
   * // Connect by device ID
   * await connection.connect("input-12345");
   */
  async connect(device) {
    if (!this.midiAccess) {
      throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
    }

    const outputs = Array.from(this.midiAccess.outputs.values())

    if (outputs.length === 0) {
      throw new MIDIDeviceError("No MIDI output devices available", "output")
    }

    if (device === undefined) {
      this.output = outputs[0]
      return
    }

    if (typeof device === "number") {
      if (device < 0 || device >= outputs.length) {
        throw new MIDIDeviceError(`Output index ${device} out of range (0-${outputs.length - 1})`, "output", device)
      }
      this.output = outputs[device]
      return
    }

    this.output = outputs.find((output) => output.name === device || output.id === device)

    if (!this.output) {
      const availableNames = outputs.map((o) => o.name).join(", ")
      throw new MIDIDeviceError(`MIDI output "${device}" not found. Available: ${availableNames}`, "output", device)
    }
  }

  /**
   * Connect to a MIDI input device for receiving messages
   * @param {string|number} [device] - Device name, ID, or index (defaults to first available)
   * @param {Function} onMessage - Callback for incoming MIDI messages
   * @returns {Promise<void>}
   * @throws {MIDIConnectionError} If MIDI access not initialized
   * @throws {MIDIValidationError} If onMessage is not a function
   * @throws {MIDIDeviceError} If device not found or index out of range
   */
  async connectInput(device, onMessage) {
    if (!this.midiAccess) {
      throw new MIDIConnectionError("MIDI access not initialized. Call requestAccess() first.")
    }

    if (typeof onMessage !== "function") {
      throw new MIDIValidationError("onMessage callback must be a function", "callback")
    }

    const inputs = Array.from(this.midiAccess.inputs.values())

    if (inputs.length === 0) {
      throw new MIDIDeviceError("No MIDI input devices available", "input")
    }

    if (this.input) {
      this.input.onmidimessage = null
    }

    if (device === undefined) {
      this.input = inputs[0]
    } else if (typeof device === "number") {
      if (device < 0 || device >= inputs.length) {
        throw new MIDIDeviceError(`Input index ${device} out of range (0-${inputs.length - 1})`, "input", device)
      }
      this.input = inputs[device]
    } else {
      this.input = inputs.find((input) => input.name === device || input.id === device)

      if (!this.input) {
        const availableNames = inputs.map((i) => i.name).join(", ")
        throw new MIDIDeviceError(`MIDI input "${device}" not found. Available: ${availableNames}`, "input", device)
      }
    }

    this.input.onmidimessage = (event) => {
      onMessage(event)
    }
  }

  /**
   * Disconnect from current output
   */
  disconnectOutput() {
    this.output = null
  }

  /**
   * Disconnect from current input
   */
  disconnectInput() {
    if (this.input) {
      this.input.onmidimessage = null
      this.input = null
    }
  }

  /**
   * Disconnect from both output and input
   */
  disconnect() {
    this.disconnectOutput()
    this.disconnectInput()
  }

  /**
   * Check if currently connected to an output
   * @returns {boolean}
   */
  isConnected() {
    return this.output !== null
  }

  /**
   * Get current output device info
   * @returns {Object|null}
   */
  getCurrentOutput() {
    if (!this.output) return null

    return {
      id: this.output.id,
      name: this.output.name,
      manufacturer: this.output.manufacturer || "Unknown",
    }
  }

  /**
   * Get current input device info
   * @returns {Object|null}
   */
  getCurrentInput() {
    if (!this.input) return null

    return {
      id: this.input.id,
      name: this.input.name,
      manufacturer: this.input.manufacturer || "Unknown",
    }
  }

  /**
   * Get all available MIDI outputs
   * @returns {Array<{id: string, name: string, manufacturer: string}>}
   */
  getOutputs() {
    if (!this.midiAccess) return []

    const outputs = []
    this.midiAccess.outputs.forEach((output) => {
      if (output.state === "connected") {
        outputs.push({
          id: output.id,
          name: output.name,
          manufacturer: output.manufacturer || "Unknown",
        })
      }
    })

    return outputs
  }

  /**
   * Get all available MIDI inputs
   * @returns {Array<{id: string, name: string, manufacturer: string}>}
   */
  getInputs() {
    if (!this.midiAccess) return []

    const inputs = []
    this.midiAccess.inputs.forEach((input) => {
      if (input.state === "connected") {
        inputs.push({
          id: input.id,
          name: input.name,
          manufacturer: input.manufacturer || "Unknown",
        })
      }
    })

    return inputs
  }

  /**
   * Send a MIDI message to the connected output. Automatically converts
   * Arrays to Uint8Array for Web MIDI API compatibility.
   *
   * @param {Uint8Array|Array<number>} message - MIDI message bytes (e.g., [0x90, 60, 100])
   * @param {number} [timestamp=performance.now()] - Optional timestamp for scheduled sending
   * @returns {void}
   *
   * @example
   * // Send a note on message (channel 1, note C4, velocity 100)
   * connection.send([0x90, 60, 100]);
   *
   * @example
   * // Send a note off message
   * connection.send([0x80, 60, 0]);
   *
   * @example
   * // Send a control change
   * connection.send([0xB0, 7, 64]); // Volume to 64
   */
  send(message, timestamp = null) {
    if (!this.output) {
      console.warn("No MIDI output connected. Call connect() first.")
      return
    }

    try {
      const data = new Uint8Array(message)

      if (timestamp === null) {
        this.output.send(data)
      } else {
        this.output.send(data, timestamp)
      }
    } catch (err) {
      console.error("Failed to send MIDI message:", err)
    }
  }

  /**
   * Send a System Exclusive (SysEx) message. Requires sysex: true in constructor options.
   *
   * @param {Array<number>} data - SysEx data bytes (without F0/F7 wrapper)
   * @param {boolean} [includeWrapper=false] - If true, wraps data with F0/F7 bytes
   * @returns {void}
   *
   * @example
   * // Send a basic SysEx message (will be wrapped with F0/F7)
   * connection.sendSysEx([0x41, 0x10, 0x42, 0x12, 0x40, 0x00, 0x7F]);
   *
   * @example
   * // Send pre-wrapped SysEx message
   * connection.sendSysEx([0xF0, 0x41, 0x10, 0x42, 0xF7], false);
   */
  sendSysEx(data, includeWrapper = false) {
    if (!this.options.sysex) {
      console.warn("SysEx not enabled. Initialize with sysex: true")
      return
    }

    let message
    if (includeWrapper) {
      message = [0xf0, ...data, 0xf7]
    } else {
      message = data
    }

    this.send(message)
  }
}