//
//  MIDILevelBuilder.swift
//  DashSmash
//
//  Converts parsed MIDI note events into a beat-accurate level. Percussion,
//  bass, harmony, and lead notes each map to different obstacle families so the
//  level reflects the arrangement instead of a generic rhythm pattern.
//

import Foundation
import CoreGraphics

enum MIDILevelBuilder {
    static func makeLevel(from song: MIDISong) -> Level {
        let file = song.parsedFile
        let totalBeats = min(max(file.totalBeats + 4, 32), 320)
        let sections = makeSections(notes: file.notes, totalBeats: totalBeats)
        var seed = SeededRNG(string: song.sourcePageURL.absoluteString)
        let hueSeed = Double(seed.next() % 10_000) / 10_000.0
        let primaryHue = CGFloat(hueSeed)
        let secondaryHue = CGFloat((hueSeed + 0.38).truncatingRemainder(dividingBy: 1.0))

        return Level(
            id: UUID(),
            songName: song.title,
            bandName: song.artistName,
            bpm: file.bpm,
            pointsPerBeat: 132,
            totalBeats: totalBeats,
            sections: sections,
            primaryHue: primaryHue,
            secondaryHue: secondaryHue,
            style: .progressive,
            trackSourceID: song.sourceID
        )
    }

    private static func makeSections(notes: [MIDINote], totalBeats: Double) -> [LevelSection] {
        let sectionLength = max(16, min(32, totalBeats / 4))
        var sections: [LevelSection] = []
        var start = 0.0
        var index = 0
        while start < totalBeats {
            let end = min(totalBeats, start + sectionLength)
            let notesInSection = notes.filter { $0.beat >= start && $0.beat < end }
            let mode = modeForSection(notes: notesInSection, index: index)
            var obstacles = makeObstacles(notes: notesInSection, mode: mode, startBeat: start, endBeat: end)
            obstacles = decorate(obstacles, mode: mode, startBeat: start, endBeat: end, sectionIndex: index)
            sections.append(LevelSection(
                mode: mode,
                startBeat: start,
                obstacles: obstacles,
                label: labelForSection(notes: notesInSection, index: index)
            ))
            start = end
            index += 1
        }
        return sections.isEmpty ? [LevelSection(mode: .cube, startBeat: 0, obstacles: [], label: "MIDI")] : sections
    }

    /// Adds Geometry Dash-style flavour to each section after the raw
    /// note→obstacle mapping: coins in the safe gaps so the player has a
    /// scoring carrot, and a jump pad early in non-intro sections so the
    /// level rewards the player with an exhilarating launch.
    ///
    /// Placement only fills "empty" beats (≥0.6 beats from any obstacle), so
    /// these additions never compete with a hazard the player must clear.
    private static func decorate(
        _ obstacles: [Obstacle],
        mode: GameMode,
        startBeat: Double,
        endBeat: Double,
        sectionIndex: Int
    ) -> [Obstacle] {
        // Only flavour cube/mini/gravity sections — ship sections already have
        // their own ceiling/floor weaving and a coin or pad mid-flight is
        // confusing.
        guard mode == .cube || mode == .mini || mode == .gravity else { return obstacles }

        var result = obstacles
        let occupied = obstacles.map { $0.beat }.sorted()
        // Inset the placement window from the section edges so decorations
        // don't get clipped by section transitions / warm-up.
        let placeStart = startBeat + 3
        let placeEnd = endBeat - 2
        guard placeEnd > placeStart else { return obstacles }

        func nearestOccupiedDistance(from beat: Double) -> Double {
            var best = Double.infinity
            for o in occupied {
                let d = abs(o - beat)
                if d < best { best = d }
                if o > beat + best { break }
            }
            return best
        }

        // Jump pad at the start of non-intro sections in ground-jumping modes.
        // Skip ship/gravity since pads on the floor wouldn't help an inverted
        // player.
        if sectionIndex > 0 && mode != .gravity {
            // Search forward for the first beat with a healthy gap to any
            // obstacle and stick a pad there.
            var beat = placeStart
            while beat < placeStart + 8 && beat < placeEnd {
                if nearestOccupiedDistance(from: beat) >= 1.2 {
                    result.append(Obstacle(kind: .jumpPad, beat: beat, yOffset: 0, width: 64, height: 16))
                    break
                }
                beat += 0.5
            }
        }

        // Sprinkle coins through quiet stretches. We walk the section in
        // 1-beat increments and drop a coin wherever there's no nearby
        // obstacle. Slight Y variation makes them visually interesting and
        // sometimes requires a jump to grab.
        var beat = placeStart + 1
        var coinCount = 0
        while beat < placeEnd && coinCount < 12 {
            if nearestOccupiedDistance(from: beat) >= 1.0 {
                // Alternate between a ground coin and a "skill" coin that
                // needs a jump to reach.
                let isHigh = (Int(beat * 2) % 3) == 0
                let yOffset: CGFloat = isHigh ? 120 : 36
                result.append(Obstacle(kind: .coin, beat: beat, yOffset: yOffset, width: 28, height: 28))
                coinCount += 1
                beat += 1.5
            } else {
                beat += 0.5
            }
        }

        return result.sorted { $0.beat < $1.beat }
    }

