// SPDX-FileCopyrightText: 2026 Reactorcoremeltdown
// SPDX-License-Identifier: GPL-3.0-or-later

package space.rcmd.android.sizzle.io

import space.rcmd.android.sizzle.audio.SampleStore
import space.rcmd.android.sizzle.model.AmbiencePresets
import space.rcmd.android.sizzle.model.NesSynthPresets
import space.rcmd.android.sizzle.model.Project
import space.rcmd.android.sizzle.model.SamplerPads
import space.rcmd.android.sizzle.model.ToolboxSlot
import space.rcmd.android.sizzle.model.ToolboxType
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream

/**
 * The on-disk preset library, living in the app's scoped storage. Presets are the
 * same plain, human- and machine-readable [PresetIo] text used for clipboard
 * copy/paste, laid out one directory per device type so a person browsing the
 * files finds them sorted by instrument/effect:
 *
 *     <baseDir>/NES_SYNTH/Lead.szp
 *     <baseDir>/SAMPLER/Kick.szp   + <baseDir>/SAMPLER/Kick.wav
 *     <baseDir>/DELAY/Slapback.szp
 *
 * The Sampler is special: its preset references the audio as a `sampleFile=` path
 * (relative to the preset, or absolute), and the actual WAV is stored next to the
 * preset. That makes a sampler preset self-contained on disk, shareable as a
 * `.zip` bundle (preset + wav), and importable the same way.
 */
class PresetLibrary(private val baseDir: File) {

    /** The directory holding presets for one device [type] (created on demand). */
    fun typeDir(type: ToolboxType): File = File(baseDir, type.name).apply { mkdirs() }

    /**
     * Write the built-in factory presets to disk (as plain [PresetIo] `.szp` files)
     * so they appear in the standard preset browser next to user presets. Call once
     * at startup. Idempotent and non-destructive: a per-type `.seeded` marker means
     * a preset the user later renames or deletes is NOT resurrected on the next
     * launch. Seeds the Ambience reverb's factory spaces and the synth's chiptune
     * presets.
     */
    fun seedFactoryPresets() {
        seedType(ToolboxType.AMBIENCE, AmbiencePresets.FACTORY.map { it.name }) { slot, name ->
            AmbiencePresets.apply(slot, AmbiencePresets.FACTORY.first { it.name == name })
        }
        seedType(ToolboxType.NES_SYNTH, NesSynthPresets.FACTORY.map { it.name }) { slot, name ->
            NesSynthPresets.apply(slot, NesSynthPresets.FACTORY.first { it.name == name })
        }
    }

    /**
     * Seed one device [type]'s factory presets. The marker records the NAMES already
     * seeded (one per line), so a factory preset added in a later app version is seeded
     * on the next launch while a preset the user deleted is NOT resurrected (its name is
     * still in the marker). Legacy markers held only "1"; on that first upgrade any
     * factory preset not currently on disk is (re)written once, then names are tracked
     * from then on.
     */
    private fun seedType(type: ToolboxType, names: List<String>, apply: (ToolboxSlot, String) -> Unit) {
        val dir = typeDir(type)
        val marker = File(dir, SEEDED_MARKER)
        val seeded = if (marker.exists())
            marker.readLines().map { it.trim() }.filterTo(HashSet()) { it.isNotEmpty() && it != "1" }
        else HashSet()
        var changed = false
        names.forEach { name ->
            if (name in seeded) return@forEach // seeded before; respect a later user delete
            val file = presetFile(type, name)
            if (!file.exists()) {
                val slot = ToolboxSlot(0).apply {
                    fill(type)
                    apply(this, name)
                    this.name = name
                }
                file.writeText(PresetIo.export(slot))
            }
            seeded.add(name)
            changed = true
        }
        if (changed || !marker.exists()) marker.writeText(seeded.joinToString("\n"))
    }

    /** Names (without extension) of the presets stored for [type], alphabetically. */
    fun list(type: ToolboxType): List<String> =
        typeDir(type).listFiles { f -> f.isFile && f.name.endsWith(PRESET_EXT) }
            ?.map { it.name.removeSuffix(PRESET_EXT) }
            ?.sorted()
            ?: emptyList()

