//
//  MIDISynthesizer.swift
//  DashSmash
//
//  Renders parsed MIDI notes into a compact WAV for deterministic playback.
//  This avoids requiring a bundled SoundFont while keeping obstacle timing and
//  audible notes driven from the same MIDI event list.
//

import Foundation
import AVFoundation

final class MIDISynthesizer {
    private var player: AVAudioPlayer?
    private let sampleRate: Double = 44100
    private var song: MIDISong?
    private var rate: Float = 1.0

    deinit { stop() }

    func prepare(song: MIDISong) {
        self.song = song
        guard let wav = Self.renderWAV(file: song.parsedFile, sampleRate: sampleRate) else { return }
        do {
            try AVAudioSession.sharedInstance().setCategory(.ambient, options: [.mixWithOthers])
            try AVAudioSession.sharedInstance().setActive(true)
            let p = try AVAudioPlayer(data: wav)
            p.enableRate = true
            p.rate = rate
            p.prepareToPlay()
            player = p
        } catch {
            player = nil
        }
    }

    func setRate(_ rate: Float) {
        self.rate = rate
        player?.enableRate = true
        player?.rate = rate
    }

    func start() {
        player?.currentTime = 0
        player?.rate = rate
        player?.play()
    }

    func stop() {
        player?.stop()
        player?.currentTime = 0
    }

    func restart() {
        stop()
        start()
    }

    private static func renderWAV(file: MIDIParsedFile, sampleRate: Double) -> Data? {
        let duration = min(file.durationSeconds + 1.0, 300)
        let totalSamples = Int(duration * sampleRate)
        guard totalSamples > 0 else { return nil }
        var samples = [Float](repeating: 0, count: totalSamples)
        let secondsPerBeat = 60.0 / file.bpm

        samples.withUnsafeMutableBufferPointer { buffer in
            guard let base = buffer.baseAddress else { return }
            for note in file.notes where note.beat * secondsPerBeat < duration {
                let start = Int(note.beat * secondsPerBeat * sampleRate)
                let length = max(Int(note.durationBeats * secondsPerBeat * sampleRate), Int(sampleRate * 0.06))
                render(note: note, into: base, start: start, lengthSamples: length, totalSamples: totalSamples, sampleRate: sampleRate)
            }
        }

        applySoftLimiter(&samples, sampleRate: sampleRate)

        var int16: [Int16] = []
        int16.reserveCapacity(totalSamples)
        for sample in samples {
            let clamped = max(-1.0, min(1.0, sample))
            int16.append(Int16(clamped * 32767))
        }

        var data = Data()
        data.reserveCapacity(44 + totalSamples * 2)
        appendWAVHeader(into: &data, sampleRate: Int(sampleRate), channelCount: 1, sampleCount: totalSamples)
        int16.withUnsafeBufferPointer { ptr in
            data.append(UnsafeBufferPointer(start: ptr.baseAddress, count: ptr.count))
        }
        return data
    }

