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

package space.rcmd.android.sizzle.model

/**
 * The third tab is a toolbox of 16 slots. Every slot can hold one instrument OR
 * one effect. Rather than a separate class per device (which would be a lot of
 * near-identical code for a newcomer to wade through), we describe each device
 * type with [ToolboxType] and store its settings as a simple
 * name -> value map. That map is exactly what we serialize to a human-readable
 * preset, and it is what the audio engine reads at render time.
 *
 * When you add a new instrument or effect you only need to:
 *   1. add an entry to [ToolboxType] with its default parameters, and
 *   2. teach the audio engine how to interpret those parameters.
 * The UI (a generic parameter editor) and preset save/load work automatically.
 */

enum class ToolboxKind { INSTRUMENT, EFFECT }

/**
 * A single tunable parameter's description: its key, a friendly label, the value
 * range and a default. [choices] being non-empty marks an enumerated parameter
 * (rendered as a dropdown, e.g. a waveform selector) instead of a slider.
 */
data class ParamSpec(
    val key: String,
    val label: String,
    val default: Float = 0f,
    val min: Float = 0f,
    val max: Float = 1f,
    val choices: List<String> = emptyList(),
    /** When true the slider snaps to whole integers (e.g. bit depth, semitones). */
    val isInt: Boolean = false,
) {
    val isEnum: Boolean get() = choices.isNotEmpty()
}