    fun presetFile(type: ToolboxType, name: String): File =
        File(typeDir(type), sanitize(name) + PRESET_EXT)

    /**
     * Save [slot] as a named preset. For the Sampler the referenced sample is
     * written next to the preset as a WAV and referenced by a relative path, so
     * the library copy is self-contained. Returns the written preset file.
     */
    fun save(slot: ToolboxSlot, name: String): File {
        val type = slot.type ?: error("cannot save an empty slot")
        val dir = typeDir(type)
        val safe = sanitize(name)
        slot.name = name
        val preset = File(dir, "$safe$PRESET_EXT")
        when (type) {
            ToolboxType.SAMPLER -> {
                // Write each assigned pad's sample as its own WAV, referenced
                // relatively. Record the reference on the slot too so a restored
                // project can rehydrate the audio from the library at startup.
                val padRefs = LinkedHashMap<Int, String>()
                for (p in 0 until SamplerPads.COUNT) {
                    val sample = SampleStore.get(slot.string(SamplerPads.key(p)))
                    if (sample == null) { slot.values.remove(padFileKey(p)); continue }
                    val wavName = "${safe}_pad$p.wav"
                    File(dir, wavName).writeBytes(SampleStore.encodeWav(sample))
                    padRefs[p] = wavName
                    slot.set(padFileKey(p), wavName)
                }
                preset.writeText(exportSampler(slot, padRefs))
            }
            ToolboxType.SOUNDFONT -> {
                // The extracted SF2/XI sample travels with the preset as a WAV.
                val sample = SampleStore.get(slot.string("sfSample"))
                val rel = if (sample != null) {
                    File(dir, "${safe}_sf.wav").writeBytes(SampleStore.encodeWav(sample))
                    slot.set("sfFile", "${safe}_sf.wav")
                    "${safe}_sf.wav"
                } else {
                    slot.values.remove("sfFile")
                    null
                }
                preset.writeText(exportSoundFont(slot, rel))
            }
            else -> preset.writeText(PresetIo.export(slot))
        }
        return preset
    }

    /**
     * Delete the named preset for [type]. For the Sampler its co-located WAV is
     * removed too (only when referenced by a relative path inside our storage — an
     * absolute path pointing elsewhere is left untouched). Returns true if the
     * preset existed.
     */
    fun delete(type: ToolboxType, name: String): Boolean {
        val dir = typeDir(type)
        val base = sanitize(name)
        val preset = File(dir, "$base$PRESET_EXT")
        if (!preset.exists()) return false
        // Remove any co-located sample WAVs this preset referenced (relative only).
        sampleFileRefs(preset).forEach { rel ->
            if (!File(rel).isAbsolute) File(dir, rel).takeIf { it.exists() }?.delete()
        }
        if (type == ToolboxType.SAMPLER) {
            for (p in 0 until SamplerPads.COUNT) File(dir, "${base}_pad$p.wav").delete()
        } else if (type == ToolboxType.SOUNDFONT) {
            File(dir, "${base}_sf.wav").delete()
        }
        return preset.delete()
    }