    private static func render(note: MIDINote, into data: UnsafeMutablePointer<Float>, start: Int, lengthSamples: Int, totalSamples: Int, sampleRate: Double) {
        if note.isPercussion {
            switch note.note {
            case 35, 36:
                renderKick(into: data, start: start, totalSamples: totalSamples, sampleRate: sampleRate)
            case 38, 40:
                renderSnare(into: data, start: start, totalSamples: totalSamples, sampleRate: sampleRate)
            case 42, 44, 46, 49, 51, 57:
                renderHat(into: data, start: start, totalSamples: totalSamples, sampleRate: sampleRate, gain: 0.16)
            default:
                renderTick(into: data, start: start, totalSamples: totalSamples, sampleRate: sampleRate)
            }
            return
        }

        let frequency = 440.0 * pow(2.0, Double(note.note - 69) / 12.0)
        let gain = Float(min(0.26, 0.07 + Double(note.velocity) / 720.0))
        // One-pole low-pass state, fresh per note so we don't smear across
        // voices that share a buffer.
        var lpState: Double = 0
        let lpCoefficient: Double
        if note.isBass {
            lpCoefficient = 0.18
        } else if note.isLead {
            lpCoefficient = 0.32
        } else {
            lpCoefficient = 0.55
        }
        let totalNoteSeconds = Double(lengthSamples) / sampleRate
        let releaseTime = max(0.14, totalNoteSeconds * 0.85)
        for i in 0..<lengthSamples {
            let idx = start + i
            if idx < 0 || idx >= totalSamples { return }
            let t = Double(i) / sampleRate
            // Slightly longer attack so dense lead lines don't pile transients.
            let attack = min(1.0, t * 80.0)
            let release = exp(-t / releaseTime)
            let env = Float(attack * release)
            let raw: Double
            if note.isBass {
                // Saturated saw + sub-sine an octave down for body without
                // burying the kick.
                let phase = frequency * t
                let saw = 2.0 * (phase - floor(phase + 0.5))
                let sub = sin(2.0 * .pi * frequency * 0.5 * t) * 0.45
                raw = tanh((saw + sub) * 1.5) * 0.6
            } else if note.isLead {
                // Detuned dual-saw approximation (super-saw lite) for a wider
                // lead than a plain sawtooth.
                let detune = 1.004
                let pA = frequency * t
                let pB = frequency * detune * t
                let sawA = 2.0 * (pA - floor(pA + 0.5))
                let sawB = 2.0 * (pB - floor(pB + 0.5))
                raw = (sawA + sawB) * 0.5
            } else {
                // Harmony: sine + a perfect-fifth partial for a fuller pad.
                let fifth = sin(2.0 * .pi * frequency * 1.5 * t) * 0.35
                raw = sin(2.0 * .pi * frequency * t) + fifth
            }
            // 1-pole IIR low-pass: y[n] = y[n-1] + a * (x - y[n-1])
            lpState += lpCoefficient * (raw - lpState)
            data[idx] += Float(lpState) * env * gain
        }
    }

    /// Forward peak limiter with a soft knee. Prevents the sample bus from
    /// clipping when many voices stack on the same beat, without the harsh
    /// "everything got quieter" feeling of a flat normalizer.
    private static func applySoftLimiter(_ samples: inout [Float], sampleRate: Double) {
        let threshold: Float = 0.88
        let attackSamples = max(1, Int(sampleRate * 0.002))     // 2 ms
        let releaseSamples = max(1, Int(sampleRate * 0.080))    // 80 ms
        let attackCoeff = 1.0 / Float(attackSamples)
        let releaseCoeff = 1.0 / Float(releaseSamples)
        var gain: Float = 1.0
        for i in samples.indices {
            let level = abs(samples[i])
            let target: Float = level * gain > threshold ? threshold / level : 1.0
            // Smooth gain transitions: fast on the way down, slow on the way up.
            if target < gain {
                gain += (target - gain) * attackCoeff
            } else {
                gain += (target - gain) * releaseCoeff
            }
            samples[i] *= gain
            // Final soft knee — anything that still pokes above 1.0 from
            // numerical wobble gets smoothly tanh'd back inside the rails.
            if samples[i] > 0.98 || samples[i] < -0.98 {
                samples[i] = tanhf(samples[i])
            }
        }
    }

    /// Multiplier that ramps a percussion voice up over the first ~3 ms so the
    /// note doesn't start mid-cycle and produce an audible click. The samples
    /// added to the mix bus are already small, but transient clicks are what
    /// were getting flattened by the int16 stage and sounding like clipping.
    private static func percAttack(_ i: Int, sampleRate: Double) -> Float {
        let ramp = max(1, Int(sampleRate * 0.003))
        return i < ramp ? Float(i) / Float(ramp) : 1.0
    }

