import Foundation
import AVFoundation
import Testing
import MusicModel
@testable import AudioCore

private func tempDir() -> URL {
    FileManager.default.temporaryDirectory
        .appendingPathComponent("acaproj-tests-\(UUID().uuidString)")
}

private func sampleScore() throws -> Score {
    try Acasong.parse("""
    acasong 1
    title: Store Test
    time: 4/4
    key: C major
    tempo: 100

    part S
    C4 1
    part B
    C3 1
    """)
}

@Suite struct ManifestTests {
    @Test func jsonRoundTrip() throws {
        var manifest = ProjectManifest()
        manifest.takes.append(.init(
            partName: "S", fileName: "take-x.caf", sampleRate: 48000,
            alignmentOffsetSamples: 12345, recordedTempoMultiplier: 1.0,
            recordedMeasureStart: 0, recordedMeasureEnd: 3,
            createdAt: Date(timeIntervalSince1970: 1_700_000_000),
            gainDb: -3, isActive: true, manualNudgeMs: -5
        ))
        manifest.mix.muted = ["B"]
        manifest.calibrationTrims["Speaker+MicrophoneBuiltIn"] = 0.011

        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        let data = try encoder.encode(manifest)
        let decoded = try decoder.decode(ProjectManifest.self, from: data)
        #expect(decoded == manifest)
    }

    @Test func effectiveOffsetAppliesNudge() {
        let take = ProjectManifest.TakeRecord(
            partName: "S", fileName: "f.caf", sampleRate: 48000,
            alignmentOffsetSamples: 1000, recordedTempoMultiplier: 1,
            recordedMeasureStart: 0, recordedMeasureEnd: 0,
            createdAt: Date(), manualNudgeMs: 10
        )
        #expect(take.effectiveOffsetSamples == 1000 + 480)
    }

    @Test func activeTakesOnePerPart() {
        var manifest = ProjectManifest()
        func record(_ part: String, active: Bool) -> ProjectManifest.TakeRecord {
            .init(partName: part, fileName: UUID().uuidString, sampleRate: 48000,
                  alignmentOffsetSamples: 0, recordedTempoMultiplier: 1,
                  recordedMeasureStart: 0, recordedMeasureEnd: 0,
                  createdAt: Date(), isActive: active)
        }
        manifest.takes = [record("S", active: false), record("S", active: true), record("B", active: true)]
        #expect(manifest.activeTakes.count == 2)
        #expect(manifest.activeTakes["S"]?.isActive == true)
    }
}

@Suite struct ProjectStoreTests {
    @Test func createOpenRoundTrip() throws {
        let dir = tempDir().appendingPathComponent("Song.acaproj")
        defer { try? FileManager.default.removeItem(at: dir) }

        let score = try sampleScore()
        let created = try ProjectStore.create(at: dir, score: score)
        #expect(created.score == score)

        let opened = try ProjectStore.open(at: dir)
        #expect(opened.score == score)
        #expect(opened.manifest == created.manifest)
    }

    @Test func addTakeDeactivatesPrevious() throws {
        let dir = tempDir().appendingPathComponent("Song.acaproj")
        defer { try? FileManager.default.removeItem(at: dir) }
        let store = try ProjectStore.create(at: dir, score: try sampleScore())

        func writeTakeFile(_ name: String) throws {
            let url = dir.appendingPathComponent("takes").appendingPathComponent(name)
            try Data([0, 1, 2]).write(to: url)
        }

        try writeTakeFile("a.caf")
        try writeTakeFile("b.caf")
        try store.addTake(.init(
            partName: "S", fileName: "a.caf", sampleRate: 48000,
            alignmentOffsetSamples: 0, recordedTempoMultiplier: 1,
            recordedMeasureStart: 0, recordedMeasureEnd: 0, createdAt: Date()
        ))
        try store.addTake(.init(
            partName: "S", fileName: "b.caf", sampleRate: 48000,
            alignmentOffsetSamples: 0, recordedTempoMultiplier: 1,
            recordedMeasureStart: 0, recordedMeasureEnd: 0, createdAt: Date()
        ))

        #expect(store.manifest.takes.count == 2)
        #expect(store.manifest.activeTakes["S"]?.fileName == "b.caf")
        let activeCount = store.manifest.takes.filter { $0.isActive && $0.partName == "S" }.count
        #expect(activeCount == 1)

        // Persisted?
        let reopened = try ProjectStore.open(at: dir)
        #expect(reopened.manifest.activeTakes["S"]?.fileName == "b.caf")
    }