    private static func modeForSection(notes: [MIDINote], index: Int) -> GameMode {
        if index == 0 { return .cube }
        let percussion = notes.filter(\.isPercussion).count
        let lead = notes.filter(\.isLead).count
        let bass = notes.filter(\.isBass).count
        if lead > percussion && lead > bass { return .ship }
        if percussion > 24 { return .mini }
        if bass > lead && index % 3 == 0 { return .gravity }
        return .cube
    }

    private static func labelForSection(notes: [MIDINote], index: Int) -> String {
        if index == 0 { return "Get ready!" }
        let percussion = notes.filter(\.isPercussion).count
        let lead = notes.filter(\.isLead).count
        let bass = notes.filter(\.isBass).count
        if lead > percussion && lead > bass { return "Lead Line" }
        if percussion > bass { return "Drum Break" }
        if bass > 0 { return "Bass Drive" }
        return "MIDI Phrase"
    }

    private static func makeObstacles(notes: [MIDINote], mode: GameMode, startBeat: Double, endBeat: Double) -> [Obstacle] {
        var obstacles: [Obstacle] = []
        var occupiedBeats: [Double] = []
        let sorted = notes
            .filter { $0.beat >= startBeat + 2 && $0.beat < endBeat - 1 }
            .sorted { score($0) > score($1) }

        for note in sorted {
            guard obstacles.count < 52 else { break }
            guard !occupiedBeats.contains(where: { abs($0 - note.beat) < 0.35 }) else { continue }
            guard let obstacle = obstacle(for: note, mode: mode) else { continue }
            obstacles.append(obstacle)
            occupiedBeats.append(note.beat)
        }
        return sanitize(obstacles.sorted { $0.beat < $1.beat }, mode: mode)
    }

