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

package space.rcmd.android.sizzle.ui.settings

import android.content.Context
import android.content.Intent
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Piano
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import space.rcmd.android.sizzle.input.GamepadChord
import space.rcmd.android.sizzle.input.GamepadInput
import space.rcmd.android.sizzle.input.InputSource
import space.rcmd.android.sizzle.ui.AppViewModel

/**
 * The fourth tab, built from STANDARD Material 3 components (Cards, Buttons,
 * ListItems, OutlinedTextField, FilterChips) rather than the bespoke retro grid,
 * for familiar and accessible interaction. Functionality is unchanged:
 * project save/load, theme selection + export, gamepad bindings, and a
 * searchable / collapsible MIDI binding list.
 */
private enum class SettingsSection { MAIN, GAMEPAD, KEYBOARD, MIDI }

@Composable
fun SettingsScreen(vm: AppViewModel) {
    @Suppress("UNUSED_VARIABLE") val rev = vm.revision
    var section by remember { mutableStateOf(SettingsSection.MAIN) }

    // Back returns from a binding subsection to the Settings home before the
    // app-level handler moves to the previous tab.
    BackHandler(enabled = section != SettingsSection.MAIN) { section = SettingsSection.MAIN }

    when (section) {
        // Top level stays light: the heavy binding lists live in their own
        // subsections, so they only compose when the user actually opens them.
        SettingsSection.MAIN -> LazyColumn(
            Modifier.fillMaxSize().padding(12.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            item { PanicButton(vm) }
            item { ProjectCard(vm) }
            item { AudioDevicesCard(vm) }
            item { ThemeCard(vm) }
            item {
                NavCard("Gamepad Bindings", "Rebind buttons & combos", Icons.Filled.SportsEsports) {
                    section = SettingsSection.GAMEPAD
                }
            }
            item {
                NavCard("Keyboard Hotkeys", "Rebind keyboard keys", Icons.Filled.Keyboard) {
                    section = SettingsSection.KEYBOARD
                }
            }
            item {
                NavCard("MIDI Bindings", "MIDI-learn controls", Icons.Filled.Piano) {
                    section = SettingsSection.MIDI
                }
            }
        }

        SettingsSection.GAMEPAD -> LazyColumn(
            Modifier.fillMaxSize().padding(12.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
        ) {
            item { SubHeader("Gamepad Bindings") { section = SettingsSection.MAIN } }
            item {
                Text(
                    "Tap Learn, then press a control — or hold one and press another to " +
                        "bind a combo (e.g. A + Up). Buttons, the D-pad and the analog " +
                        "sticks can all be used, alone or as the modifier. A control used " +
                        "only in combos can have its own binding cleared to make it a pure modifier.",
                    style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            items(BIND_MODULES.values.flatten()) { action ->
                BindingRow(vm, action, InputSource.GAMEPAD)
                HorizontalDivider()
            }
        }

        SettingsSection.KEYBOARD -> LazyColumn(
            Modifier.fillMaxSize().padding(12.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
        ) {
            item { SubHeader("Keyboard Hotkeys") { section = SettingsSection.MAIN } }
            item {
                Text(
                    "Tap Learn, then press a key to bind it. The two-octave typing " +
                        "piano (Z–M, Q–U) is fixed and unaffected.",
                    style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            }
            items(KEYBOARD_MODULES.values.flatten()) { action ->
                BindingRow(vm, action, InputSource.KEYBOARD)
                HorizontalDivider()
            }
        }

        SettingsSection.MIDI -> LazyColumn(
            Modifier.fillMaxSize().padding(12.dp),
            verticalArrangement = Arrangement.spacedBy(2.dp),
        ) {
            item { SubHeader("MIDI Bindings") { section = SettingsSection.MAIN } }
            item { MidiBindingSections(vm) }
        }
    }
}

/** Header row with a back arrow for a settings subsection. */
@Composable
private fun SubHeader(title: String, onBack: () -> Unit) {
    Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
        IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") }
        Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
    }
}

/** A tappable card that navigates into a settings subsection. */
@Composable
private fun NavCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
    ElevatedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
        ListItem(
            leadingContent = { Icon(icon, null) },
            headlineContent = { Text(title) },
            supportingContent = { Text(subtitle) },
            trailingContent = { Icon(Icons.Filled.ChevronRight, null) },
        )
    }
}

// ---------------------------------------------------------------- top-level cards

/** Prominent, always-at-the-top MIDI panic: immediately silences every sounding
 *  or stuck note (sequencer, live, audition). */
@Composable
private fun PanicButton(vm: AppViewModel) {
    Button(
        onClick = { vm.midiPanic() },
        modifier = Modifier.fillMaxWidth(),
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.error,
            contentColor = MaterialTheme.colorScheme.onError,
        ),
    ) {
        Text("MIDI PANIC — ALL NOTES OFF")
    }
}

@Composable
private fun ProjectCard(vm: AppViewModel) {
    val context = LocalContext.current
    @Suppress("UNUSED_VARIABLE") val rev = vm.revision   // refresh the list after save/delete
    val songs = vm.songNames()

    var selectedName by remember { mutableStateOf<String?>(null) }
    // Show this session's explicit pick, else the loaded project (its name survives
    // restarts via the autosave), else the first saved song.
    val current = selectedName?.takeIf { it in songs }
        ?: vm.songName.takeIf { it in songs }
        ?: songs.firstOrNull()
    var menuOpen by remember { mutableStateOf(false) }
    var showSave by remember { mutableStateOf(false) }
    var confirmDelete by remember { mutableStateOf<String?>(null) }
    var confirmNew by remember { mutableStateOf(false) }
    var importError by remember { mutableStateOf<String?>(null) }

    // Import a .sng (desktop interchange) into the current project. Read/parse
    // failures surface as a popup instead of doing nothing.
    val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
        if (uri == null) return@rememberLauncherForActivityResult
        val bytes = runCatching {
            context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
        }.getOrNull()
        when {
            bytes == null -> importError = "Couldn't read the selected file."
            !vm.importSng(String(bytes, Charsets.UTF_8)) -> importError =
                "Couldn't import this file. It doesn't look like a valid .sng song."
        }
    }

    SettingsCard("Project", subtitle = "Full projects saved on this device; .sng for desktop interchange") {
        // On-device full-project browser: the load dropdown sits ABOVE the action
        // buttons (save / new / delete) so the selector is clear of them.
        Box {
            OutlinedButton(onClick = { menuOpen = true }) { Text(current ?: "No projects", maxLines = 1) }
            DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
                if (songs.isEmpty()) {
                    DropdownMenuItem(text = { Text("No saved projects") }, onClick = { menuOpen = false }, enabled = false)
                } else {
                    songs.forEach { s ->
                        DropdownMenuItem(text = { Text(s) }, onClick = {
                            vm.loadSong(s); selectedName = s; menuOpen = false
                        })
                    }
                }
            }
        }
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            Button(onClick = { showSave = true }) { Text("Save") }
            OutlinedButton(onClick = { confirmNew = true }) { Text("New") }
            if (current != null) {
                Button(
                    onClick = { confirmDelete = current },
                    colors = ButtonDefaults.buttonColors(
                        containerColor = MaterialTheme.colorScheme.error,
                        contentColor = MaterialTheme.colorScheme.onError,
                    ),
                ) { Text("Delete") }
            }
        }
        Text(
            "Full projects keep mixer, FX, instruments and sample assignments.",
            style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
        )

        HorizontalDivider()
        // Desktop interchange: .sng is only exported / imported, never the library.
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            OutlinedButton(onClick = { shareSongFile(context, vm.writeSngExport()) }) {
                Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
                Text("Export .sng")
            }
            OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
                Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
                Text("Import .sng")
            }
        }
        Text(
            ".sng carries notes/blocks/arrangement only (desktop-compatible).",
            style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }

    if (showSave) {
        var name by remember { mutableStateOf(vm.songName.ifBlank { "untitled" }) }
        AlertDialog(
            onDismissRequest = { showSave = false },
            title = { Text("Save project") },
            text = {
                OutlinedTextField(
                    value = name, onValueChange = { name = it }, singleLine = true,
                    label = { Text("Name") },
                )
            },
            confirmButton = {
                TextButton(onClick = {
                    if (name.isNotBlank()) { vm.saveSong(name.trim()); selectedName = name.trim() }
                    showSave = false
                }) { Text("Save") }
            },
            dismissButton = { TextButton(onClick = { showSave = false }) { Text("Cancel") } },
        )
    }

    importError?.let { msg ->
        AlertDialog(
            onDismissRequest = { importError = null },
            title = { Text("Import failed") },
            text = { Text(msg) },
            confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
        )
    }

    if (confirmNew) {
        AlertDialog(
            onDismissRequest = { confirmNew = false },
            title = { Text("New project") },
            text = { Text("Start a clean, empty project? Any unsaved changes to the current song will be lost.") },
            confirmButton = {
                TextButton(onClick = {
                    vm.newProject(); selectedName = null; confirmNew = false
                }) { Text("New project") }
            },
            dismissButton = { TextButton(onClick = { confirmNew = false }) { Text("Cancel") } },
        )
    }

    confirmDelete?.let { name ->
        AlertDialog(
            onDismissRequest = { confirmDelete = null },
            title = { Text("Delete project") },
            text = { Text("Delete \"$name\"? This cannot be undone.") },
            confirmButton = {
                TextButton(onClick = {
                    vm.deleteSong(name); selectedName = null; confirmDelete = null
                }) { Text("Delete") }
            },
            dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
        )
    }
}

