import Testing
import MusicModel
@testable import AudioCore

/// A 1:1 timebase (1 tick = 1 ns) keeps the arithmetic transparent.
private let ns = ClockMath.HostTimebase(numer: 1, denom: 1)

@Suite struct ClockMathTests {
    @Test func songZeroDerivation() {
        // A buffer at position 48 000 (1 s at 48 kHz) stamped at t=5 s.
        let songZero = ClockMath.songZeroHostTime(
            renderHostTime: 5_000_000_000,
            renderPosition: 48_000,
            sampleRate: 48_000,
            timebase: ns
        )
        #expect(songZero == 4_000_000_000)
    }

    @Test func songZeroWithNegativePosition() {
        // During count-in, position is negative: song zero is in the future.
        let songZero = ClockMath.songZeroHostTime(
            renderHostTime: 5_000_000_000,
            renderPosition: -24_000,   // half a second before content
            sampleRate: 48_000,
            timebase: ns
        )
        #expect(songZero == 5_500_000_000)
    }

    @Test func alignmentOffsetBasic() {
        // Capture began 0.5 s before song zero; no latency.
        let offset = ClockMath.alignmentOffset(
            captureStartHostTime: 3_500_000_000,
            songZeroHostTime: 4_000_000_000,
            fileSampleRate: 48_000,
            totalLatencySeconds: 0,
            timebase: ns
        )
        #expect(offset == 24_000)
    }

    @Test func alignmentOffsetWithLatency() {
        // Same, plus 20 ms of I/O latency: the singer's song-zero audio
        // lands 20 ms LATER in the file.
        let offset = ClockMath.alignmentOffset(
            captureStartHostTime: 3_500_000_000,
            songZeroHostTime: 4_000_000_000,
            fileSampleRate: 48_000,
            totalLatencySeconds: 0.020,
            timebase: ns
        )
        #expect(offset == 24_000 + 960)
    }

    @Test func alignmentOffsetNegative() {
        // Capture began AFTER song zero (recording punched in late).
        let offset = ClockMath.alignmentOffset(
            captureStartHostTime: 4_500_000_000,
            songZeroHostTime: 4_000_000_000,
            fileSampleRate: 48_000,
            totalLatencySeconds: 0,
            timebase: ns
        )
        #expect(offset == -24_000)
    }

    @Test func rescaleOffsets() {
        #expect(ClockMath.rescale(offset: 44_100, from: 44_100, to: 48_000) == 48_000)
        #expect(ClockMath.rescale(offset: -22_050, from: 44_100, to: 48_000) == -24_000)
    }

    @Test func endToEndScenario() {
        // Full pipeline with count-in: transport starts at -96 000 (2 s count-in),
        // render callback at position -96 000 is stamped t=10 s. Capture starts
        // at t=10.3 s. Latency 15 ms. Song zero plays at t=12 s.
        let songZero = ClockMath.songZeroHostTime(
            renderHostTime: 10_000_000_000,
            renderPosition: -96_000,
            sampleRate: 48_000,
            timebase: ns
        )
        #expect(songZero == 12_000_000_000)

        let offset = ClockMath.alignmentOffset(
            captureStartHostTime: 10_300_000_000,
            songZeroHostTime: songZero,
            fileSampleRate: 48_000,
            totalLatencySeconds: 0.015,
            timebase: ns
        )
        // 1.7 s + 15 ms = 1.715 s → 82 320 samples into the file.
        #expect(offset == 82_320)
    }
}

@Suite struct TakePlaybackTests {
    let sr = 48000.0

