import Foundation
import Testing
@testable import MusicModel

/// Compact MusicXML builder for fixtures.
private func musicXML(parts: [(name: String, measures: [String])], head: String = "") -> Data {
    var partList = ""
    var body = ""
    for (i, part) in parts.enumerated() {
        let id = "P\(i + 1)"
        partList += #"<score-part id="\#(id)"><part-name>\#(part.name)</part-name></score-part>"#
        body += #"<part id="\#(id)">"#
        for (mi, measure) in part.measures.enumerated() {
            body += #"<measure number="\#(mi + 1)">\#(measure)</measure>"#
        }
        body += "</part>"
    }
    let xml = """
    <?xml version="1.0" encoding="UTF-8"?>
    <score-partwise version="4.0">
    \(head)
    <part-list>\(partList)</part-list>
    \(body)
    </score-partwise>
    """
    return Data(xml.utf8)
}

private let standardAttributes = """
<attributes>
<divisions>4</divisions>
<key><fifths>1</fifths><mode>major</mode></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<sound tempo="90"/>
"""

private func note(_ step: String, _ octave: Int, divisions: Int, alter: Int? = nil,
                  tie: Bool = false, chord: Bool = false, lyric: String? = nil) -> String {
    var xml = "<note>"
    if chord { xml += "<chord/>" }
    xml += "<pitch><step>\(step)</step>"
    if let alter { xml += "<alter>\(alter)</alter>" }
    xml += "<octave>\(octave)</octave></pitch>"
    xml += "<duration>\(divisions)</duration>"
    if tie { xml += #"<tie type="start"/>"# }
    if let lyric { xml += "<lyric><text>\(lyric)</text></lyric>" }
    xml += "</note>"
    return xml
}

private func rest(divisions: Int) -> String {
    "<note><rest/><duration>\(divisions)</duration></note>"
}

@Suite struct MusicXMLImporterTests {

    @Test func importsBasicScore() throws {
        let data = musicXML(
            parts: [
                ("Soprano", [standardAttributes + note("G", 4, divisions: 4) + note("A", 4, divisions: 4) + note("B", 4, divisions: 8)]),
                ("Bass", ["<attributes><divisions>4</divisions><clef><sign>F</sign><line>4</line></clef></attributes>" + note("G", 2, divisions: 16)]),
            ],
            head: "<work><work-title>Test Piece</work-title></work><identification><creator type=\"composer\">Trad.</creator></identification>"
        )
        let result = try MusicXMLImporter.importScore(data: data)
        let score = result.score

        #expect(score.title == "Test Piece")
        #expect(score.composer == "Trad.")
        #expect(score.keySignature.name == "G major")
        #expect(score.timeSignature.name == "4/4")
        #expect(score.tempoMap.baseBPM == 90)
        #expect(score.parts.count == 2)
        #expect(score.parts[0].name == "Soprano")
        #expect(score.parts[0].clef == .treble)
        #expect(score.parts[1].clef == .bass)
        #expect(score.parts[0].notes.map(\.durationTicks) == [960, 960, 1920])
        try score.validate()
    }

    @Test func importsTiesAndSplitsBarlineCrossings() throws {
        // A whole note tied into a half, and a half that crosses the barline
        // (divisions=4 → 16 per measure; note of 24 starting at beat 3).
        let data = musicXML(parts: [
            ("S", [
                standardAttributes + note("C", 5, divisions: 8) + note("D", 5, divisions: 24),
                rest(divisions: 8),
            ]),
        ])
        let result = try MusicXMLImporter.importScore(data: data)
        let notes = result.score.parts[0].notes
        // D5 of 6 beats split at the barline (2+4, tied); the short final
        // measure is padded with an extra rest → 5 events total.
        #expect(notes.count == 5)
        #expect(notes[1].durationTicks == 1920)
        #expect(notes[1].tieToNext)
        #expect(notes[2].durationTicks == 3840)
        #expect(notes[2].pitch == notes[1].pitch)
        try result.score.validate()
    }

    @Test func collapsesChordsWithWarning() throws {
        let data = musicXML(parts: [
            ("Piano", [standardAttributes
                + note("C", 4, divisions: 16)
                + note("E", 4, divisions: 16, chord: true)
                + note("G", 4, divisions: 16, chord: true)]),
        ])
        let result = try MusicXMLImporter.importScore(data: data)
        #expect(result.score.parts[0].notes.count == 1)
        #expect(result.score.parts[0].notes[0].pitch == Pitch.parse("C4"))
        #expect(result.warnings.contains { $0.contains("chords collapsed") })
    }

    @Test func detectsPickupMeasure() throws {
        let data = musicXML(parts: [
            ("S", [
                standardAttributes + note("D", 4, divisions: 4),   // 1-beat pickup
                note("G", 4, divisions: 16),
            ]),
        ])
        let result = try MusicXMLImporter.importScore(data: data)
        #expect(result.score.pickupTicks == 960)
        #expect(result.score.measureCount == 2)
        try result.score.validate()
    }

    @Test func importsAccidentalsAndLyrics() throws {
        let data = musicXML(parts: [
            ("S", [standardAttributes
                + note("F", 4, divisions: 8, alter: 1, lyric: "sing")
                + note("B", 3, divisions: 8, alter: -1, lyric: "low")]),
        ])
        let result = try MusicXMLImporter.importScore(data: data)
        let notes = result.score.parts[0].notes
        #expect(notes[0].pitch == Pitch.parse("F#4"))
        #expect(notes[1].pitch == Pitch.parse("Bb3"))
        #expect(notes[0].lyric == "sing")
        #expect(notes[1].lyric == "low")
    }

    @Test func padsShortMeasuresWithWarning() throws {
        let data = musicXML(parts: [
            ("S", [
                standardAttributes + note("C", 4, divisions: 16),
                note("D", 4, divisions: 8),   // half-empty measure
                note("E", 4, divisions: 16),
            ]),
        ])
        let result = try MusicXMLImporter.importScore(data: data)
        try result.score.validate()
        #expect(result.warnings.contains { $0.contains("padded") })
    }

    @Test func rejectsEmptyFile() {
        #expect(throws: (any Error).self) {
            _ = try MusicXMLImporter.importScore(data: Data("<score-partwise/>".utf8))
        }
    }

    @Test func roundTripsThroughAcasong() throws {
        let data = musicXML(parts: [
            ("Soprano", [standardAttributes + note("G", 4, divisions: 4, lyric: "la") + rest(divisions: 4) + note("B", 4, divisions: 8, tie: true), note("B", 4, divisions: 16)]),
            ("Bass", ["<attributes><divisions>4</divisions><clef><sign>F</sign><line>4</line></clef></attributes>" + note("G", 2, divisions: 16), note("D", 2, divisions: 16)]),
        ])
        let imported = try MusicXMLImporter.importScore(data: data).score
        let text = Acasong.serialize(imported)
        let reparsed = try Acasong.parse(text)
        #expect(reparsed == imported)
    }
}