/** Launch the system share sheet for an exported .sng file. */
private fun shareSongFile(context: Context, file: java.io.File) {
    val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
    val send = Intent(Intent.ACTION_SEND).apply {
        type = "text/plain"
        putExtra(Intent.EXTRA_STREAM, uri)
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    context.startActivity(Intent.createChooser(send, "Share song"))
}

@Composable
private fun AudioDevicesCard(vm: AppViewModel) {
    SettingsCard("Audio Devices", subtitle = "Route playback / recording to USB or built-in") {
        val context = LocalContext.current
        val am = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
        @Suppress("UNUSED_VARIABLE") val rev = vm.revision
        val outputs = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
        val inputs = am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
        DeviceRow("Output", outputs, vm.outputDeviceId) { vm.setAudioOutput(it) }
        HorizontalDivider()
        DeviceRow("Input", inputs, vm.inputDeviceId) { vm.setAudioInput(it) }
    }
}

@Composable
private fun ThemeCard(vm: AppViewModel) {
    val context = LocalContext.current
    @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh the list after import/delete
    val themes = vm.themeNames()
    val current = vm.palette.name
    var menuOpen by remember { mutableStateOf(false) }
    var confirmDelete by remember { mutableStateOf<String?>(null) }
    var importError by remember { mutableStateOf<String?>(null) }

    // Import a .szt theme (adds it to the library and applies it).
    val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
        if (uri == null) return@rememberLauncherForActivityResult
        val bytes = runCatching {
            context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
        }.getOrNull()
        when {
            bytes == null -> importError = "Couldn't read the selected file."
            !vm.importTheme(bytes) -> importError =
                "Couldn't import this file. It doesn't look like a valid .szt theme."
        }
    }

    SettingsCard("Color Theme", subtitle = "Pick, import, export or delete a palette") {
        // Selector dropdown + delete, mirroring the Project card.
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
            Box {
                OutlinedButton(onClick = { menuOpen = true }) { Text(current, maxLines = 1) }
                DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
                    themes.forEach { name ->
                        DropdownMenuItem(
                            text = { Text(name + if (vm.isStockTheme(name)) "  (stock)" else "") },
                            onClick = { vm.selectThemeByName(name); menuOpen = false },
                        )
                    }
                }
            }
            // Delete only offered for user themes — stock themes are protected.
            if (!vm.isStockTheme(current)) {
                Button(
                    onClick = { confirmDelete = current },
                    colors = ButtonDefaults.buttonColors(
                        containerColor = MaterialTheme.colorScheme.error,
                        contentColor = MaterialTheme.colorScheme.onError,
                    ),
                ) { Text("Delete") }
            }
        }

        HorizontalDivider()
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            OutlinedButton(onClick = { shareThemeFile(context, vm.themeExportFile()) }) {
                Icon(Icons.Filled.ContentCopy, null, Modifier.padding(end = 6.dp))
                Text("Export .szt")
            }
            OutlinedButton(onClick = { importer.launch(arrayOf("*/*")) }) {
                Icon(Icons.Filled.ContentPaste, null, Modifier.padding(end = 6.dp))
                Text("Import .szt")
            }
        }
        Text(
            "Stock themes can't be deleted; imported .szt themes appear in the list.",
            style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }

    importError?.let { msg ->
        AlertDialog(
            onDismissRequest = { importError = null },
            title = { Text("Import failed") },
            text = { Text(msg) },
            confirmButton = { TextButton(onClick = { importError = null }) { Text("OK") } },
        )
    }

    confirmDelete?.let { name ->
        AlertDialog(
            onDismissRequest = { confirmDelete = null },
            title = { Text("Delete theme") },
            text = { Text("Delete \"$name\"? This cannot be undone.") },
            confirmButton = {
                TextButton(onClick = { vm.deleteTheme(name); confirmDelete = null }) { Text("Delete") }
            },
            dismissButton = { TextButton(onClick = { confirmDelete = null }) { Text("Cancel") } },
        )
    }
}