    private func program(_ notes: String) throws -> PlaybackProgram {
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: 120

        part S
        \(notes)
        """
        return try ProgramCompiler.compile(
            score: try Acasong.parse(text),
            options: .init(sampleRate: sr, tonePartNames: [])   // no tones: takes only
        )
    }

    @Test func impulseLandsAtExactSample() throws {
        let prog = try program("R 1")
        // A take whose buffer has an impulse at index 30 000, aligned so that
        // buffer index 6 000 == song zero → impulse plays at song sample 24 000.
        var samples = [Float](repeating: 0, count: 96_000)
        samples[30_000] = 1.0
        let take = LoadedTake(
            partName: "S",
            buffer: TakeBuffer(samples: samples, sampleRate: sr),
            offsetAtEngineRate: 6_000
        )
        let result = OfflineRenderer.render(program: prog, takes: [take])
        let impulseIndex = result.samples.firstIndex { $0 != 0 }
        #expect(impulseIndex == result.index(ofPosition: 24_000))
    }

    @Test func negativeOffsetShiftsLater() throws {
        let prog = try program("R 1")
        // Buffer index -12 000 == song zero (capture started after song zero):
        // buffer[0] plays at song sample 12 000.
        var samples = [Float](repeating: 0, count: 48_000)
        samples[0] = 1.0
        let take = LoadedTake(
            partName: "S",
            buffer: TakeBuffer(samples: samples, sampleRate: sr),
            offsetAtEngineRate: -12_000
        )
        let result = OfflineRenderer.render(program: prog, takes: [take])
        let impulseIndex = result.samples.firstIndex { $0 != 0 }
        #expect(impulseIndex == result.index(ofPosition: 12_000))
    }

    @Test func takePlaysDuringCountIn() throws {
        // A take aligned before song zero is audible during count-in
        // (it contains the singer's earlier content; rendering starts at
        // firstSample and take indexing must handle negative positions).
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: 120

        part S
        R 1
        """
        let prog = try ProgramCompiler.compile(
            score: try Acasong.parse(text),
            options: .init(sampleRate: sr, countIn: true, tonePartNames: [])
        )
        var samples = [Float](repeating: 0, count: 96_000 * 2)
        samples[48_000] = 1.0   // with offset 96_000: song position -48_000
        let take = LoadedTake(
            partName: "S",
            buffer: TakeBuffer(samples: samples, sampleRate: sr),
            offsetAtEngineRate: 96_000
        )
        let result = OfflineRenderer.render(program: prog, takes: [take])
        // Count-in clicks also render; find the impulse by exact position.
        let expected = result.index(ofPosition: -48_000)
        #expect(abs(result.samples[expected]) == 1.0)
    }

    @Test func simulatedRecordingRoundTripAlignsUnderOneSample() throws {
        // The Phase 3 acceptance test, offline:
        // 1. "Perform" a part by rendering its tones.
        let text = """
        acasong 1
        title: T
        time: 4/4
        key: C major
        tempo: 120

        part S
        C4 4 R 4 E4 4 G4 4
        """
        let score = try Acasong.parse(text)
        let perfProgram = try ProgramCompiler.compile(score: score, options: .init(sampleRate: sr))
        let performance = OfflineRenderer.render(program: perfProgram)

        // 2. Simulate capture: the "mic file" begins 0.35 s before song zero
        //    (mid count-in) and the I/O chain delays content by 12 ms.
        let latency = 0.012
        let latencySamples = Int(latency * sr)
        let preRollSamples = Int(0.35 * sr)
        var micFile = [Float](repeating: 0, count: preRollSamples + latencySamples + performance.samples.count)
        for (i, s) in performance.samples.enumerated() {
            micFile[preRollSamples + latencySamples + i] = s
        }

        // 3. Compute alignment from timestamps exactly as the engine would.
        //    Song zero at t=100 s; capture start 0.35 s earlier.
        let songZero: UInt64 = 100_000_000_000
        let captureStart: UInt64 = songZero - UInt64(0.35 * 1e9)
        let offset = ClockMath.alignmentOffset(
            captureStartHostTime: captureStart,
            songZeroHostTime: songZero,
            fileSampleRate: sr,
            totalLatencySeconds: latency,
            timebase: ns
        )

        // 4. Play the "recording" back as a take (tones off) and compare
        //    with the original performance sample-for-sample.
        let playbackProgram = try ProgramCompiler.compile(
            score: score, options: .init(sampleRate: sr, tonePartNames: [])
        )
        let take = LoadedTake(
            partName: "S",
            buffer: TakeBuffer(samples: micFile, sampleRate: sr),
            offsetAtEngineRate: offset
        )
        let replay = OfflineRenderer.render(program: playbackProgram, takes: [take])

        #expect(replay.samples.count == performance.samples.count)
        var maxError: Float = 0
        for i in 0..<min(replay.samples.count, performance.samples.count) {
            maxError = max(maxError, abs(replay.samples[i] - performance.samples[i]))
        }
        // Bit-exact: alignment error is zero samples, not merely < 1.
        #expect(maxError == 0)
    }
}
