import Testing
import MusicModel
@testable import AudioCore

/// These tests are the foundation of the whole app: they prove note
/// boundaries land at EXACT sample positions in rendered audio.
@Suite struct RenderExactnessTests {
    let sr = 48000.0

    private func render(_ notes: String, options: ProgramCompiler.Options? = nil,
                        bpm: Double = 120) throws -> (PlaybackProgram, OfflineRenderer.Result) {
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: \(bpm)

        part S
        \(notes)
        """
        let score = try Acasong.parse(text)
        let program = try ProgramCompiler.compile(
            score: score, options: options ?? .init(sampleRate: sr)
        )
        return (program, OfflineRenderer.render(program: program))
    }

    @Test func silenceIsExactOutsideNotes() throws {
        // Quarter rest, quarter note, then rest of the measure.
        let (program, result) = try render("R 4 C4 4 R 2")
        let noteStart = result.index(ofPosition: 24000)
        let noteEnd = result.index(ofPosition: 48000)

        // Every sample before the note and after it must be EXACTLY zero.
        for i in 0..<noteStart where result.samples[i] != 0 {
            Issue.record("nonzero sample \(result.samples[i]) at \(i) before note start")
            break
        }
        for i in noteEnd..<result.samples.count where result.samples[i] != 0 {
            Issue.record("nonzero sample \(result.samples[i]) at \(i) after note end")
            break
        }
        // And the note must actually sound.
        let peak = result.samples[noteStart..<noteEnd].map(abs).max() ?? 0
        #expect(peak > 0.05)
        #expect(program.endSample == 96000)
    }

    @Test func onsetIsSampleExact() throws {
        let (_, result) = try render("R 4 C4 4 R 2")
        let noteStart = result.index(ofPosition: 24000)
        // env(0) = 0, so the first audible sample is exactly noteStart + 1.
        #expect(result.samples[noteStart] == 0)
        #expect(result.samples[noteStart + 1] != 0)
        #expect(result.samples[noteStart - 1] == 0)
    }

    @Test func envelopeReachesZeroAtNoteEnd() throws {
        let (_, result) = try render("C4 4 R 2.")
        let noteEnd = result.index(ofPosition: 24000)
        // Samples at/after the end are exact zeros (release completes inside the note).
        #expect(result.samples[noteEnd] == 0)
        // Just before the end the release has brought the level near zero.
        #expect(abs(result.samples[noteEnd - 1]) < 0.01)
    }

    @Test func consecutiveNotesButtJoin() throws {
        let (_, result) = try render("C4 4 E4 4 R 2")
        let boundary = result.index(ofPosition: 24000)
        // No gap: the sample right after the boundary belongs to the new note.
        #expect(result.samples[boundary] == 0)      // release end + attack(0)
        #expect(result.samples[boundary + 1] != 0)  // new note speaking
    }

    @Test func countInClicksThenContent() throws {
        let (program, result) = try render(
            "C4 1", options: .init(sampleRate: sr, countIn: true)
        )
        #expect(program.firstSample == -96000)
        #expect(result.startPosition == -96000)

        // First click at position -96000: burst starts at its sample (0 at
        // exactly the click sample, nonzero right after).
        let click0 = result.index(ofPosition: -96000)
        #expect(result.samples[click0] == 0)
        #expect(result.samples[click0 + 1] != 0)

        // Silence in the gap between click burst end and the next click.
        let burst = Int(0.030 * sr)
        let click1 = result.index(ofPosition: -72000)
        for i in (click0 + burst)..<click1 where result.samples[i] != 0 {
            Issue.record("nonzero at \(i) between count-in clicks")
            break
        }

        // Content onset exactly at position 0.
        let content = result.index(ofPosition: 0)
        #expect(result.samples[content] == 0)
        #expect(result.samples[content + 1] != 0)
    }

    @Test func loopRestartsIdentically() throws {
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: 120

        part S
        C4 4 R 4 E4 2
        """
        let score = try Acasong.parse(text)
        let program = try ProgramCompiler.compile(
            score: score, options: .init(sampleRate: sr, looping: true)
        )
        let state = RenderState(program: program)
        state.playRequested.store(true, ordering: .relaxed)

        let measure = 96000
        var output = [Float](repeating: 0, count: measure * 2)
        output.withUnsafeMutableBufferPointer { buffer in
            var rendered = 0
            while rendered < buffer.count {
                let chunk = min(512, buffer.count - rendered)
                state.render(into: buffer.baseAddress! + rendered, frames: chunk, hostTime: 0)
                rendered += chunk
            }
        }
        // Second pass must be bit-identical to the first (phase resets on loop).
        for i in 0..<measure where output[i] != output[measure + i] {
            Issue.record("loop pass differs at sample \(i): \(output[i]) vs \(output[measure + i])")
            break
        }
        let stillLooping = state.playRequested.load(ordering: .relaxed)
        #expect(stillLooping)
    }