    /**
     * Reload the sample audio referenced by every Sampler / SoundFont slot in
     * [project] back into the [SampleStore]. Call at startup: a restored project's
     * autosave keeps the sample file references (`padNFile` / `sfFile`) but not the
     * decoded PCM, so without this the pads of a selected preset would be silent
     * until re-imported. Missing files are skipped.
     */
    fun rehydrate(project: Project) {
        project.toolbox.forEach { slot ->
            when (slot.type) {
                ToolboxType.SAMPLER -> resolvePads(slot, typeDir(ToolboxType.SAMPLER))
                ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(ToolboxType.SOUNDFONT))
                else -> {}
            }
        }
    }

    /** Load the named preset for [type] into [slot]. A missing, unreadable or
     *  malformed preset returns false instead of throwing. */
    fun load(slot: ToolboxSlot, type: ToolboxType, name: String): Boolean = runCatching {
        val file = File(typeDir(type), sanitize(name) + PRESET_EXT)
        if (!file.exists()) return false
        if (!PresetIo.import(slot, file.readText())) return false
        val dir = file.parentFile ?: typeDir(type)
        when (slot.type) {
            ToolboxType.SAMPLER -> resolvePads(slot, dir)
            ToolboxType.SOUNDFONT -> resolveSoundFont(slot, dir)
            else -> {}
        }
        true
    }.getOrDefault(false)

    /**
     * Import a preset supplied as raw text (e.g. picked from Files). Loads it into
     * [slot] and persists a copy in the library so it appears in the browser.
     * Sampler samples referenced by an absolute path are pulled in; relative refs
     * need the zip bundle form (see [importZip]).
     */
    fun importText(slot: ToolboxSlot, text: String): Boolean = runCatching {
        if (!PresetIo.import(slot, text)) return false
        val type = slot.type ?: return false
        when (type) {
            ToolboxType.SAMPLER -> resolvePads(slot, typeDir(type))
            ToolboxType.SOUNDFONT -> resolveSoundFont(slot, typeDir(type))
            else -> {}
        }
        save(slot, slot.name.ifBlank { type.displayName })
        true
    }.getOrDefault(false)

    /**
     * Import a preset packaged as a `.zip` bundle (a `.szp` plus its sample WAVs) —
     * used for the Sampler and SoundFont. Entries are extracted to a scratch dir,
     * the contained preset is loaded and its samples resolved, then it is re-saved
     * into the proper type folder so it joins the library self-contained.
     */
    fun importZip(slot: ToolboxSlot, input: InputStream): Boolean = runCatching {
        val scratch = File(baseDir, IMPORT_DIR).apply { mkdirs(); listFiles()?.forEach { it.delete() } }
        var presetText: String? = null
        ZipInputStream(input).use { zis ->
            var entry: ZipEntry? = zis.nextEntry
            while (entry != null) {
                // Use only the base name to guard against zip-slip path traversal.
                val safeName = File(entry.name).name
                // Skip directories, entries with no usable file name, and macOS
                // AppleDouble junk (a "__MACOSX/" folder plus "._name" resource-fork
                // twins). The latter matters: "._preset.szp" also ends in .szp, so
                // without this it would clobber the real preset with binary garbage.
                val junk = entry.name.startsWith("__MACOSX") || safeName.startsWith("._")
                if (!entry.isDirectory && safeName.isNotBlank() && !junk) {
                    val target = File(scratch, safeName)
                    target.outputStream().use { zis.copyTo(it) }
                    // Keep the first real preset; a later entry can't overwrite it.
                    if (presetText == null && target.name.endsWith(PRESET_EXT)) presetText = target.readText()
                }
                zis.closeEntry()
                entry = zis.nextEntry
            }
        }
        // A zip without a valid .szp preset (or with an unrecognised one) is rejected.
        val text = presetText ?: return false
        if (!PresetIo.import(slot, text)) return false
        when (slot.type) {
            ToolboxType.SAMPLER -> resolvePads(slot, scratch)
            ToolboxType.SOUNDFONT -> resolveSoundFont(slot, scratch)
            else -> {}
        }
        // Persist a self-contained copy into the real library folder.
        save(slot, slot.name.ifBlank { slot.type?.displayName ?: "preset" })
        true
    }.getOrDefault(false)

    /**
     * Produce a shareable file for a stored preset: a self-contained `.zip`
     * (preset + sample WAVs) for the Sampler / SoundFont, or the plain `.szp` for
     * everything else. The result lives under [baseDir] so the app's FileProvider
     * can expose it. Returns null if the preset does not exist.
     */
    fun bundleForShare(type: ToolboxType, name: String): File? {
        val preset = File(typeDir(type), sanitize(name) + PRESET_EXT)
        if (!preset.exists()) return null
        if (type != ToolboxType.SAMPLER && type != ToolboxType.SOUNDFONT) return preset

        val bundles = File(baseDir, BUNDLE_DIR).apply { mkdirs() }
        val zip = File(bundles, sanitize(name) + ".zip")
        ZipOutputStream(zip.outputStream()).use { zos ->
            addEntry(zos, preset, preset.name)
            sampleFileRefs(preset).forEach { rel ->
                val wav = File(rel).let { if (it.isAbsolute) it else File(preset.parentFile, rel) }
                if (wav.exists()) addEntry(zos, wav, wav.name)
            }
        }
        return zip
    }

    // ---------------------------------------------------------------- internals

    /** Key under which a pad's sample file path is written in the preset text. */
    private fun padFileKey(pad: Int): String = "pad${pad}File"

    /** Serialize a Sampler slot: global volume, plus each assigned pad's sample
     *  file ([padRefs]) and its slice trim. */
    private fun exportSampler(slot: ToolboxSlot, padRefs: Map<Int, String>): String = buildString {
        appendLine(PresetIo.HEADER)
        appendLine("TYPE=${ToolboxType.SAMPLER.name}")
        appendLine("NAME=${slot.name}")
        appendLine("volume=${slot.string("volume", "0.8")}")
        for ((pad, ref) in padRefs) {
            appendLine("${padFileKey(pad)}=$ref")
            appendLine("${SamplerPads.sliceStartKey(pad)}=${slot.string(SamplerPads.sliceStartKey(pad), "0.0")}")
            appendLine("${SamplerPads.sliceEndKey(pad)}=${slot.string(SamplerPads.sliceEndKey(pad), "1.0")}")
        }
    }

    /** Resolve every `padNFile` reference (relative to [presetDir], or absolute)
     *  into decoded samples in the [SampleStore], mapping each to its pad. */
    private fun resolvePads(slot: ToolboxSlot, presetDir: File) {
        for (pad in 0 until SamplerPads.COUNT) {
            val rel = slot.string(padFileKey(pad))
            if (rel.isBlank()) continue
            resolveWav(rel, presetDir)?.let { slot.set(SamplerPads.key(pad), it) }
        }
    }

    /** Serialize a SoundFont slot: volume, root note, and its extracted sample WAV. */
    private fun exportSoundFont(slot: ToolboxSlot, sampleRel: String?): String = buildString {
        appendLine(PresetIo.HEADER)
        appendLine("TYPE=${ToolboxType.SOUNDFONT.name}")
        appendLine("NAME=${slot.name}")
        appendLine("volume=${slot.string("volume", "0.8")}")
        appendLine("sfRoot=${slot.string("sfRoot", "60.0")}")
        if (sampleRel != null) appendLine("sfFile=$sampleRel")
    }

    /** Resolve a SoundFont preset's `sfFile` into the [SampleStore] and point the
     *  slot's `sfSample` at it (its `sfRoot` was already restored by PresetIo). */
    private fun resolveSoundFont(slot: ToolboxSlot, presetDir: File) {
        val rel = slot.string("sfFile")
        if (rel.isBlank()) return
        resolveWav(rel, presetDir)?.let { slot.set("sfSample", it) }
    }

    /** Decode a referenced WAV (relative to [presetDir], or absolute) into the
     *  [SampleStore] and return its id, or null if missing/undecodable. */
    private fun resolveWav(rel: String, presetDir: File): String? {
        val wav = File(rel).let { if (it.isAbsolute) it else File(presetDir, rel) }
        if (!wav.exists()) return null
        val sample = SampleStore.decodeWav(wav.readBytes()) ?: return null
        val id = "file:" + wav.absolutePath
        SampleStore.put(id, sample)
        return id
    }

    /** Every sample file path a preset references (any `…File=` line — covers both
     *  the Sampler's `padNFile` and the SoundFont's `sfFile`). */
    private fun sampleFileRefs(preset: File): List<String> =
        preset.readLines().mapNotNull { line ->
            val t = line.trim()
            val eq = t.indexOf('=')
            if (eq > 0 && t.substring(0, eq).endsWith("File")) t.substring(eq + 1).trim() else null
        }

    private fun addEntry(zos: ZipOutputStream, file: File, entryName: String) {
        zos.putNextEntry(ZipEntry(entryName))
        file.inputStream().use { it.copyTo(zos) }
        zos.closeEntry()
    }

    /** Keep names safe as file names while staying human-readable. */
    private fun sanitize(name: String): String =
        name.trim().replace(Regex("[^A-Za-z0-9._ -]"), "_").ifBlank { "preset" }

    private companion object {
        const val PRESET_EXT = ".szp"
        const val BUNDLE_DIR = ".bundles"
        const val IMPORT_DIR = ".import"
        const val SEEDED_MARKER = ".seeded"
    }
}
