//
//  SavedLevelsStore.swift
//  DashSmash
//
//  Persists levels the player chose to keep, so they can be replayed later.
//
//  Level metadata lives in UserDefaults. For MIDI-backed levels, the raw
//  `.mid` bytes are also written into the app's Documents directory so a
//  saved MIDI level can be replayed offline — no second BitMidi fetch and
//  no re-parsing the file on a cold launch.
//

import CryptoKit
import Foundation

final class SavedLevelsStore {
    static let shared = SavedLevelsStore()

    /// Hard cap on how many entries we keep. Oldest (bottom of the list)
    /// drops out when the cap is exceeded; the associated MIDI sidecar is
    /// removed at the same time so we don't leak files.
    private let maxEntries = 50

    private let key = "DashSmash.savedLevels.v3"
    private let defaults: UserDefaults
    private let midiDirectory: URL

    private init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        self.midiDirectory = docs.appendingPathComponent("SavedMIDIs", isDirectory: true)
        try? FileManager.default.createDirectory(at: midiDirectory, withIntermediateDirectories: true)
    }

    func loadAll() -> [Level] {
        guard let data = defaults.data(forKey: key) else { return [] }
        do {
            return try JSONDecoder().decode([Level].self, from: data)
        } catch {
            return []
        }
    }

    func save(_ level: Level) {
        save(level, midiData: nil)
    }

    /// Persist a level. For MIDI-backed levels, pass the raw `.mid` bytes so
    /// they can be replayed without re-downloading. Levels without MIDI data
    /// (procedural / Apple Music / library) just call this with nil.
    func save(_ level: Level, midiData: Data?) {
        var levels = loadAll()
        let key = dedupKey(for: level)
        // Dedup: a regenerated "same song" gets a fresh UUID but the same
        // track. Saving it again should silently keep the existing entry.
        if let existing = levels.firstIndex(where: { dedupKey(for: $0) == key }) {
            // If the caller now has MIDI bytes and we didn't before, opportunistically
            // backfill — otherwise leave the existing record alone.
            if let midiData, !midiSidecarExists(for: levels[existing]) {
                writeMIDISidecar(midiData, for: levels[existing])
            }
            return
        }
        levels.insert(level, at: 0)
        if let midiData {
            writeMIDISidecar(midiData, for: level)
        }
        evictIfNeeded(&levels)
        persist(levels)
    }

    /// Moves the matching saved level to the top of the list so that
    /// "Saved Levels" is naturally ordered by recent play. No-op when the
    /// level isn't in the store yet (e.g. the player just generated it and
    /// hasn't saved it).
    func touchLastPlayed(_ level: Level) {
        var levels = loadAll()
        let key = dedupKey(for: level)
        guard let index = levels.firstIndex(where: { dedupKey(for: $0) == key }) else { return }
        if index == 0 { return }
        let entry = levels.remove(at: index)
        levels.insert(entry, at: 0)
        persist(levels)
    }

    /// True when a level with the same content key is already saved. Lets the
    /// game-over UI show "Saved ✓" for regenerated copies, not just the exact
    /// `id` that's in the store.
    func isSaved(_ level: Level) -> Bool {
        let key = dedupKey(for: level)
        return loadAll().contains(where: { dedupKey(for: $0) == key })
    }

    func delete(_ level: Level) {
        let updated = loadAll().filter { $0.id != level.id }
        deleteMIDISidecar(for: level)
        persist(updated)
    }

    /// Returns the raw MIDI bytes saved alongside this level, or nil if
    /// no sidecar exists (the level isn't MIDI-backed, or was saved before
    /// MIDI persistence shipped).
    func midiData(for level: Level) -> Data? {
        let url = midiSidecarURL(for: level)
        return try? Data(contentsOf: url)
    }

    private func evictIfNeeded(_ levels: inout [Level]) {
        guard levels.count > maxEntries else { return }
        let toDrop = levels.suffix(levels.count - maxEntries)
        for level in toDrop {
            deleteMIDISidecar(for: level)
        }
        levels = Array(levels.prefix(maxEntries))
    }

    private func persist(_ levels: [Level]) {
        guard let data = try? JSONEncoder().encode(levels) else { return }
        defaults.set(data, forKey: key)
    }

    private func dedupKey(for level: Level) -> String {
        // Prefer the track source (MIDI page URL, MusicKit ID, library
        // persistentID) — that uniquely identifies the song the level was
        // built from. Fall back to song+band for procedurally generated
        // levels without a backing track.
        if let trackID = level.trackSourceID, !trackID.isEmpty {
            return "track:" + trackID
        }
        let song = level.songName.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
        let band = level.bandName.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
        return "name:\(song)::\(band)"
    }

    // MARK: - MIDI sidecar

    private func midiSidecarURL(for level: Level) -> URL {
        let hash = SHA256.hash(data: Data(dedupKey(for: level).utf8))
        let name = hash.map { String(format: "%02x", $0) }.joined() + ".mid"
        return midiDirectory.appendingPathComponent(name)
    }

    private func midiSidecarExists(for level: Level) -> Bool {
        FileManager.default.fileExists(atPath: midiSidecarURL(for: level).path)
    }

    private func writeMIDISidecar(_ data: Data, for level: Level) {
        try? data.write(to: midiSidecarURL(for: level), options: .atomic)
    }

    private func deleteMIDISidecar(for level: Level) {
        try? FileManager.default.removeItem(at: midiSidecarURL(for: level))
    }
}
