// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

package space.rcmd.android.sizzle.input

import android.content.Context
import android.media.midi.MidiDeviceInfo
import android.media.midi.MidiManager
import android.media.midi.MidiOutputPort
import android.media.midi.MidiReceiver
import android.os.Handler
import android.os.Looper

/**
 * Bridges hardware MIDI (USB *and* Bluetooth — Android surfaces both through the
 * same [MidiManager]) into the app's [InputAction] stream.
 *
 * MIDI note-on/off become live notes; MIDI Control Change (CC) messages are
 * matched against [ccBindings] so any knob/pad can drive navigation or transport.
 * Unmatched CCs are forwarded as [InputAction.RawControl] so the Settings screen
 * can "learn" them.
 *
 * Note: this handles MIDI *input*. Sending MIDI out lives in the audio/midi
 * output module; both share the same [MidiManager].
 */
class MidiInput(
    context: Context,
    private val router: InputRouter,
    /** CC number -> action id, editable from Settings (MIDI bindings section). */
    var ccBindings: MutableMap<Int, String> = mutableMapOf(),
) {
    private val midiManager = context.getSystemService(Context.MIDI_SERVICE) as? MidiManager
    private val handler = Handler(Looper.getMainLooper())
    private val openPorts = mutableListOf<MidiOutputPort>()

    /** Start listening. Opens every currently-attached input-capable device and
     *  watches for hot-plug events. Safe to call if the device has no MIDI. */
    fun start() {
        val mm = midiManager ?: return
        mm.devices.forEach(::openDevice)
        mm.registerDeviceCallback(object : MidiManager.DeviceCallback() {
            override fun onDeviceAdded(device: MidiDeviceInfo) = openDevice(device)
        }, handler)
    }

    fun stop() {
        openPorts.forEach { runCatching { it.close() } }
        openPorts.clear()
    }

    private fun openDevice(info: MidiDeviceInfo) {
        // We want devices that can SEND to us (they expose output ports).
        if (info.outputPortCount == 0) return
        midiManager?.openDevice(info, { device ->
            val port = device?.openOutputPort(0) ?: return@openDevice
            port.connect(parser)
            openPorts += port
        }, handler)
    }

    /** Parses the raw MIDI byte stream. MIDI status bytes have the high bit set;
     *  the low nibble is the channel, the high nibble the message type. */
    private val parser = object : MidiReceiver() {
        override fun onSend(data: ByteArray, offset: Int, count: Int, timestamp: Long) {
            var i = offset
            val end = offset + count
            while (i < end) {
                val status = data[i].toInt() and 0xFF
                if (status < 0x80) { i++; continue } // skip stray data bytes
                val type = status and 0xF0
                when (type) {
                    0x90 -> { // Note On
                        val note = data.getOrZero(i + 1)
                        val vel = data.getOrZero(i + 2)
                        if (vel > 0) router.dispatch(InputAction.NoteOn(note, vel))
                        else router.dispatch(InputAction.NoteOff(note)) // vel 0 == note off
                        i += 3
                    }
                    0x80 -> { // Note Off
                        router.dispatch(InputAction.NoteOff(data.getOrZero(i + 1))); i += 3
                    }
                    0xB0 -> { // Control Change
                        val cc = data.getOrZero(i + 1)
                        val value = data.getOrZero(i + 2)
                        handleCc(cc, value); i += 3
                    }
                    0xC0, 0xD0 -> i += 2 // program change / channel pressure: 1 data byte
                    else -> i += 3
                }
            }
        }
    }

    private fun handleCc(cc: Int, value: Int) {
        val actionId = ccBindings[cc]
        if (actionId == null) {
            router.dispatch(InputAction.RawControl(InputSource.MIDI, cc, value / 127f))
            return
        }
        // Navigation CCs are momentary (a pad/note-button): press (value >= 64) starts
        // an accelerating hold-to-repeat, release (value < 64) ends it — same feel as a
        // held key or D-pad. Keyed on the CC number so its release stops the right one.
        val nav = when (actionId) {
            "up" -> InputAction.NavUp
            "down" -> InputAction.NavDown
            "left" -> InputAction.NavLeft
            "right" -> InputAction.NavRight
            else -> null
        }
        if (nav != null) {
            if (value >= 64) router.pressRepeat(cc, nav) else router.releaseRepeat(cc)
            return
        }

        // Momentary controls fire on the "press" half (value >= 64).
        val action = when (actionId) {
            "playPause" -> if (value >= 64) InputAction.PlayPause else null
            "stop" -> if (value >= 64) InputAction.Stop else null
            "loop" -> if (value >= 64) InputAction.ToggleLoop else null
            "increment" -> InputAction.Increment(1)
            "decrement" -> InputAction.Decrement(1)
            "clear" -> if (value >= 64) InputAction.ClearCell else null
            "nextTab" -> if (value >= 64) InputAction.NextTab else null
            "prevTab" -> if (value >= 64) InputAction.PrevTab else null
            else -> null
        }
        action?.let(router::dispatch)
    }

    private fun ByteArray.getOrZero(index: Int): Int =
        if (index < size) this[index].toInt() and 0xFF else 0
}