enum class ToolboxType(
    val kind: ToolboxKind,
    val displayName: String,
    val params: List<ParamSpec>,
) {
    // ---------- Instruments ----------
    // A classic 3-oscillator subtractive synth (Minimoog-style): oscillators →
    // mixer → resonant filter (own contour) → amp (own envelope), with a tempo-synced
    // LFO and a small mod matrix. Rendered by [SynthVoice]; edited by NesSynthEditor.
    // NOTE: the enum name NES_SYNTH is the on-disk/serialized identifier (preset dirs,
    // .sng type) and must stay stable for backward compatibility; only the user-facing
    // displayName is generic.
    NES_SYNTH(
        ToolboxKind.INSTRUMENT, "Synth",
        buildList {
            for (i in 0 until SynthDefs.OSC_COUNT) {
                add(ParamSpec("osc${i}wave", "Osc${i + 1} Wave", choices = SynthDefs.WAVES))
                add(ParamSpec("osc${i}oct", "Osc${i + 1} Oct", 0f, -2f, 2f, isInt = true))
                add(ParamSpec("osc${i}semi", "Osc${i + 1} Semi", 0f, -12f, 12f, isInt = true))
                add(ParamSpec("osc${i}fine", "Osc${i + 1} Fine", 0f, -50f, 50f))
                add(ParamSpec("osc${i}level", "Osc${i + 1} Level", SynthDefs.DEF_OSC_LEVEL[i], 0f, 1f))
            }
            add(ParamSpec("noise", "Noise", 0f, 0f, 1f))
            // Filter + its contour (envelope) amount and key tracking.
            add(ParamSpec("cutoff", "Cutoff", 0.55f, 0f, 1f))
            add(ParamSpec("reso", "Resonance", 0.15f, 0f, 1f))
            add(ParamSpec("keytrack", "Key Track", 0.3f, 0f, 1f))
            add(ParamSpec("fenvamt", "Filter Env", 0.4f, -1f, 1f))
            add(ParamSpec("fatk", "F.Attack", 0.005f, 0f, 2f))
            add(ParamSpec("fdec", "F.Decay", 0.25f, 0f, 2f))
            add(ParamSpec("fsus", "F.Sustain", 0.4f, 0f, 1f))
            add(ParamSpec("frel", "F.Release", 0.2f, 0f, 3f))
            // Amplifier envelope.
            add(ParamSpec("attack", "Attack", 0.005f, 0f, 2f))
            add(ParamSpec("decay", "Decay", 0.2f, 0f, 2f))
            add(ParamSpec("sustain", "Sustain", 0.7f, 0f, 1f))
            add(ParamSpec("release", "Release", 0.2f, 0f, 3f))
            // Tempo-synced LFO (rate is an index into [TimeDivision]).
            add(ParamSpec("lfowave", "LFO Wave", choices = SynthDefs.LFO_WAVES))
            add(ParamSpec("lfodiv", "LFO Rate", 3f, 0f, 6f, isInt = true))
            // Modulation matrix.
            for (n in 0 until SynthDefs.MOD_SLOTS) {
                add(ParamSpec("m${n}src", "Mod${n + 1} Src", choices = SynthDefs.MOD_SRC))
                add(ParamSpec("m${n}dst", "Mod${n + 1} Dst", choices = SynthDefs.MOD_DST))
                add(ParamSpec("m${n}amt", "Mod${n + 1} Amt", 0f, -1f, 1f))
            }
        },
    ),
    SAMPLER(
        ToolboxKind.INSTRUMENT, "Sampler",
        listOf(
            ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
            // A 16-pad bank: each pad maps to a consecutive note from C2 (see
            // [SamplerPads]) and stores its own sample id + slice as string params
            // ("pad0", "pad0S", "pad0E", …). Edited by ui/toolbox/SamplerEditor.
        ),
    ),
    SOUNDFONT(
        ToolboxKind.INSTRUMENT, "SF2 / XI Loader",
        listOf(
            ParamSpec("program", "Program", 0f, 0f, 127f, isInt = true),
            ParamSpec("volume", "Volume", 0.8f, 0f, 1f),
            // Volume ADSR envelope (seconds, sustain 0..1).
            ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
            ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
            ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
            ParamSpec("release", "Release", 0.20f, 0f, 3f),
            // Sustain-loop the sample while a note is held (uses the file's loop
            // points when present, else loops the whole sample). Ends on note-off.
            ParamSpec("loop", "Loop", choices = listOf("Off", "On")),
            // Loop crossfade time in milliseconds — smooths the loop seam.
            ParamSpec("loopXfade", "Loop XFade", 0f, 0f, 500f),
        ),
    ),

    // ---------- Effects ----------
    ARPEGGIATOR(
        ToolboxKind.EFFECT, "MIDI Arpeggiator",
        listOf(
            ParamSpec("mode", "Mode", choices = listOf("Up", "Down", "UpDown", "Random")),
            ParamSpec("octaves", "Octaves", 1f, 1f, 4f, isInt = true),
            ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true), // index into TimeDivision
            // When On, the arp restarts from step 0 on every bar downbeat so its
            // octave pattern stays locked to the bar (otherwise it free-runs).
            ParamSpec("resetbar", "Reset/Bar", choices = listOf("Off", "On")),
        ),
    ),
    TRANSPOSER(
        ToolboxKind.EFFECT, "MIDI Transposer",
        listOf(
            ParamSpec("octaves", "Octaves", 0f, -4f, 4f, isInt = true),
            ParamSpec("semitones", "Semitones", 0f, -24f, 24f, isInt = true),
        ),
    ),
    LFO(
        ToolboxKind.EFFECT, "MIDI LFO",
        listOf(
            ParamSpec("wave", "Wave", choices = listOf("Sine", "Triangle", "Saw", "Square", "SampleHold")),
            ParamSpec("phase", "Phase", 0f, 0f, 1f),
            ParamSpec("depth", "Depth", 0.5f, 0f, 1f),
            ParamSpec("division", "Division", 3f, 0f, 6f, isInt = true),
            // "target" (which parameter to modulate) is stored as a string param.
        ),
    ),
    DELAY(
        ToolboxKind.EFFECT, "Tape Delay",
        // Four independent tape heads, each with its own tempo-synced division and an
        // on/off flag, plus the common feedback / dry-wet / tape-strength controls.
        // Feedback tops out below 1 (no runaway). Edited by ui/toolbox/DelayEditor.
        buildList {
            add(ParamSpec("feedback", "Feedback", 0.4f, 0f, DelayDivisions.MAX_FEEDBACK))
            add(ParamSpec("drywet", "Dry/Wet", 0.35f, 0f, 1f))
            add(ParamSpec("tape", "Tape Strength", 0.3f, 0f, 1f))
            val headDefaults = intArrayOf(
                DelayDivisions.indexOf("1/4"), DelayDivisions.indexOf("1/8"),
                DelayDivisions.indexOf("1/4."), DelayDivisions.indexOf("1/2"),
            )
            for (h in 0 until DelayDivisions.HEADS) {
                add(ParamSpec("head${h}On", "Head ${h + 1} On", if (h == 0) 1f else 0f, 0f, 1f))
                add(ParamSpec("head${h}Div", "Head ${h + 1} Div",
                    headDefaults[h].toFloat(), 0f, (DelayDivisions.size - 1).toFloat()))
            }
        },
    ),
    FILTER(
        ToolboxKind.EFFECT, "Filter (LP/HP/BP)",
        listOf(
            ParamSpec("type", "Type", choices = listOf("LPF", "HPF", "BPF")),
            ParamSpec("cutoff", "Cutoff", 0.7f, 0f, 1f),
            ParamSpec("resonance", "Resonance", 0.2f, 0f, 1f),
            // Analog-style saturation added in the filter path.
            ParamSpec("warmth", "Warmth", 0f, 0f, 1f),
            // Cutoff envelope: how far the ADSR opens the filter above the base
            // cutoff. 0 = envelope off (the filter behaves as a plain filter). The
            // envelope is (re)triggered by signal activity arriving on the channel.
            ParamSpec("envamt", "Env Amount", 0f, 0f, 1f),
            ParamSpec("attack", "Attack", 0.005f, 0f, 2f),
            ParamSpec("decay", "Decay", 0.10f, 0f, 2f),
            ParamSpec("sustain", "Sustain", 1f, 0f, 1f),
            ParamSpec("release", "Release", 0.20f, 0f, 3f),
        ),
    ),
    EQ10(
        ToolboxKind.EFFECT, "10-Band EQ",
        // 10 gain bands, each -1..+1 mapping to roughly -12..+12 dB.
        (0 until 10).map { ParamSpec("band$it", "Band ${it + 1}", 0f, -1f, 1f) },
    ),
    BITCRUSHER(
        ToolboxKind.EFFECT, "Bitcrusher",
        listOf(
            ParamSpec("bits", "Bits", 8f, 1f, 16f, isInt = true),
            ParamSpec("downsample", "Downsample", 1f, 1f, 32f, isInt = true),
            ParamSpec("drive", "Drive", 0.2f, 0f, 1f),
        ),
    ),
    AMBIENCE(
        ToolboxKind.EFFECT, "Ambience Reverb",
        // A mono port of the Ambience 16-channel FDN reverb (OTODESK): 7 algorithms,
        // tempo-independent tunables and 21 factory spaces (seeded into the preset
        // library). Rendered by audio/AmbienceReverb.kt.
        listOf(
            ParamSpec("algo", "Algorithm", 0f, 0f, 6f, AmbiencePresets.ALGORITHMS),
            ParamSpec("predelay", "Pre-Delay", 10f, 0f, 500f),
            ParamSpec("roomsize", "Room Size", 1f, 0.3f, 2f),
            ParamSpec("decay", "Decay", 1.5f, 0.1f, 20f),
            ParamSpec("hfdamp", "HF Damp", 0f, 0f, 1f),
            ParamSpec("lfabsorb", "LF Absorb", 0f, 0f, 1f),
            ParamSpec("diffusion", "Diffusion", 0.7f, 0f, 1f),
            ParamSpec("modamt", "Mod Amount", 0.25f, 0f, 1f),
            ParamSpec("modrate", "Mod Rate", 0.5f, 0.05f, 2f),
            ParamSpec("erlevel", "ER Level", 0.6f, 0f, 1f),
            ParamSpec("saturation", "Saturation", 0f, 0f, 1f),
            ParamSpec("wet", "Wet (dB)", -4f, -60f, 0f),
            ParamSpec("dry", "Dry (dB)", 0f, -60f, 0f),
            ParamSpec("duckamt", "Ducking (dB)", 0f, 0f, 20f),
            ParamSpec("duckthresh", "Duck Thresh (dB)", -20f, -60f, 0f),
            ParamSpec("duckattack", "Duck Attack (ms)", 10f, 0.5f, 100f),
            ParamSpec("duckrelease", "Duck Release (ms)", 200f, 10f, 2000f),
        ),
    );

    val isInstrument: Boolean get() = kind == ToolboxKind.INSTRUMENT

    /** Fresh default parameter map for a new instance of this type. */
    fun defaultValues(): MutableMap<String, String> =
        params.associate { spec ->
            spec.key to if (spec.isEnum) spec.choices[spec.default.toInt().coerceIn(0, spec.choices.lastIndex)]
            else spec.default.toString()
        }.toMutableMap()
}