    @Test func stopsExactlyAtEndWhenNotLooping() throws {
        let (program, _) = try render("C4 1")
        let state = RenderState(program: program)
        state.playRequested.store(true, ordering: .relaxed)

        var output = [Float](repeating: 0, count: 96000 + 4800)
        output.withUnsafeMutableBufferPointer { buffer in
            state.render(into: buffer.baseAddress!, frames: buffer.count, hostTime: 0)
        }
        let reachedEnd = state.reachedEnd.load(ordering: .relaxed)
        let stillPlaying = state.playRequested.load(ordering: .relaxed)
        #expect(reachedEnd)
        #expect(!stillPlaying)
        // Nothing after the end sample.
        for i in 96000..<output.count where output[i] != 0 {
            Issue.record("audio after program end at \(i)")
            break
        }
    }

    @Test func seekIsAppliedByRenderThreadAndRestartsIdentically() throws {
        // Regression: seeking used to rebuild voice arrays on the caller's
        // thread, racing the render thread (EXC_BAD_ACCESS in ToneVoice).
        // Now it's a request applied inside render(); a seek back to the
        // start mid-piece must reproduce the opening bit-identically.
        let (program, reference) = try render("C4 4 R 4 E4 2")
        let state = RenderState(program: program)
        state.playRequested.store(true, ordering: .relaxed)

        var first = [Float](repeating: 0, count: 48_000)
        first.withUnsafeMutableBufferPointer { buffer in
            state.render(into: buffer.baseAddress!, frames: buffer.count, hostTime: 0)
        }

        state.seek(toSample: program.firstSample)   // request, not direct mutation
        state.playRequested.store(true, ordering: .relaxed)
        var second = [Float](repeating: 0, count: 48_000)
        second.withUnsafeMutableBufferPointer { buffer in
            state.render(into: buffer.baseAddress!, frames: buffer.count, hostTime: 0)
        }

        for i in 0..<48_000 where first[i] != second[i] {
            Issue.record("post-seek render differs at \(i)")
            break
        }
        // And matches the reference offline render of the same region.
        for i in 0..<48_000 where second[i] != reference.samples[i] {
            Issue.record("post-seek render differs from reference at \(i)")
            break
        }
    }

    @Test func mutedPartIsSilentAndGainsSmooth() throws {
        let (program, _) = try render("C4 1")
        let muted = OfflineRenderer.render(program: program, gains: ["S": 0])
        #expect(muted.samples.allSatisfy { $0 == 0 })

        let full = OfflineRenderer.render(program: program)
        let half = OfflineRenderer.render(program: program, gains: ["S": 0.5])
        let fullPeak = full.samples.map(abs).max()!
        let halfPeak = half.samples.map(abs).max()!
        #expect(abs(halfPeak - fullPeak / 2) < 0.02)
    }

    @Test func twoPartsMixAdditively() throws {
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: 120

        part S
        C4 1
        part B
        R 2 C3 2
        """
        let score = try Acasong.parse(text)
        let program = try ProgramCompiler.compile(score: score, options: .init(sampleRate: sr))
        let both = OfflineRenderer.render(program: program)
        let sOnly = OfflineRenderer.render(program: program, gains: ["B": 0])
        let bOnly = OfflineRenderer.render(program: program, gains: ["S": 0])

        // First half: only S sounds; B enters at the half measure.
        let half = both.index(ofPosition: 48000)
        #expect(bOnly.samples[0..<half].allSatisfy { $0 == 0 })
        // Superposition (gains were constant, no limiter in path).
        for i in stride(from: 0, to: both.samples.count, by: 997) {
            let sum = sOnly.samples[i] + bOnly.samples[i]
            if abs(both.samples[i] - sum) > 1e-4 {
                Issue.record("mix is not additive at \(i)")
                break
            }
        }
    }
}
