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

package space.rcmd.android.sizzle.ui

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import space.rcmd.android.sizzle.audio.AudioEngine
import space.rcmd.android.sizzle.input.InputAction
import space.rcmd.android.sizzle.input.InputRouter
import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.TimeSignature
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/**
 * The single screen-facing state holder. It sits between the UI (Compose) and
 * the model/engine, and it is the ONE place that reacts to [InputAction]s. Touch
 * gestures call its methods directly; keyboard/gamepad/MIDI reach it through the
 * [InputRouter] flow collected in [init]. Either way the same code runs, which is
 * how all four input methods stay equally capable.
 *
 * Recomposition trick: the model classes are plain (non-observable) for audio
 * performance, so after we mutate the grid we bump [revision]. Compose reads that
 * value while drawing the grid, so an increment forces a redraw. Simple and fast.
 */
class AppViewModel(
    val project: Project,
    private val engine: AudioEngine,
    private val router: InputRouter,
    private val gamepadInput: space.rcmd.android.sizzle.input.GamepadInput,
    private val midiInput: space.rcmd.android.sizzle.input.MidiInput,
    private val keyboardInput: space.rcmd.android.sizzle.input.KeyboardInput,
    private val bindingStore: space.rcmd.android.sizzle.input.BindingStore,
    private val presetLibrary: space.rcmd.android.sizzle.io.PresetLibrary,
    private val songLibrary: space.rcmd.android.sizzle.io.SongLibrary,
    private val settingsStore: space.rcmd.android.sizzle.io.SettingsStore,
    private val themeLibrary: space.rcmd.android.sizzle.io.ThemeLibrary,
    private val appContext: android.content.Context,
) : ViewModel() {

    // ----- Observable UI state -----
    var selectedTab by mutableIntStateOf(0); private set
    var revision by mutableIntStateOf(0); private set   // bump to force grid redraw
    /** Active colour theme; read by [MainActivity] to drive SizzleTheme. Set via
     *  [selectPalette] so the choice is persisted. */
    var palette by mutableStateOf(space.rcmd.android.sizzle.ui.theme.RetroPalette.AMBER)
        private set

    // Tracker cursor position.
    var cursorTrack by mutableIntStateOf(0); private set
    var cursorLine by mutableIntStateOf(0); private set
    var cursorColumn by mutableStateOf(CellColumn.NOTE); private set

    // ----- Selection & clipboard (tracker edit ops) -----
    /** When on, the cursor drags out a rectangular selection from [selAnchorTrack]/
     *  [selAnchorLine] to the live cursor; edit ops act on that rectangle. */
    var selectMode by mutableStateOf(false); private set
    var selAnchorTrack by mutableIntStateOf(-1); private set
    var selAnchorLine by mutableIntStateOf(-1); private set
    /** Copied cells, indexed [trackOffset][lineOffset]; null until first copy/cut. */
    private var clipboard: Array<Array<Cell>>? = null
    val hasSelection: Boolean get() = selectMode && selAnchorTrack >= 0
    val canPaste: Boolean get() = clipboard != null

    // ----- Entry memory -----
    /** The last note & channel actually entered (by any input). Keyboard/gamepad
     *  edits on an empty tick resume from these so entry continues where you left
     *  off instead of jumping to a fixed default. */
    var lastNote by mutableIntStateOf(Pitch.MIDDLE_C); private set
    var lastChannel by mutableIntStateOf(0); private set

    // ----- Shared keyboard / punch-in state -----
    // These live on the ViewModel (not per-screen) so the tracker punch-in keyboard
    // and the toolbox audition keyboard share them and they survive tab switches.
    /** Rows the cursor jumps after a punch-in and per up/down nav step (0/1/2/4/8;
     *  0 keeps the cursor put). */
    var skipStep by mutableIntStateOf(1); private set
    /** Whether the tracker's lower half shows the punch-in keyboard (vs the arranger). */
    var punchInMode by mutableStateOf(false); private set
    /** Target MIDI channel for keyboard notes; 0 == the empty channel. */
    var kbdChannel by mutableIntStateOf(0); private set
    /** Base octave (the lower of the two shown octaves). */
    var kbdOctave by mutableIntStateOf(3); private set
    /** Velocity for keyboard notes (0..127). */
    var kbdVelocity by mutableIntStateOf(Cell.MAX_VELOCITY); private set

    fun updateSkipStep(n: Int) { skipStep = n.coerceIn(0, 8) }
    fun togglePunchIn() { punchInMode = !punchInMode }
    fun updateKbdChannel(c: Int) { kbdChannel = c.coerceIn(0, Cell.MAX_CHANNEL); persistKeyboard(); touched() }
    fun updateKbdOctave(o: Int) { kbdOctave = o.coerceIn(0, 8); persistKeyboard(); touched() }
    fun updateKbdVelocity(v: Int) { kbdVelocity = v.coerceIn(0, Cell.MAX_VELOCITY); touched() }

    /** Persist the keyboard MIDI channel + base octave so they survive a restart. */
    private fun persistKeyboard() {
        viewModelScope.launch { settingsStore.saveKeyboard(kbdChannel, kbdOctave) }
    }

    /** Live transport snapshot from the audio thread (drives the playhead). */
    val transport get() = engine.transport

    /** Just the play/stop state, deduped so it emits ONLY on a play↔stop change — not
     *  on every playhead line like [transport] does. Composables that only care whether
     *  playback is running (e.g. the PLAY/PAUSE button) collect this instead of the full
     *  transport, so they don't recompose ~12×/second during playback. */
    val isPlaying: StateFlow<Boolean> = engine.transport
        .map { it.isPlaying }
        .distinctUntilChanged()
        .stateIn(viewModelScope, SharingStarted.Eagerly, engine.transport.value.isPlaying)

    // ----- Binding-learn state (Settings tab) -----
    /** The action id currently waiting to be bound, or null. */
    var learnTarget by mutableStateOf<String?>(null); private set
    private var learnSource: space.rcmd.android.sizzle.input.InputSource? = null

    init {
        // Collect neutral input actions from every non-touch device.
        viewModelScope.launch {
            router.actions.collect(::onAction)
        }
    }

    // ------------------------------------------------------------- tab control
    fun selectTab(index: Int) { selectedTab = index.coerceIn(0, 3) }

    // ----------------------------------------------------------- cursor & edit
    fun focusCell(track: Int, line: Int, column: CellColumn) {
        val p = project.activePattern()
        cursorTrack = track.coerceIn(0, Pattern.TRACK_COUNT - 1)
        cursorLine = line.coerceIn(0, p.length - 1)
        cursorColumn = column
    }

    private fun focusedCell(): Cell =
        project.activePattern().cell(cursorTrack, cursorLine)

    /**
     * Move the cursor vertically by [dLine] rows, cycling past the top/bottom of the
     * pattern (wrap-around) rather than clamping. Used by the external controllers'
     * up/down so repeated presses keep scrolling — combined with the skip step this
     * jumps by N rows and wraps at the pattern ends.
     */
    fun moveCursorVerticalWrap(dLine: Int) {
        val len = project.activePattern().length
        if (len <= 0) return
        cursorLine = Math.floorMod(cursorLine + dLine, len)
    }

    fun nextColumn() { cursorColumn = nextOf(cursorColumn, +1) }
    fun prevColumn() { cursorColumn = nextOf(cursorColumn, -1) }

    /**
     * Move the cursor by one column across the whole row — the three columns
     * (note/velocity/channel) of every track laid end to end — so stepping right
     * off a track's last column lands on the next track's first column. Used by the
     * external controllers' left/right so they traverse columns, not just tracks.
     */
    fun moveColumnFlat(dir: Int) {
        val cols = CellColumn.entries.size
        val flat = (cursorTrack * cols + cursorColumn.ordinal + dir)
            .coerceIn(0, Pattern.TRACK_COUNT * cols - 1)
        cursorTrack = flat / cols
        cursorColumn = CellColumn.entries[flat % cols]
    }

    /** Punch a note into the focused cell (velocity/channel from the keyboard) and
     *  advance the cursor by [skipStep] rows, cycling past the pattern ends — the
     *  touch-keyboard entry path. */
    fun punchNote(midi: Int) {
        val cell = focusedCell()
        cell.note = midi.coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
        cell.velocity = kbdVelocity.coerceIn(0, Cell.MAX_VELOCITY)
        cell.channel = kbdChannel
        lastNote = cell.note; lastChannel = kbdChannel
        moveCursorVerticalWrap(skipStep)
        touched()
    }

    // ------------------------------------------------------------ held notes
    /**
     * Notes currently held down, from ANY source (on-screen keyboards, external
     * keyboard/gamepad, MIDI in). Keyed by MIDI note → active-press count so the
     * same note held by two sources at once (e.g. touch + MIDI) doesn't clear the
     * highlight until both release. Backed by a snapshot map, so every on-screen
     * keyboard widget that reads [isNoteHeld] lights its keys up while held.
     */
    private val heldNotes = mutableStateMapOf<Int, Int>()

    /** True while [midi] is held down by at least one input source. */
    fun isNoteHeld(midi: Int): Boolean = (heldNotes[midi] ?: 0) > 0

    private fun markNoteOn(midi: Int) { heldNotes[midi] = (heldNotes[midi] ?: 0) + 1 }
    private fun markNoteOff(midi: Int) {
        val n = (heldNotes[midi] ?: 0) - 1
        if (n <= 0) heldNotes.remove(midi) else heldNotes[midi] = n
    }

    /** Audition a note on the keyboard's channel/velocity (mix-bus playback). */
    fun kbdNoteOn(midi: Int) { markNoteOn(midi); engine.busNoteOn(kbdChannel, midi, kbdVelocity) }
    fun kbdNoteOff(midi: Int) { markNoteOff(midi); engine.busNoteOff(kbdChannel, midi) }

    /**
     * The core value-editing operation, shared by drag gestures and +/- input.
     * NOTE column cycles within the active scale; VELOCITY/CHANNEL step numerically.
     */
    fun editFocused(direction: Int) {
        val cell = focusedCell()
        // A note-off has no velocity/channel — only its NOTE column is editable
        // (which turns it back into a real note).
        if (cell.isNoteOff && cursorColumn != CellColumn.NOTE) return
        when (cursorColumn) {
            CellColumn.NOTE -> {
                // Seed an empty tick from the last note entered so filling resumes
                // where you left off rather than at a fixed default.
                val seed = if (cell.isPlayable) cell.note else lastNote
                cell.note = project.scale.step(seed, project.rootNote, direction)
                lastNote = cell.note
            }
            CellColumn.VELOCITY -> cell.velocity = (cell.velocity + direction).coerceIn(0, Cell.MAX_VELOCITY)
            CellColumn.CHANNEL -> {
                // Likewise seed an empty tick's channel from the last one entered.
                val seed = if (cell.isEmpty) lastChannel else cell.channel
                cell.channel = (seed + direction).coerceIn(1, Cell.MAX_CHANNEL)
                lastChannel = cell.channel
            }
        }
        touched()
    }

    fun clearFocused() { focusedCell().clear(); touched() }

    /** Insert a note-off ("===") at the cursor and advance by the skip step, like a
     *  punch-in. A note-off is tied to its track (no velocity/channel of its own — it
     *  releases whatever that track last played), so those fields are reset to
     *  defaults and ignored. */
    fun noteOffFocused() {
        val cell = focusedCell()
        cell.note = Cell.OFF
        cell.velocity = Cell.MAX_VELOCITY
        cell.channel = 1
        moveCursorVerticalWrap(skipStep)
        touched()
    }

    /** DEL button: clear the selection (or focused cell) and advance by the skip
     *  step. Kept separate from [deleteSelection] so a CUT doesn't move the cursor. */
    fun deleteAndAdvance() {
        deleteSelection()
        moveCursorVerticalWrap(skipStep)
        touched()
    }

    // ---------------------------------------------------- selection & clipboard
    /** The selected track range (inclusive). Anchor..cursor, or just the cursor
     *  cell when there is no active selection. */
    val selTrackRange: IntRange
        get() = if (hasSelection) minOf(selAnchorTrack, cursorTrack)..maxOf(selAnchorTrack, cursorTrack)
        else cursorTrack..cursorTrack
    val selLineRange: IntRange
        get() = if (hasSelection) minOf(selAnchorLine, cursorLine)..maxOf(selAnchorLine, cursorLine)
        else cursorLine..cursorLine

    /** Toggle select mode. Turning it on anchors the selection at the cursor;
     *  turning it off clears the selection (the clipboard is untouched). */
    fun toggleSelectMode() {
        selectMode = !selectMode
        if (selectMode) { selAnchorTrack = cursorTrack; selAnchorLine = cursorLine }
        else { selAnchorTrack = -1; selAnchorLine = -1 }
        touched()
    }

    /** Copy the selection (or, with none, the focused cell) into the clipboard. */
    fun copySelection() {
        val p = project.activePattern()
        val tr = selTrackRange; val lr = selLineRange
        clipboard = Array(tr.count()) { ti ->
            Array(lr.count()) { li -> p.cell(tr.first + ti, lr.first + li).copyOf() }
        }
        touched() // so the PASTE button (canPaste) refreshes now that a clipboard exists
    }

    /** Clear the selected cells (or the focused cell when nothing is selected). */
    fun deleteSelection() {
        val p = project.activePattern()
        for (t in selTrackRange) for (l in selLineRange) p.cell(t, l).clear()
        touched()
    }

    /** Copy then clear — a standard cut. */
    fun cutSelection() { copySelection(); deleteSelection() }

    /** Paste the clipboard with its top-left at the cursor, clipped to the grid. */
    fun pasteClipboard() {
        val cb = clipboard ?: return
        val p = project.activePattern()
        for (ti in cb.indices) for (li in cb[ti].indices) {
            val t = cursorTrack + ti
            val l = cursorLine + li
            if (t < Pattern.TRACK_COUNT && l < p.length) p.cell(t, l).setFrom(cb[ti][li])
        }
        touched()
    }

    /** Which block (0..7) the tracker is editing. Lane i is tied to block i. */
    val activeBlock: Int get() = project.activePatternId
    fun setActiveBlock(i: Int) {
        project.activePatternId = i.coerceIn(0, project.patterns.size - 1)
        cursorLine = cursorLine.coerceIn(0, project.activePattern().length - 1)
        touched()
    }

    // ------------------------------------------------------- transport control
    fun playPause() {
        val t = transport.value
        if (t.isPlaying) engine.pause() else engine.play()
    }

    fun stop() = engine.stop()

    /** MIDI panic: silence all sounding/stuck notes immediately. */
    fun midiPanic() { heldNotes.clear(); engine.panic() }

    // ------------------------------------------------ master-bus recording
    /** Whether recording is armed (starts capturing when playback next starts). */
    var recordArmed by mutableStateOf(false); private set
    /** FX-tail length captured after the sequencer stops, in bars (0..4). */
    var recordTailBars by mutableIntStateOf(2); private set
    /** Chosen output folder as a persisted SAF tree URI string, or null. */
    var recordDirUri by mutableStateOf<String?>(null); private set
    /** Human-readable name of the chosen output folder (for the toolbar). */
    var recordDirName by mutableStateOf<String?>(null); private set
    /** True while a take is actively capturing (arm has fired on play). */
    var masterRecording by mutableStateOf(false); private set
    /** Filename of the most recently written take (a small "saved …" status). */
    var lastRecordingName by mutableStateOf<String?>(null); private set

    /** Toggle record-arm. Requires a folder first — see [MixerScreen] which opens the
     *  picker when none is set, so this is only reached once a destination exists. */
    fun toggleRecordArm() {
        recordArmed = !recordArmed
        engine.setRecordingArm(recordArmed, recordTailBars)
    }

    fun updateRecordTailBars(n: Int) {
        recordTailBars = n.coerceIn(0, 4)
        engine.setRecordTailBars(recordTailBars)
        persistRecordingSettings()
    }

    /** Adopt a folder picked via the Storage Access Framework as the WAV destination,
     *  taking a persistable permission so it survives an app restart. */
    fun setRecordDir(uri: android.net.Uri) {
        runCatching {
            appContext.contentResolver.takePersistableUriPermission(
                uri,
                android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
                    android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
            )
        }
        recordDirUri = uri.toString()
        recordDirName = displayNameForTree(uri)
        persistRecordingSettings()
    }

    private fun persistRecordingSettings() {
        viewModelScope.launch { settingsStore.saveRecording(recordDirUri, recordTailBars) }
    }

    /** Copy a finalized temp WAV into the chosen SAF folder with a date-time name.
     *  Runs on the recorder's writer thread; returns the written file name or null. */
    private fun saveRecordingToDir(temp: java.io.File, startMillis: Long): String? {
        val dirUriStr = recordDirUri
        if (dirUriStr == null) { temp.delete(); return null }
        return runCatching {
            val treeUri = android.net.Uri.parse(dirUriStr)
            val dirDocUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(
                treeUri, android.provider.DocumentsContract.getTreeDocumentId(treeUri),
            )
            val name = "sizzle-" + java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US)
                .format(java.util.Date(startMillis)) + ".wav"
            val fileUri = android.provider.DocumentsContract.createDocument(
                appContext.contentResolver, dirDocUri, "audio/wav", name,
            ) ?: return@runCatching null
            appContext.contentResolver.openOutputStream(fileUri)?.use { out ->
                temp.inputStream().use { it.copyTo(out) }
            }
            name
        }.getOrNull().also { temp.delete() }
    }

    /** Best-effort readable folder name from a SAF tree URI's document id. */
    private fun displayNameForTree(uri: android.net.Uri): String {
        val id = runCatching { android.provider.DocumentsContract.getTreeDocumentId(uri) }
            .getOrNull() ?: return "folder"
        return id.substringAfterLast('/').substringAfterLast(':').ifBlank { id }
    }

    // --------------------------------------------- project-level setting edits
    fun setTempo(bpm: Float) { project.tempoBpm = bpm.coerceIn(20f, 300f); touched() }

    fun setTimeSignature(sig: TimeSignature) {
        project.timeSignature = sig
        // Snap pattern length to the shortest option for the new signature.
        val p = project.activePattern()
        if (p.length !in sig.lengthOptions) p.resize(sig.lengthOptions[0])
        cursorLine = cursorLine.coerceIn(0, p.length - 1)
        touched()
    }

    fun setPatternLength(len: Int) {
        project.activePattern().resize(len)
        cursorLine = cursorLine.coerceIn(0, len - 1)
        touched()
    }

    // ----------------------------------------------------- input action router
    /** The single dispatch point for keyboard / gamepad / MIDI actions. */
    private fun onAction(action: InputAction) {
        when (action) {
            // External up/down jump by the skip step; left/right traverse columns.
            InputAction.NavUp -> moveCursorVerticalWrap(-skipStep)
            InputAction.NavDown -> moveCursorVerticalWrap(+skipStep)
            InputAction.NavLeft -> moveColumnFlat(-1)
            InputAction.NavRight -> moveColumnFlat(+1)
            InputAction.NextColumn -> nextColumn()
            InputAction.PrevColumn -> prevColumn()
            is InputAction.Increment -> editFocused(action.amount)
            is InputAction.Decrement -> editFocused(-action.amount)
            InputAction.ClearCell -> clearFocused()
            InputAction.NoteOffCell -> noteOffFocused()
            is InputAction.NoteOn -> {
                markNoteOn(action.pitch)
                engine.liveNoteOn(action.pitch, action.velocity)
                // Live entry also writes the note into the focused NOTE cell.
                if (cursorColumn == CellColumn.NOTE) {
                    focusedCell().note = action.pitch; lastNote = action.pitch; touched()
                }
            }
            is InputAction.NoteOff -> { markNoteOff(action.pitch); engine.liveNoteOff(action.pitch) }
            InputAction.PlayPause -> playPause()
            InputAction.Stop -> stop()
            InputAction.ToggleLoop -> { project.arrangement.loopEnabled = !project.arrangement.loopEnabled; touched() }
            is InputAction.SelectTab -> selectTab(action.index)
            InputAction.NextTab -> selectTab(selectedTab + 1)
            InputAction.PrevTab -> selectTab(selectedTab - 1)
            is InputAction.RawControl -> onRawControl(action)
        }
    }

    // ------------------------------------------------------- binding learn
    /** Arm MIDI-learn / gamepad-rebind: the next matching control is bound to
     *  [actionId]. [source] restricts which device kind will be captured. */
    fun beginLearn(actionId: String, source: space.rcmd.android.sizzle.input.InputSource) {
        learnTarget = actionId
        learnSource = source
        router.learnSource = source
        router.learnMode = true
    }

    fun cancelLearn() {
        learnTarget = null
        learnSource = null
        router.learnSource = null
        router.learnMode = false
    }

    /** A control arrived during learn. If it matches the armed source, store it as
     *  the binding for [learnTarget]. Each action keeps at most one binding, so any
     *  previous one for this action (on the same device) is replaced. */
    private fun onRawControl(action: InputAction.RawControl) {
        val target = learnTarget ?: return
        if (action.source != learnSource) return
        when (action.source) {
            space.rcmd.android.sizzle.input.InputSource.MIDI ->
                midiInput.ccBindings[action.code] = target
            space.rcmd.android.sizzle.input.InputSource.GAMEPAD -> {
                gamepadInput.bindings.entries.removeAll { it.value == target }
                gamepadInput.bindings[
                    space.rcmd.android.sizzle.input.GamepadChord(action.modifier, action.code),
                ] = target
            }
            space.rcmd.android.sizzle.input.InputSource.KEYBOARD -> {
                keyboardInput.bindings.entries.removeAll { it.value == target }
                keyboardInput.bindings[action.code] = target
            }
            else -> {}
        }
        cancelLearn()
        touched()
        persistBindings()
    }

    /** Current MIDI CC number bound to [actionId], or null. */
    fun midiBindingFor(actionId: String): Int? =
        midiInput.ccBindings.entries.firstOrNull { it.value == actionId }?.key

    /** Current gamepad chord (modifier + button, or plain button) bound to
     *  [actionId], or null. */
    fun gamepadBindingFor(actionId: String): space.rcmd.android.sizzle.input.GamepadChord? =
        gamepadInput.bindings.entries.firstOrNull { it.value == actionId }?.key

    /** Current keyboard key code bound to [actionId], or null. */
    fun keyboardBindingFor(actionId: String): Int? =
        keyboardInput.bindings.entries.firstOrNull { it.value == actionId }?.key

    fun clearBinding(actionId: String, source: space.rcmd.android.sizzle.input.InputSource) {
        when (source) {
            space.rcmd.android.sizzle.input.InputSource.MIDI ->
                midiInput.ccBindings.entries.removeAll { it.value == actionId }
            space.rcmd.android.sizzle.input.InputSource.GAMEPAD ->
                gamepadInput.bindings.entries.removeAll { it.value == actionId }
            space.rcmd.android.sizzle.input.InputSource.KEYBOARD ->
                keyboardInput.bindings.entries.removeAll { it.value == actionId }
            else -> {}
        }
        touched()
        persistBindings()
    }

    /** Save all input binding maps so edits survive an app restart. */
    private fun persistBindings() {
        viewModelScope.launch { bindingStore.save(keyboardInput, gamepadInput, midiInput) }
    }

    private fun touched() { revision++ }

    /** Public hook so screens that mutate the model directly (e.g. dropdowns in
     *  the toolbar, mixer faders) can request a redraw. */
    fun bumpForToolbar() = touched()

    /** Call after changing mixer FX routing so the audio engine rebuilds the
     *  per-channel insert chains without waiting for the next Play. */
    fun rebuildAudioRouting() {
        engine.rebuildFxChains()
        touched()
    }

    /** Toggle mute on an arrangement MIDI lane — a muted lane's blocks are skipped
     *  by the sequencer while its cells stay in place. Read live on the audio thread. */
    fun toggleArrangementLaneMute(lane: Int) {
        val muted = project.arrangement.laneMuted
        if (lane in muted.indices) { muted[lane] = !muted[lane]; touched() }
    }

    /** Nudge an A/B loop region's repeat count and apply it to live playback
     *  immediately (without restarting the transport). */
    fun changeRegionRepeats(region: space.rcmd.android.sizzle.model.LoopRegion, delta: Int) {
        region.repeats = (region.repeats + delta).coerceAtLeast(1)
        engine.refreshLoopPlaylist()
        touched()
    }

    // -------------------------------------------------------------- sampler
    private val recorder = space.rcmd.android.sizzle.audio.SampleRecorder()
    var isRecording by mutableStateOf(false); private set

    /** Decode a WAV and assign it to Sampler [slot]'s [pad] (from a file import). */
    fun loadSampleInto(
        slot: space.rcmd.android.sizzle.model.ToolboxSlot,
        pad: Int,
        id: String,
        bytes: ByteArray,
    ): Boolean {
        val sample = space.rcmd.android.sizzle.audio.SampleStore.decodeWav(bytes) ?: return false
        space.rcmd.android.sizzle.audio.SampleStore.put(id, sample)
        val pads = space.rcmd.android.sizzle.model.SamplerPads
        slot.set(pads.key(pad), id)
        slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
        touched()
        return true
    }

    /** Load an SF2 / XI (or WAV) file into a SoundFont [slot]: parse it, cache the
     *  extracted sample, and store its id + root note on the slot. */
    fun loadSoundFont(
        slot: space.rcmd.android.sizzle.model.ToolboxSlot,
        id: String,
        bytes: ByteArray,
    ): Boolean {
        val loaded = space.rcmd.android.sizzle.audio.SoundFontLoader.load(bytes) ?: return false
        val previous = slot.string("sfSample")
        space.rcmd.android.sizzle.audio.SampleStore.put(id, loaded.sample)
        slot.set("sfSample", id)
        slot.set("sfRoot", loaded.rootNote.toFloat())
        // Free the sample this slot previously held (unless another slot still uses
        // the same file) so repeatedly re-loading instruments doesn't leak PCM.
        if (previous.isNotBlank() && previous != id && !isSampleReferenced(previous, except = slot)) {
            space.rcmd.android.sizzle.audio.SampleStore.remove(previous)
        }
        touched()
        return true
    }

    /** True if any toolbox slot other than [except] references the sample id [id]
     *  (as a SoundFont's `sfSample` or a Sampler pad). Used before evicting a sample
     *  from the [space.rcmd.android.sizzle.audio.SampleStore] so shared samples stay. */
    private fun isSampleReferenced(
        id: String,
        except: space.rcmd.android.sizzle.model.ToolboxSlot,
    ): Boolean = project.toolbox.any { s -> s !== except && s.values.values.any { it == id } }

    /**
     * Destructively trim a Sampler [slot]'s [pad] to its current slice region: the
     * pad's sample is replaced by just the portion between the two slice markers,
     * and the markers reset to the full (new) sample. There is no undo — re-import
     * or re-record to start over. Returns false if there's nothing to trim.
     */
    fun trimSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int): Boolean {
        val pads = space.rcmd.android.sizzle.model.SamplerPads
        val store = space.rcmd.android.sizzle.audio.SampleStore
        val sample = store.get(slot.string(pads.key(pad))) ?: return false
        val len = sample.data.size
        if (len <= 1) return false
        val a = slot.float(pads.sliceStartKey(pad), 0f).coerceIn(0f, 1f)
        val b = slot.float(pads.sliceEndKey(pad), 1f).coerceIn(0f, 1f)
        val start = (minOf(a, b) * len).toInt().coerceIn(0, len - 1)
        val end = (maxOf(a, b) * len).toInt().coerceIn(start + 1, len)
        if (start == 0 && end == len) return false // slice already spans the whole sample
        // A fresh id so other pads sharing the original sample are left untouched.
        val id = "trim:" + System.nanoTime()
        store.put(id, space.rcmd.android.sizzle.audio.SampleStore.Sample(
            sample.data.copyOfRange(start, end), sample.sampleRate))
        slot.set(pads.key(pad), id)
        slot.set(pads.sliceStartKey(pad), 0f)
        slot.set(pads.sliceEndKey(pad), 1f)
        touched()
        return true
    }

    /**
     * Peak-normalize a Sampler [slot]'s [pad]: scale the whole sample so its loudest
     * peak reaches full scale (+-1). The slice markers are unchanged (the length is
     * the same). No undo. Returns false if the pad is empty, silent, or already
     * normalized.
     */
    fun normalizeSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int): Boolean {
        val pads = space.rcmd.android.sizzle.model.SamplerPads
        val store = space.rcmd.android.sizzle.audio.SampleStore
        val sample = store.get(slot.string(pads.key(pad))) ?: return false
        val data = sample.data
        if (data.isEmpty()) return false
        var peak = 0f
        for (v in data) { val a = if (v < 0f) -v else v; if (a > peak) peak = a }
        if (peak <= 0f) return false                  // silent — nothing to normalize
        val gain = 1f / peak
        if (gain in 0.999f..1.001f) return false      // already at full scale
        val out = FloatArray(data.size) { (data[it] * gain).coerceIn(-1f, 1f) }
        val id = "norm:" + System.nanoTime()          // fresh id so shared samples are untouched
        store.put(id, space.rcmd.android.sizzle.audio.SampleStore.Sample(out, sample.sampleRate))
        slot.set(pads.key(pad), id)
        touched()
        return true
    }

    /** Clear the sample assigned to a Sampler [slot]'s [pad]. */
    fun clearSamplerPad(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int) {
        val pads = space.rcmd.android.sizzle.model.SamplerPads
        slot.values.remove(pads.key(pad))
        slot.values.remove(pads.sliceStartKey(pad))
        slot.values.remove(pads.sliceEndKey(pad))
        touched()
    }

    fun startRecording() { if (recorder.start()) isRecording = true }

    // ------------------------------------------------------- audio device routing
    /** Ids of the chosen output/input devices (for the Settings UI), null = default. */
    var outputDeviceId by mutableStateOf<Int?>(null); private set
    var inputDeviceId by mutableStateOf<Int?>(null); private set

    fun setAudioOutput(device: android.media.AudioDeviceInfo?) {
        engine.setPreferredOutput(device)
        outputDeviceId = device?.id
    }

    fun setAudioInput(device: android.media.AudioDeviceInfo?) {
        recorder.preferredInput = device
        inputDeviceId = device?.id
    }

    /** Set when the native Oboe backend failed to initialise and playback fell back
     *  to the standard AudioTrack path; drives a one-off popup. Null when clear. */
    var nativeAudioError by mutableStateOf<String?>(null); private set
    fun dismissNativeAudioError() { nativeAudioError = null }

    /** Choose the colour theme and persist the choice. */
    fun selectPalette(p: space.rcmd.android.sizzle.ui.theme.RetroPalette) {
        palette = p
        persistSettings()
    }

    // ------------------------------------------------------------ colour themes
    private val stockThemes get() = space.rcmd.android.sizzle.ui.theme.RetroPalette.ALL

    /** The two built-in themes are read-only and can never be deleted. */
    fun isStockTheme(name: String): Boolean = stockThemes.any { it.name == name }

    /** Every selectable theme: the two stock themes followed by user themes. */
    fun allThemes(): List<space.rcmd.android.sizzle.ui.theme.RetroPalette> =
        stockThemes + themeLibrary.loadAll()

    /** All theme names for the selector dropdown (stock first, then user). */
    fun themeNames(): List<String> = allThemes().map { it.name }

    /** Apply the theme with [name] (stock or user), persisting the choice. */
    fun selectThemeByName(name: String) {
        allThemes().firstOrNull { it.name == name }?.let { selectPalette(it) }
    }

    /** Import a theme from picked bytes and apply it. Returns false if it isn't a
     *  valid `.szt` theme (so the caller can show a popup). */
    fun importTheme(bytes: ByteArray): Boolean {
        val palette = themeLibrary.importText(String(bytes, Charsets.UTF_8)) ?: return false
        selectPalette(palette) // apply + persist
        touched()
        return true
    }

    /** A shareable `.szt` file of the current theme (for the system share sheet). */
    fun themeExportFile(): java.io.File = themeLibrary.writeExport(palette)

    /** Delete a USER theme by name. Stock themes are protected (returns false). If
     *  the deleted theme was active, fall back to the default palette. */
    fun deleteTheme(name: String): Boolean {
        if (isStockTheme(name)) return false
        val ok = themeLibrary.delete(name)
        if (ok) {
            if (palette.name == name) selectPalette(space.rcmd.android.sizzle.ui.theme.RetroPalette.AMBER)
            touched()
        }
        return ok
    }

    /** Save app-level settings (theme + audio backend) so they survive a restart. */
    private fun persistSettings() {
        viewModelScope.launch { settingsStore.save(palette.name) }
    }

    // Restore persisted app settings. This init block sits AFTER the `palette` state
    // property is declared, so its backing MutableState exists before we touch it
    // (init blocks run in textual order). A tiny blocking read at construction
    // (before the first compose) so the theme applies without a flash of the default.
    init {
        kotlinx.coroutines.runBlocking {
            val s = settingsStore.load()
            // Match the saved theme against the stock themes first, then user themes.
            allThemes().firstOrNull { it.name == s.paletteName }?.let { palette = it }

            // Surface a popup if the low-latency (Oboe) backend fails to init and the
            // engine falls back to standard playback. Fires off the main thread.
            engine.onNativeInitError = { msg ->
                viewModelScope.launch { nativeAudioError = msg }
            }

            // Restore the recording destination + tail, and wire the engine's
            // capture staging/completion into the SAF copy.
            recordDirUri = s.recordDirUri
            recordDirName = recordDirUri
                ?.let { runCatching { displayNameForTree(android.net.Uri.parse(it)) }.getOrNull() }
            recordTailBars = s.recordTailBars.coerceIn(0, 4)
            // Restore the on-screen keyboard's channel + octave.
            kbdChannel = s.kbdChannel.coerceIn(0, Cell.MAX_CHANNEL)
            kbdOctave = s.kbdOctave.coerceIn(0, 8)
            engine.recTempDir = appContext.cacheDir
            engine.setRecordTailBars(recordTailBars)
            engine.onRecordingStarted = {
                viewModelScope.launch { masterRecording = true }
            }
            engine.onRecordingComplete = { file, startMillis ->
                // On the recorder's writer thread: copy off the main thread, then
                // publish the UI state change on it.
                val saved = saveRecordingToDir(file, startMillis)
                viewModelScope.launch {
                    masterRecording = false
                    if (saved != null) lastRecordingName = saved
                }
            }
        }
    }

    /** Stop recording and assign the captured audio to Sampler [slot]'s [pad]. */
    fun stopRecordingInto(slot: space.rcmd.android.sizzle.model.ToolboxSlot, pad: Int) {
        val sample = recorder.stop()
        isRecording = false
        if (sample != null) {
            val id = "rec-" + System.currentTimeMillis()
            space.rcmd.android.sizzle.audio.SampleStore.put(id, sample)
            val pads = space.rcmd.android.sizzle.model.SamplerPads
            slot.set(pads.key(pad), id)
            slot.set(pads.sliceStartKey(pad), 0f); slot.set(pads.sliceEndKey(pad), 1f)
            touched()
        }
    }

    /** Point an LFO slot at another device's parameter, capturing that param's
     *  current value as the modulation centre. Pass targetSlotIndex < 0 to clear. */
    fun setLfoTarget(
        lfoSlot: space.rcmd.android.sizzle.model.ToolboxSlot,
        targetSlotIndex: Int,
        key: String,
    ) {
        if (targetSlotIndex < 0) {
            lfoSlot.set("target", "")
        } else {
            val tSlot = project.toolbox.getOrNull(targetSlotIndex)
            val spec = tSlot?.type?.params?.firstOrNull { it.key == key }
            lfoSlot.set("target", "$targetSlotIndex:$key")
            lfoSlot.set("center", tSlot?.float(key, spec?.default ?: 0f) ?: 0f)
        }
        touched()
    }

    /** Preview a note of the Sampler slot's loaded sample (one-shot, editor use). */
    fun auditionSample(slot: space.rcmd.android.sizzle.model.ToolboxSlot, midi: Int) {
        engine.auditionSlotOn(slot.index, midi)
    }

    /** Toolbox test keyboard: press/release a note on the instrument in [slotIndex]. */
    fun auditionOn(slotIndex: Int, midi: Int) { markNoteOn(midi); engine.auditionSlotOn(slotIndex, midi) }
    fun auditionOff(midi: Int) { markNoteOff(midi); engine.auditionSlotOff(midi) }


    // ------------------------------------------------------------ project I/O
    /** Serialize the current song to `.sng` text. */
    fun saveProjectText(): String =
        space.rcmd.android.sizzle.io.ProjectIo.save(project)

    /** Replace the current song's contents from our full internal text (in place). */
    fun loadProjectText(text: String) {
        space.rcmd.android.sizzle.io.ProjectIo.loadInto(project, text)
        cursorLine = 0; cursorTrack = 0
        touched()
    }

    /** Export to the reference desktop sizzletracker `.sng` format (musical data). */
    fun exportSng(): String =
        space.rcmd.android.sizzle.io.SngFormat.export(project)

    /** Import a reference `.sng` file (in place). Returns false if the text doesn't
     *  look like a `.sng` (so the caller can surface a popup) rather than silently
     *  wiping the song with garbage. */
    fun importSng(text: String): Boolean {
        val looksLikeSng = Regex("(?m)^\\s*(version|bpm|sig|block)\\b").containsMatchIn(text)
        if (!looksLikeSng) return false
        runCatching {
            space.rcmd.android.sizzle.io.SngFormat.importInto(project, text)
        }.getOrElse { return false }
        cursorLine = 0; cursorTrack = 0
        touched()
        return true
    }

    // -------------------------------------------- on-device project library (full)
    /** Names of the full projects stored on device (for the Settings browser). */
    fun songNames(): List<String> = songLibrary.list()

    /** The current project name (shown as the save default). */
    val songName: String get() = project.name

    /** Save the current project — full fidelity (mixer/FX/instruments) — to storage. */
    fun saveSong(name: String) {
        project.name = name
        songLibrary.save(name, saveProjectText())
        touched()
    }

    /**
     * Discard the current song and start from a clean, empty project — the same
     * state as a fresh launch. Implemented by loading a freshly-serialized default
     * [Project] into the shared instance (reusing the loader's full reset), so every
     * field — patterns, arrangement, mixer routing, toolbox, tempo/scale — returns to
     * its default. There is no undo. Playback is stopped first so the audio thread
     * isn't reading the model as it's cleared.
     */
    fun newProject() {
        engine.stop()
        loadProjectText(space.rcmd.android.sizzle.io.ProjectIo.save(Project()))
        project.name = "untitled"
        engine.rebuildFxChains()   // all FX routing was cleared
    }

    /** Load a full project from storage (in place), restoring its sample audio. */
    fun loadSong(name: String): Boolean {
        val text = songLibrary.load(name) ?: return false
        loadProjectText(text)                 // full restore + cursor reset + revision bump
        project.name = name
        presetLibrary.rehydrate(project)      // reload the referenced sample audio
        engine.rebuildFxChains()              // mixer FX routing may have changed
        return true
    }

    /** Delete a stored full project. */
    fun deleteSong(name: String): Boolean {
        val ok = songLibrary.delete(name)
        if (ok) touched()
        return ok
    }

    /** Write the current project as a desktop-compatible `.sng` to a shareable file. */
    fun writeSngExport(): java.io.File =
        songLibrary.writeExport(project.name.ifBlank { "song" }, exportSng())

    // ------------------------------------------------------ instrument/effect presets
    /** Preset names available for a device type (for the editor's browser dropdown). */
    fun presetNames(type: space.rcmd.android.sizzle.model.ToolboxType): List<String> =
        presetLibrary.list(type)

    /** Save [slot] as a named preset in the library. Returns the preset file. */
    fun savePreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, name: String): java.io.File {
        val file = presetLibrary.save(slot, name)
        touched()
        return file
    }

    /** Load a named preset for [slot]'s current type into it. The slot is renamed to
     *  the preset so the toolbox tile and mixer inserts read as the loaded sound. */
    fun loadPreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, name: String): Boolean {
        val type = slot.type ?: return false
        val ok = presetLibrary.load(slot, type, name)
        if (ok) { slot.name = name; engine.rebuildFxChains(); touched() }
        return ok
    }

    /** Import a preset from picked bytes: a `.zip` bundle (Sampler) or plain text. */
    fun importPreset(slot: space.rcmd.android.sizzle.model.ToolboxSlot, bytes: ByteArray): Boolean {
        val isZip = bytes.size >= 2 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte()
        val ok = if (isZip) presetLibrary.importZip(slot, java.io.ByteArrayInputStream(bytes))
        else presetLibrary.importText(slot, String(bytes, Charsets.UTF_8))
        if (ok) { engine.rebuildFxChains(); touched() }
        return ok
    }

    /** A shareable file for a stored preset (a zip bundle for the Sampler). */
    fun presetShareFile(type: space.rcmd.android.sizzle.model.ToolboxType, name: String): java.io.File? =
        presetLibrary.bundleForShare(type, name)

    /** Delete a stored preset (and, for the Sampler, its bundled sample). */
    fun deletePreset(type: space.rcmd.android.sizzle.model.ToolboxType, name: String): Boolean {
        val ok = presetLibrary.delete(type, name)
        if (ok) touched()
        return ok
    }

    private fun nextOf(c: CellColumn, dir: Int): CellColumn {
        val values = CellColumn.entries
        return values[(c.ordinal + dir + values.size) % values.size]
    }
}