/** Launch the system share sheet for an exported .szt theme file. */
private fun shareThemeFile(context: Context, file: java.io.File) {
    val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
    val send = Intent(Intent.ACTION_SEND).apply {
        type = "text/plain"
        putExtra(Intent.EXTRA_STREAM, uri)
        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    context.startActivity(Intent.createChooser(send, "Share theme"))
}

/** A bindable action: user-facing label + the action id the input handlers use. */
private data class BindAction(val label: String, val id: String)

private val BIND_MODULES: Map<String, List<BindAction>> = linkedMapOf(
    "Transport" to listOf(
        BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
    ),
    "Navigation" to listOf(
        BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
        BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
    ),
    "Editing" to listOf(
        BindAction("Value +", "increment"), BindAction("Value -", "decrement"), BindAction("Clear Cell", "clear"),
    ),
    "Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
)

/** The keyboard supports the shared actions plus a few keyboard-only ones
 *  (column movement, note-off, and octave shifting of the typing piano). */
private val KEYBOARD_MODULES: Map<String, List<BindAction>> = linkedMapOf(
    "Transport" to listOf(
        BindAction("Play / Pause", "playPause"), BindAction("Stop", "stop"), BindAction("Toggle Loop", "loop"),
    ),
    "Navigation" to listOf(
        BindAction("Cursor Up", "up"), BindAction("Cursor Down", "down"),
        BindAction("Cursor Left", "left"), BindAction("Cursor Right", "right"),
    ),
    "Editing" to listOf(
        BindAction("Value +", "increment"), BindAction("Value -", "decrement"),
        BindAction("Clear Cell", "clear"), BindAction("Note Off", "noteOff"),
    ),
    "Columns" to listOf(
        BindAction("Next Column", "nextColumn"), BindAction("Prev Column", "prevColumn"),
    ),
    "Octave" to listOf(
        BindAction("Octave Down", "octaveDown"), BindAction("Octave Up", "octaveUp"),
    ),
    "Tabs" to listOf(BindAction("Next Tab", "nextTab"), BindAction("Prev Tab", "prevTab")),
)

/** One action row with its current binding, a Learn toggle, and a clear button. */
@Composable
private fun BindingRow(vm: AppViewModel, action: BindAction, source: InputSource) {
    @Suppress("UNUSED_VARIABLE") val rev = vm.revision // refresh when a binding changes
    val listening = vm.learnTarget == action.id
    val label: String
    val bound: Boolean
    when (source) {
        InputSource.MIDI -> {
            val code = vm.midiBindingFor(action.id)
            label = code?.let { "CC $it" } ?: "unbound"; bound = code != null
        }
        InputSource.GAMEPAD -> {
            val chord = vm.gamepadBindingFor(action.id)
            label = chord?.let { gamepadChordLabel(it) } ?: "unbound"; bound = chord != null
        }
        else -> {
            val code = vm.keyboardBindingFor(action.id)
            label = code?.let { keyLabel(it) } ?: "unbound"; bound = code != null
        }
    }

    ListItem(
        headlineContent = { Text(action.label) },
        supportingContent = { Text(label, style = MaterialTheme.typography.bodySmall) },
        trailingContent = {
            Row(verticalAlignment = Alignment.CenterVertically) {
                TextButton(onClick = { if (listening) vm.cancelLearn() else vm.beginLearn(action.id, source) }) {
                    Text(if (listening) "Listening…" else "Learn")
                }
                if (bound) {
                    TextButton(onClick = { vm.clearBinding(action.id, source) }) { Text("✕") }
                }
            }
        },
        colors = ListItemDefaults.colors(containerColor = Color.Transparent),
    )
}

/** Human-readable name for a physical keyboard key ("PAGE UP", "TAB", "L"). */
private fun keyLabel(code: Int): String =
    KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_").replace('_', ' ')

/** Short gamepad control name ("A", "L1", "UP", "LS↑") for a button/D-pad/stick code. */
private fun gamepadButtonLabel(code: Int): String = GamepadInput.controlLabel(code)

/** "A + UP" for a chord, or just "A" for a plain button binding. */
private fun gamepadChordLabel(chord: GamepadChord): String =
    if (chord.modifier == GamepadChord.NONE) gamepadButtonLabel(chord.code)
    else "${gamepadButtonLabel(chord.modifier)} + ${gamepadButtonLabel(chord.code)}"

/** A label + dropdown that routes audio to a chosen device (or the default). */
@Composable
private fun DeviceRow(
    label: String,
    devices: List<AudioDeviceInfo>,
    selectedId: Int?,
    onSelect: (AudioDeviceInfo?) -> Unit,
) {
    var open by remember { mutableStateOf(false) }
    val current = devices.firstOrNull { it.id == selectedId }?.let { deviceLabel(it) } ?: "Default"
    Row(
        Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text(label, style = MaterialTheme.typography.bodyMedium)
        Box {
            OutlinedButton(onClick = { open = true }) { Text(current, maxLines = 1) }
            DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
                DropdownMenuItem(text = { Text("Default") }, onClick = { onSelect(null); open = false })
                devices.forEach { d ->
                    DropdownMenuItem(text = { Text(deviceLabel(d)) }, onClick = { onSelect(d); open = false })
                }
            }
        }
    }
}