/**
 * The Sampler's 16-pad bank. Pads map to consecutive MIDI notes starting at C2
 * ([BASE_NOTE] = 36), so pad 0 = C2 … pad 15 = D#3. Each pad stores, in the slot's
 * value map, a sample id under [key] and its trim under [sliceStartKey]/[sliceEndKey].
 * Triggering a pad's note plays that sample at its natural pitch (one-shot).
 */
object SamplerPads {
    const val COUNT = 16
    const val BASE_NOTE = 36 // C2 (this app uses MIDI 60 == C4)
    const val DEFAULT_VOLUME = 0.8f

    fun key(pad: Int): String = "pad$pad"
    fun sliceStartKey(pad: Int): String = "pad${pad}S"
    fun sliceEndKey(pad: Int): String = "pad${pad}E"
    fun volumeKey(pad: Int): String = "pad${pad}V"

    /** The MIDI note a pad triggers, and the inverse. */
    fun noteFor(pad: Int): Int = BASE_NOTE + pad
    fun padFor(note: Int): Int = note - BASE_NOTE
}

/**
 * The tempo-synced note values a Tape Delay head can be set to: straight, dotted
 * (·, ×1.5) and triplet (T, ×2/3) subdivisions from 1/1 down to 1/16, ordered
 * longest → shortest. Each head stores its choice as an index into [entries], which
 * serializes as a plain integer. [beatFraction] is measured in quarter-note beats
 * (1/4 == 1.0 beat), matching [TimeDivision].
 */