@Suite struct MXLArchiveTests {
    @Test func extractsStoredZip() throws {
        // Minimal stored-entry zip containing a tiny score.
        let xml = musicXML(parts: [("S", [standardAttributes + note("C", 4, divisions: 16)])])
        let zip = makeStoredZip(entries: [("score.xml", xml)])
        #expect(MXLArchive.isZip(zip))
        let result = try MusicXMLImporter.importScore(data: zip)
        #expect(result.score.parts.count == 1)
    }

    @Test func honorsContainerXML() throws {
        let xml = musicXML(parts: [("S", [standardAttributes + note("C", 4, divisions: 16)])])
        let container = Data("""
        <?xml version="1.0"?><container><rootfiles>
        <rootfile full-path="inner/real-score.xml"/>
        </rootfiles></container>
        """.utf8)
        let decoy = Data("<not-a-score/>".utf8)
        let zip = makeStoredZip(entries: [
            ("META-INF/container.xml", container),
            ("aaa-decoy.xml", decoy),
            ("inner/real-score.xml", xml),
        ])
        let result = try MusicXMLImporter.importScore(data: zip)
        #expect(result.score.parts.count == 1)
    }

    @Test func rejectsGarbage() {
        #expect(throws: (any Error).self) {
            _ = try MXLArchive.extractScoreXML(from: Data([0x50, 0x4B, 0x00, 0x00, 1, 2, 3]))
        }
    }
}

