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

package space.rcmd.android.sizzle.audio

import kotlin.math.exp

/**
 * Plays back a decoded [SampleStore.Sample] as a pitched, one-shot voice for the
 * Sampler / SoundFont instruments. The note's distance from the root note sets the
 * playback rate (so a single sample is pitch-stretched across the keyboard), and
 * the slice start/end markers bound the region that plays.
 *
 * Amplitude is shaped by a linear **ADSR envelope** (attack → decay → sustain →
 * release), so a held note swells/sustains and a note-off releases it. The Sampler
 * pads pass one-shot defaults (near-instant attack, full sustain, quick release) so
 * they behave exactly as before; the SoundFont loader exposes the envelope as
 * user-tunable sliders. A separate short end-of-sample fade prevents a click when
 * the sample data runs out before the release finishes.
 *
 * Runs on the audio thread: [render] must not allocate.
 */
class SampleVoice(private val engineSampleRate: Int) {
    private var sample: SampleStore.Sample? = null
    private var position = 0.0     // fractional read index into sample.data
    private var rate = 1.0         // samples advanced per output sample
    private var startIndex = 0
    private var endIndex = 0
    private var amp = 0f
    var pitch = -1; private set

    // ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding,
    // until a note-off's release envelope ends the voice. Uses the file's loop points
    // when valid, else the whole played region.
    private var loopActive = false
    private var loopStartIdx = 0
    private var loopEndIdx = 0
    private var loopSpan = 0.0
    // Loop crossfade: [xfade] output samples of the loop tail are blended into its
    // head so the seam is click-free. The repeating span is then [loopEff] long and
    // wraps back to [loopBase] (= loopStartIdx + xfade). xfade == 0 → a plain wrap.
    private var xfade = 0.0
    private var loopBase = 0.0
    private var loopEff = 0.0

    // ---- ADSR envelope (linear ramps; levels 0..1) ----
    private enum class Phase { ATTACK, DECAY, SUSTAIN, RELEASE }
    private var phase = Phase.ATTACK
    private var env = 0f
    private var attackInc = 1f
    private var decayInc = 1f
    private var sustainLevel = 1f
    private var releaseSeconds = 0.02f
    private var releaseInc = 1f

    // ---- anti-click end-of-sample fade (independent of the ADSR) ----
    private var endGain = 1f

    // ---- declick: when a still-sounding voice is stolen/retriggered, its last
    // output is carried as a fast-decaying offset so the hand-off has no
    // discontinuity (an abrupt reset would zero a loud voice in one sample = click).
    private var lastY = 0f
    private var declick = 0f
    private val declickCoeff = exp(-1f / (engineSampleRate * 0.003f)) // ~3 ms decay

    val isActive: Boolean get() = sample != null

    /**
     * @param root MIDI note at which the sample plays at its natural pitch.
     * @param sliceStart/[sliceEnd] 0..1 fractions of the sample to play.
     * @param volume 0..1 instrument volume.
     * @param attack/[decay]/[release] envelope stage times in seconds.
     * @param sustain sustain level 0..1 held after the decay stage.
     */
    fun noteOn(
        s: SampleStore.Sample, midi: Int, velocity: Int, root: Int,
        sliceStart: Float, sliceEnd: Float, volume: Float,
        attack: Float = 0.002f, decay: Float = 0f, sustain: Float = 1f, release: Float = 0.02f,
        loop: Boolean = false, crossfadeMs: Float = 0f,
    ) {
        // Carry the level we're interrupting so the new note starts continuously
        // (the new note fades in from ~0; without this the sum would jump from the
        // old voice's level straight to ~0 → a click when stealing a loud voice).
        declick += lastY
        sample = s
        pitch = midi
        val len = s.data.size
        val a = sliceStart.coerceIn(0f, 1f)
        val b = sliceEnd.coerceIn(0f, 1f)
        startIndex = (minOf(a, b) * len).toInt().coerceIn(0, (len - 1).coerceAtLeast(0))
        endIndex = (maxOf(a, b) * len).toInt().coerceIn(startIndex + 1, len)
        position = startIndex.toDouble()

        // Loop region: prefer the file's loop points (clamped into the played slice);
        // otherwise loop the whole slice. Disabled if the resulting span is too short.
        if (loop) {
            val fileLs = s.loopStart
            val fileLe = s.loopEnd
            if (fileLe > fileLs && fileLs >= 0 && fileLe <= len) {
                loopStartIdx = fileLs.coerceIn(startIndex, endIndex - 1)
                loopEndIdx = fileLe.coerceIn(loopStartIdx + 1, endIndex)
            } else {
                loopStartIdx = startIndex
                loopEndIdx = endIndex
            }
            loopSpan = (loopEndIdx - loopStartIdx).toDouble()
            loopActive = loopSpan >= 2.0
            // Crossfade length in samples, kept under half the loop so the repeating
            // span stays positive; needs a loop of at least a few samples to be useful.
            val xf = if (loopActive) (crossfadeMs / 1000f * engineSampleRate).toDouble() else 0.0
            xfade = if (loopActive && loopSpan >= 4.0) xf.coerceIn(0.0, loopSpan / 2.0 - 1.0) else 0.0
            loopBase = loopStartIdx + xfade
            loopEff = loopSpan - xfade
        } else {
            loopActive = false
            xfade = 0.0
        }
        // Pitch ratio * sample-rate conversion.
        val semis = (midi - root) / 12.0
        rate = Math.pow(2.0, semis) * (s.sampleRate.toDouble() / engineSampleRate)
        amp = (velocity / 127f).coerceIn(0f, 1f) * volume

        // Envelope: enforce a tiny minimum attack so the onset never clicks.
        val sr = engineSampleRate.toFloat()
        sustainLevel = sustain.coerceIn(0f, 1f)
        attackInc = 1f / (maxOf(attack, MIN_ATTACK) * sr)
        decayInc = if (decay <= 0f) 1f else (1f - sustainLevel) / (decay * sr)
        releaseSeconds = release
        env = 0f
        phase = Phase.ATTACK
        endGain = 1f
    }