object DelayDivisions {
    const val HEADS = 4
    const val MAX_FEEDBACK = 0.9f   // hard ceiling < 1 so feedback can never run away

    /** (label, beat-fraction), longest first. */
    val entries: List<Pair<String, Double>> = listOf(
        "1/1" to 4.0,
        "1/2." to 3.0,
        "1/2" to 2.0,
        "1/4." to 1.5,
        "1/2T" to 4.0 / 3.0,
        "1/4" to 1.0,
        "1/8." to 0.75,
        "1/4T" to 2.0 / 3.0,
        "1/8" to 0.5,
        "1/16." to 0.375,
        "1/8T" to 1.0 / 3.0,
        "1/16" to 0.25,
        "1/16T" to 1.0 / 6.0,
    )

    val size: Int get() = entries.size
    fun label(i: Int): String = entries[i.coerceIn(0, entries.lastIndex)].first
    fun beatFraction(i: Int): Double = entries[i.coerceIn(0, entries.lastIndex)].second

    /** Plain loop (no lambda/iterator) — this is read on the audio thread. */
    fun indexOf(label: String): Int {
        for (i in entries.indices) if (entries[i].first == label) return i
        return 0
    }

    /** Default division index (a quarter note). A cached `val`, not a getter, so
     *  reading it on the audio thread (as a fallback arg) allocates nothing. */
    val DEFAULT: Int = indexOf("1/4")
}

/** Tempo-synced note lengths used by arpeggiator / LFO "division" params. */
enum class TimeDivision(val label: String, val beatFraction: Double) {
    WHOLE("1/1", 4.0), HALF("1/2", 2.0), QUARTER("1/4", 1.0),
    EIGHTH("1/8", 0.5), SIXTEENTH("1/16", 0.25),
    THIRTYSECOND("1/32", 0.125), DOTTED_EIGHTH("1/8.", 0.75);

    companion object {
        fun fromIndex(i: Int): TimeDivision = entries[i.coerceIn(0, entries.lastIndex)]
    }
}

/**
 * One occupied (or empty) toolbox slot. [type] == null means the slot is empty.
 * [values] holds every parameter as a string so the same structure serializes
 * cleanly to text and round-trips without loss. String-only params such as a
 * sample file path live in the same map under keys the engine knows about
 * (e.g. "samplePath", "target").
 *
 * (Serialized via [space.rcmd.android.sizzle.io.PresetIo], not
 * kotlinx, so no @Serializable annotation is needed here.)
 */
class ToolboxSlot(
    val index: Int,
    var type: ToolboxType? = null,
    var name: String = "",
    // Concurrent: the audio thread READS params (per note / per block) while the UI
    // thread WRITES them (knob drags, preset loads). A plain HashMap can corrupt or
    // crash under that race; ConcurrentHashMap makes each get/put atomic and its
    // reads lock-free (RT-safe). Iteration is weakly consistent (fine for PresetIo).
    val values: MutableMap<String, String> = java.util.concurrent.ConcurrentHashMap(),
) {
    val isEmpty: Boolean get() = type == null

    fun fill(newType: ToolboxType) {
        type = newType
        name = newType.displayName
        values.clear()
        values.putAll(newType.defaultValues())
    }

    fun clearSlot() {
        type = null
        name = ""
        values.clear()
    }

    /**
     * Bumped on every parameter write. The audio engine reads it to skip re-parsing
     * a slot's params (and re-deriving effect state) every block — string parsing on
     * the audio thread allocates and, unchecked, drove GC-pause dropouts. Volatile so
     * a UI-thread edit is seen by the audio thread. */
    @Volatile
    var version: Int = 0
        private set

    // Typed accessors used by the audio engine and UI.
    // NOTE: `float` runs on the audio thread; `toFloatOrNull()` boxes a Float, so parse
    // to a primitive via try/catch instead (the happy path doesn't box; stored values
    // are valid). Callers should still avoid calling it every block — see [version].
    fun float(key: String, fallback: Float = 0f): Float {
        val s = values[key] ?: return fallback
        return try { s.toFloat() } catch (e: NumberFormatException) { fallback }
    }
    fun string(key: String, fallback: String = ""): String = values[key] ?: fallback
    fun set(key: String, value: Float) { values[key] = value.toString(); version++ }
    fun set(key: String, value: String) { values[key] = value; version++ }
}