    private static func renderKick(into data: UnsafeMutablePointer<Float>, start: Int, totalSamples: Int, sampleRate: Double) {
        let length = Int(sampleRate * 0.2)
        for i in 0..<length {
            let idx = start + i
            if idx < 0 || idx >= totalSamples { return }
            let t = Double(i) / sampleRate
            let freq = 42.0 + 90.0 * exp(-t * 30.0)
            let body = sin(2.0 * .pi * freq * t) * exp(-t * 16.0)
            let click = exp(-t * 220.0) * sin(2.0 * .pi * 1800.0 * t) * 0.18
            let raw = Float(body + click) * percAttack(i, sampleRate: sampleRate)
            data[idx] += raw * 0.82
        }
    }

    private static func renderSnare(into data: UnsafeMutablePointer<Float>, start: Int, totalSamples: Int, sampleRate: Double) {
        let length = Int(sampleRate * 0.17)
        var seed: UInt32 = 0x51A4E
        for i in 0..<length {
            let idx = start + i
            if idx < 0 || idx >= totalSamples { return }
            seed = seed &* 1664525 &+ 1013904223
            let noise = Float(Int32(bitPattern: seed)) / Float(Int32.max)
            let t = Double(i) / sampleRate
            // Tonal body underneath the noise so the snare lands instead of
            // just hissing on top of the bass.
            let tone = Float(sin(2.0 * .pi * 220.0 * t) * exp(-t * 30.0)) * 0.25
            let env = Float(exp(-t * 22.0))
            data[idx] += (noise * env * 0.46 + tone) * percAttack(i, sampleRate: sampleRate)
        }
    }

    private static func renderHat(into data: UnsafeMutablePointer<Float>, start: Int, totalSamples: Int, sampleRate: Double, gain: Float) {
        let length = Int(sampleRate * 0.05)
        var seed: UInt32 = 0xDA5EED
        // High-pass state to make the noise feel like a metal hat instead of
        // broadband static — keeps it from muddying the bass when stacked.
        var prevNoise: Float = 0
        for i in 0..<length {
            let idx = start + i
            if idx < 0 || idx >= totalSamples { return }
            seed = seed &* 1664525 &+ 1013904223
            let noise = Float(Int32(bitPattern: seed)) / Float(Int32.max)
            let hp = noise - prevNoise * 0.85
            prevNoise = noise
            let t = Double(i) / sampleRate
            data[idx] += hp * Float(exp(-t * 90.0)) * gain * percAttack(i, sampleRate: sampleRate)
        }
    }

    private static func renderTick(into data: UnsafeMutablePointer<Float>, start: Int, totalSamples: Int, sampleRate: Double) {
        let length = Int(sampleRate * 0.04)
        for i in 0..<length {
            let idx = start + i
            if idx < 0 || idx >= totalSamples { return }
            let t = Double(i) / sampleRate
            let raw = Float(sin(2.0 * .pi * 900.0 * t) * exp(-t * 80.0))
            data[idx] += raw * 0.16 * percAttack(i, sampleRate: sampleRate)
        }
    }

    private static func appendWAVHeader(into data: inout Data, sampleRate: Int, channelCount: Int, sampleCount: Int) {
        let bitsPerSample = 16
        let byteRate = sampleRate * channelCount * (bitsPerSample / 8)
        let blockAlign = channelCount * (bitsPerSample / 8)
        let subChunk2Size = sampleCount * channelCount * (bitsPerSample / 8)
        let chunkSize = 36 + subChunk2Size

        func appendAscii(_ string: String) { data.append(contentsOf: Array(string.utf8)) }
        func append<T: FixedWidthInteger>(_ value: T) {
            var v = value.littleEndian
            withUnsafeBytes(of: &v) { data.append(contentsOf: $0) }
        }

        appendAscii("RIFF")
        append(UInt32(chunkSize))
        appendAscii("WAVE")
        appendAscii("fmt ")
        append(UInt32(16))
        append(UInt16(1))
        append(UInt16(channelCount))
        append(UInt32(sampleRate))
        append(UInt32(byteRate))
        append(UInt16(blockAlign))
        append(UInt16(bitsPerSample))
        appendAscii("data")
        append(UInt32(subChunk2Size))
    }
}