/// Build a minimal ZIP with stored (uncompressed) entries — enough to test
/// the reader without an external zip tool.
private func makeStoredZip(entries: [(name: String, data: Data)]) -> Data {
    var out = Data()
    var centralDirectory = Data()
    var offsets: [Int] = []

    func append16(_ value: Int, to data: inout Data) {
        data.append(UInt8(value & 0xFF)); data.append(UInt8((value >> 8) & 0xFF))
    }
    func append32(_ value: Int, to data: inout Data) {
        append16(value & 0xFFFF, to: &data); append16((value >> 16) & 0xFFFF, to: &data)
    }

    for (name, data) in entries {
        offsets.append(out.count)
        let nameBytes = Data(name.utf8)
        let crc = crc32(data)
        // Local file header
        append32(0x04034b50, to: &out)
        append16(20, to: &out)             // version needed
        append16(0, to: &out)              // flags
        append16(0, to: &out)              // method: stored
        append32(0, to: &out)              // mod time/date
        append32(crc, to: &out)
        append32(data.count, to: &out)     // compressed
        append32(data.count, to: &out)     // uncompressed
        append16(nameBytes.count, to: &out)
        append16(0, to: &out)              // extra length
        out.append(nameBytes)
        out.append(data)
    }

    for (i, (name, data)) in entries.enumerated() {
        let nameBytes = Data(name.utf8)
        let crc = crc32(data)
        append32(0x02014b50, to: &centralDirectory)
        append16(20, to: &centralDirectory)   // version made by
        append16(20, to: &centralDirectory)   // version needed
        append16(0, to: &centralDirectory)
        append16(0, to: &centralDirectory)    // method
        append32(0, to: &centralDirectory)    // time/date
        append32(crc, to: &centralDirectory)
        append32(data.count, to: &centralDirectory)
        append32(data.count, to: &centralDirectory)
        append16(nameBytes.count, to: &centralDirectory)
        append16(0, to: &centralDirectory)    // extra
        append16(0, to: &centralDirectory)    // comment
        append16(0, to: &centralDirectory)    // disk
        append16(0, to: &centralDirectory)    // internal attrs
        append32(0, to: &centralDirectory)    // external attrs
        append32(offsets[i], to: &centralDirectory)
        centralDirectory.append(nameBytes)
    }

    let cdOffset = out.count
    out.append(centralDirectory)
    // EOCD
    append32(0x06054b50, to: &out)
    append16(0, to: &out)                  // disk
    append16(0, to: &out)                  // cd disk
    append16(entries.count, to: &out)
    append16(entries.count, to: &out)
    append32(centralDirectory.count, to: &out)
    append32(cdOffset, to: &out)
    append16(0, to: &out)                  // comment length
    return out
}

private func crc32(_ data: Data) -> Int {
    var table = [UInt32](repeating: 0, count: 256)
    for i in 0..<256 {
        var c = UInt32(i)
        for _ in 0..<8 { c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1 }
        table[i] = c
    }
    var crc: UInt32 = 0xFFFFFFFF
    for byte in data {
        crc = table[Int((crc ^ UInt32(byte)) & 0xFF)] ^ (crc >> 8)
    }
    return Int(crc ^ 0xFFFFFFFF)
}