    /// Strips obstacle configurations that the player physically can't clear.
    ///
    /// Rules:
    ///   1. Tight pairs of ground obstacles are fine (the cube can clear both
    ///      in a single jump arc), but three packed-together ground obstacles
    ///      are not — the third one always lands on the player's downstroke.
    ///      So we reject a ground obstacle if the *previous two* are already
    ///      within ~0.8 beats of it.
    ///   2. Ground obstacles still need a tiny minimum gap so they don't
    ///      visually overlap.
    ///   3. An overhead obstacle at the same beat as a ground obstacle is a
    ///      vertical conflict — the player can't simultaneously jump over and
    ///      duck under, so we drop the overhead.
    ///
    /// Lower-priority candidates are dropped rather than shifted, so beat
    /// alignment with the music is preserved.
    private static func sanitize(_ obstacles: [Obstacle], mode: GameMode) -> [Obstacle] {
        // Window in which 3+ ground obstacles would form an unfair run.
        let tripleWindow = 0.8
        // Minimum spacing so two ground obstacles don't visually merge.
        let minGroundGap = 0.35
        // How close (in beats) an overhead obstacle has to be to a ground
        // obstacle before we treat them as a vertical conflict. A pure
        // "same beat" check (~0.5) isn't enough — the player's jump arc lasts
        // over a beat, so an overhead obstacle 0.7 beats before or after a
        // ground spike still sits inside the jump trajectory and is
        // effectively unclearable.
        let verticalConflictWindow = 0.9

        let sortedByBeat = obstacles.sorted { $0.beat < $1.beat }
        var groundAccepted: [Obstacle] = []
        var lastGroundBeat = -Double.infinity
        var secondLastGroundBeat = -Double.infinity
        var overheadCandidates: [Obstacle] = []

        for obstacle in sortedByBeat {
            if isGroundBlocking(obstacle, mode: mode) {
                let gapToLast = obstacle.beat - lastGroundBeat
                if gapToLast < minGroundGap { continue }
                // If the last two ground obstacles are themselves close, the
                // current one would make a triple — reject it.
                let lastPairSpan = lastGroundBeat - secondLastGroundBeat
                if gapToLast < tripleWindow && lastPairSpan < tripleWindow {
                    continue
                }
                groundAccepted.append(obstacle)
                secondLastGroundBeat = lastGroundBeat
                lastGroundBeat = obstacle.beat
            } else {
                overheadCandidates.append(obstacle)
            }
        }

        let overheadKept = overheadCandidates.filter { overhead in
            !groundAccepted.contains(where: { abs($0.beat - overhead.beat) < verticalConflictWindow })
        }

        return (groundAccepted + overheadKept).sorted { $0.beat < $1.beat }
    }

    /// True for obstacles that force the player off the ground (or off the
    /// ceiling, in gravity mode) — i.e. the only way past them is to jump/flip.
    private static func isGroundBlocking(_ obstacle: Obstacle, mode: GameMode) -> Bool {
        switch obstacle.kind {
        case .spike, .block:
            return mode != .gravity
        case .ceilingBlock, .ceilingSpike:
            return mode == .gravity
        case .floatingSpike, .beatOrb, .coin, .jumpPad:
            return false
        }
    }

    private static func score(_ note: MIDINote) -> Int {
        var value = note.velocity
        if note.isPercussion { value += 24 }
        if note.isBass { value += 12 }
        if note.isLead { value += 8 }
        return value
    }

    private static func obstacle(for note: MIDINote, mode: GameMode) -> Obstacle? {
        if note.isPercussion {
            switch note.note {
            case 35, 36:
                return Obstacle(kind: .block, beat: note.beat, yOffset: 0, width: 56, height: 50)
            case 38, 40:
                return Obstacle(kind: .beatOrb, beat: note.beat, yOffset: 124, width: 38, height: 38)
            case 42, 44, 46, 49, 51, 57:
                // Sit above the player's jump apex (~184pt at the slowest tempos)
                // so a hi-hat diamond can't land inside the arc of a kick jump.
                return Obstacle(kind: .floatingSpike, beat: note.beat, yOffset: 210, width: 32, height: 32)
            default:
                return Obstacle(kind: .spike, beat: note.beat, yOffset: 0, width: 26, height: 26)
            }
        }

        if note.isBass {
            return mode == .gravity
                ? Obstacle(kind: .ceilingBlock, beat: note.beat, yOffset: 0, width: 70, height: 70)
                : Obstacle(kind: .block, beat: note.beat, yOffset: 0, width: 78, height: 58)
        }

        if note.isLead {
            let yOffset = CGFloat(120 + min(120, max(0, note.note - 72) * 5))
            return mode == .ship
                ? Obstacle(kind: .ceilingSpike, beat: note.beat, yOffset: 0, width: 30, height: 30)
                : Obstacle(kind: .beatOrb, beat: note.beat, yOffset: yOffset, width: 34, height: 34)
        }

        if note.isHarmony {
            return Obstacle(kind: .spike, beat: note.beat, yOffset: 0, width: 28, height: 28)
        }
        return nil
    }
}