private fun deviceLabel(d: AudioDeviceInfo): String {
    val kind = when (d.type) {
        AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB"
        AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
        AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Mic"
        AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
        AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BT"
        else -> "Dev"
    }
    return "${d.productName} · $kind"
}

/** A titled Material card wrapper used for every settings group. */
@Composable
private fun SettingsCard(
    title: String,
    subtitle: String? = null,
    content: @Composable () -> Unit,
) {
    ElevatedCard(Modifier.fillMaxWidth()) {
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
            Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
            subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
            content()
        }
    }
}

/**
 * Searchable + collapsible MIDI binding list. Each module is a Card whose header
 * toggles expansion; its actions are real [BindingRow]s whose Learn button arms
 * MIDI-learn — the next incoming CC is stored into the live MIDI binding map.
 */
@Composable
private fun MidiBindingSections(vm: AppViewModel) {
    var query by remember { mutableStateOf("") }
    var expanded by remember { mutableStateOf<String?>("Transport") }

    OutlinedTextField(
        value = query,
        onValueChange = { query = it },
        singleLine = true,
        leadingIcon = { Icon(Icons.Filled.Search, null) },
        placeholder = { Text("Search controls…") },
        modifier = Modifier.fillMaxWidth(),
    )

    BIND_MODULES.forEach { (module, actions) ->
        val matches = actions.filter { it.label.contains(query, ignoreCase = true) }
        if (query.isNotEmpty() && matches.isEmpty()) return@forEach
        val isOpen = expanded == module || query.isNotEmpty()

        Card(Modifier.fillMaxWidth().padding(top = 8.dp)) {
            Row(
                Modifier
                    .fillMaxWidth()
                    .clickable { expanded = if (expanded == module) null else module }
                    .padding(horizontal = 16.dp, vertical = 12.dp),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                Text(module, style = MaterialTheme.typography.titleSmall)
                Icon(if (isOpen) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null)
            }
            if (isOpen) {
                matches.forEach { action -> BindingRow(vm, action, InputSource.MIDI) }
            }
        }
    }
}