    /** Enter the release stage: fade from the current level to 0 over the release time. */
    fun noteOff() {
        val sr = engineSampleRate.toFloat()
        releaseInc = if (releaseSeconds <= 0f) 1f else env / (releaseSeconds * sr)
        if (releaseInc <= 0f) releaseInc = 1f
        phase = Phase.RELEASE
    }

    fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false }

    /** One sample of the decaying declick offset (used on the paths that stop the
     *  voice, so any carried level rings out smoothly instead of snapping to 0). */
    private fun declickTail(): Float {
        if (declick == 0f) { lastY = 0f; return 0f }
        val y = declick
        declick *= declickCoeff
        if (declick < 1e-5f && declick > -1e-5f) declick = 0f
        lastY = y
        return y
    }

    fun render(): Float {
        val s = sample ?: return declickTail() // idle → 0; ring out any declick tail
        val data = s.data

        if (loopActive) {
            // Wrap the (crossfaded) loop; the voice keeps looping — even through the
            // release stage — until the envelope reaches 0 below. Modulo (not a single
            // subtract) so a very high playback rate that steps past the region in one
            // sample still lands back inside it.
            if (position >= loopEndIdx) position = loopBase + (position - loopBase) % loopEff
            // Belt-and-braces bounds guard so a malformed loop can never read OOB.
            if (position >= data.size - 1 || position < loopStartIdx) position = loopBase
        } else {
            val i = position.toInt()
            if (i >= endIndex - 1 || i >= data.size - 1) { sample = null; pitch = -1; return declickTail() }
            // Anti-click fade as the slice end approaches (in OUTPUT samples, so it
            // holds regardless of playback rate).
            if ((endIndex - i) / rate <= FADE_SAMPLES) {
                endGain = (endGain - FADE_STEP).coerceAtLeast(0f)
                if (endGain <= 0f) { sample = null; pitch = -1; return declickTail() }
            }
        }

        // Advance the ADSR envelope one output sample.
        when (phase) {
            Phase.ATTACK -> { env += attackInc; if (env >= 1f) { env = 1f; phase = Phase.DECAY } }
            Phase.DECAY -> { env -= decayInc; if (env <= sustainLevel) { env = sustainLevel; phase = Phase.SUSTAIN } }
            Phase.SUSTAIN -> {}
            Phase.RELEASE -> { env -= releaseInc; if (env <= 0f) { sample = null; pitch = -1; return declickTail() } }
        }

        // Linear interpolation between neighbouring samples (neighbour clamped so the
        // final frame of a buffer-length loop can't read past the end).
        val i = position.toInt()
        val i1 = if (i + 1 < data.size) i + 1 else i
        val frac = (position - i).toFloat()
        var out = data[i] + (data[i1] - data[i]) * frac

        // In the crossfade zone, blend the loop tail into its head so the wrap seam is
        // click-free. Reads only within [loopStartIdx, loopEndIdx) → never OOB.
        if (loopActive && xfade >= 1.0 && position >= loopEndIdx - xfade) {
            val into = position - (loopEndIdx - xfade)   // 0 … xfade
            val f = (into / xfade).toFloat()             // 0 → 1 across the zone
            val sp = loopStartIdx + into                 // matching head position
            val si = sp.toInt()
            val si1 = if (si + 1 < data.size) si + 1 else si
            val sfrac = (sp - si).toFloat()
            val head = data[si] + (data[si1] - data[si]) * sfrac
            out = out * (1f - f) + head * f
        }

        position += rate
        val y = out * amp * env * endGain + declick
        declick *= declickCoeff
        lastY = y
        return y
    }

    companion object {
        private const val FADE_STEP = 0.01f      // ~2 ms end fade at 48 kHz
        private const val FADE_SAMPLES = 1.0 / FADE_STEP // output samples the fade spans
        private const val MIN_ATTACK = 0.0005f   // ~0.5 ms minimum onset ramp (anti-click)
    }
}