    @Test func deleteTakeRemovesFile() throws {
        let dir = tempDir().appendingPathComponent("Song.acaproj")
        defer { try? FileManager.default.removeItem(at: dir) }
        let store = try ProjectStore.create(at: dir, score: try sampleScore())

        let fileURL = dir.appendingPathComponent("takes/gone.caf")
        try Data([0]).write(to: fileURL)
        let record = try store.addTake(.init(
            partName: "S", fileName: "gone.caf", sampleRate: 48000,
            alignmentOffsetSamples: 0, recordedTempoMultiplier: 1,
            recordedMeasureStart: 0, recordedMeasureEnd: 0, createdAt: Date()
        ))
        try store.deleteTake(id: record.id)
        #expect(store.manifest.takes.isEmpty)
        #expect(!FileManager.default.fileExists(atPath: fileURL.path))
    }

    @Test func rejectsTakeWithMissingFile() throws {
        let dir = tempDir().appendingPathComponent("Song.acaproj")
        defer { try? FileManager.default.removeItem(at: dir) }
        let store = try ProjectStore.create(at: dir, score: try sampleScore())
        #expect(throws: (any Error).self) {
            try store.addTake(.init(
                partName: "S", fileName: "nope.caf", sampleRate: 48000,
                alignmentOffsetSamples: 0, recordedTempoMultiplier: 1,
                recordedMeasureStart: 0, recordedMeasureEnd: 0, createdAt: Date()
            ))
        }
    }

    @Test func mismatchedTempoTakesAreSkipped() throws {
        let dir = tempDir().appendingPathComponent("Song.acaproj")
        defer { try? FileManager.default.removeItem(at: dir) }
        let store = try ProjectStore.create(at: dir, score: try sampleScore())

        // Write a tiny real CAF so TakeBuffer.load succeeds.
        let takeFileURL = store.newTakeFileURL().url
        try writeTestCAF(url: takeFileURL, samples: [0.5, -0.5, 0.25], sampleRate: 48000)
        try store.addTake(.init(
            partName: "S", fileName: takeFileURL.lastPathComponent, sampleRate: 48000,
            alignmentOffsetSamples: 0, recordedTempoMultiplier: 1.0,
            recordedMeasureStart: 0, recordedMeasureEnd: 0, createdAt: Date()
        ))

        let matched = try store.loadActiveTakes(engineRate: 48000, tempoMultiplier: 1.0)
        #expect(matched.takes.count == 1)
        #expect(matched.mismatched.isEmpty)
        #expect(matched.takes[0].buffer.samples.count == 3)

        let mismatched = try store.loadActiveTakes(engineRate: 48000, tempoMultiplier: 1.25)
        #expect(mismatched.takes.isEmpty)
        #expect(mismatched.mismatched.count == 1)
    }
}

/// Minimal CAF writer for tests.
private func writeTestCAF(url: URL, samples: [Float], sampleRate: Double) throws {
    let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false)!
    let file = try AVAudioFile(forWriting: url, settings: format.settings, commonFormat: .pcmFormatFloat32, interleaved: false)
    let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(samples.count))!
    buffer.frameLength = AVAudioFrameCount(samples.count)
    for (i, s) in samples.enumerated() { buffer.floatChannelData![0][i] = s }
    try file.write(from: buffer)
}
