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

package space.rcmd.android.sizzle.model

/**
 * Musical primitives shared across the whole app: pitches, scales and time
 * signatures. Everything here is deliberately small, immutable and free of any
 * Android dependency so it can be unit-tested and reasoned about in isolation.
 */

/**
 * A MIDI note number, 0..127, where 60 == middle C (C4 in the convention we use).
 * We keep notes as plain Ints in the hot paths (patterns) for memory reasons and
 * only wrap them when we need naming/formatting helpers.
 */
object Pitch {
    const val LOWEST = 0
    const val HIGHEST = 127
    const val MIDDLE_C = 60

    private val NAMES = arrayOf(
        "C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-",
    )

    /** Human/tracker-readable name, e.g. 60 -> "C-4", 61 -> "C#4". */
    fun name(midi: Int): String {
        if (midi !in LOWEST..HIGHEST) return "..."
        val octave = midi / 12 - 1          // MIDI 12 == C0 in this convention
        return NAMES[midi % 12] + octave
    }

    /**
     * Inverse of [name]: parse "C-4" / "C#4" / "OFF" / "..." into a note value.
     * Returns [Cell.OFF] for a note-off, [Cell.EMPTY] for empty/unparseable input.
     * Used by the sizzletracker `.sng` importer.
     */
    fun parse(text: String): Int {
        val t = text.trim()
        if (t == "OFF" || t == "===") return Cell.OFF
        if (t.length < 3 || t.startsWith(".")) return Cell.EMPTY
        val idx = NAMES.indexOf(t.substring(0, 2))
        if (idx < 0) return Cell.EMPTY
        val octave = t.substring(2).toIntOrNull() ?: return Cell.EMPTY
        return ((octave + 1) * 12 + idx).coerceIn(LOWEST, HIGHEST)
    }
}

/**
 * The three time signatures the tracker supports. The key idea in this app is
 * that a *beat* is made of a fixed number of tracker *lines* (ticks), and that
 * number is what changes between signatures:
 *  - 3/4 -> 3 lines per beat
 *  - 4/4 -> 4 lines per beat
 *  - 5/4 -> 5 lines per beat
 * A *bar* is always [beatsPerBar] beats long (here, equal to the numerator).
 */
enum class TimeSignature(
    val label: String,
    val linesPerBeat: Int,
    val beatsPerBar: Int,
    /** Allowed pattern lengths (in lines) offered in the length dropdown. */
    val lengthOptions: List<Int>,
) {
    THREE_FOUR("3/4", linesPerBeat = 3, beatsPerBar = 3, lengthOptions = listOf(12, 24, 48)),
    FOUR_FOUR("4/4", linesPerBeat = 4, beatsPerBar = 4, lengthOptions = listOf(16, 32, 64)),
    FIVE_FOUR("5/4", linesPerBeat = 5, beatsPerBar = 5, lengthOptions = listOf(20, 40, 80));

    /** Lines per bar = lines-per-beat * beats-per-bar. */
    val linesPerBar: Int get() = linesPerBeat * beatsPerBar
}

/**
 * A musical scale expressed as semitone offsets from the root (0..11).
 * Used by the tracker's note stepping (keyboard/gamepad +/- and the touch editor)
 * so the user can only land on in-key notes when a non-chromatic scale is selected.
 */
enum class Scale(val label: String, val intervals: List<Int>) {
    CHROMATIC("Chromatic", listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)),
    MAJOR("Major", listOf(0, 2, 4, 5, 7, 9, 11)),
    MINOR("Natural Minor", listOf(0, 2, 3, 5, 7, 8, 10)),
    HARMONIC_MINOR("Harmonic Minor", listOf(0, 2, 3, 5, 7, 8, 11)),
    DORIAN("Dorian", listOf(0, 2, 3, 5, 7, 9, 10)),
    PHRYGIAN("Phrygian", listOf(0, 1, 3, 5, 7, 8, 10)),
    MIXOLYDIAN("Mixolydian", listOf(0, 2, 4, 5, 7, 9, 10)),
    PENTATONIC_MAJOR("Pentatonic Major", listOf(0, 2, 4, 7, 9)),
    PENTATONIC_MINOR("Pentatonic Minor", listOf(0, 3, 5, 7, 10)),
    BLUES("Blues", listOf(0, 3, 5, 6, 7, 10));

    /**
     * Returns the next in-scale MIDI note above [fromMidi] when [direction] is
     * +1, or below when -1, given a scale [root] (0=C .. 11=B). If [fromMidi] is
     * off-scale we snap to the nearest in-scale note first. This is the single
     * function that powers the tracker's note stepping (keyboard/gamepad + editor).
     */
    fun step(fromMidi: Int, root: Int, direction: Int): Int {
        if (this == CHROMATIC) {
            return (fromMidi + direction).coerceIn(Pitch.LOWEST, Pitch.HIGHEST)
        }
        // Build the set of pitch-classes that are in-key.
        val inKey = intervals.map { (it + root) % 12 }.toSortedSet()
        var candidate = fromMidi
        // Walk one semitone at a time until we hit an in-key pitch-class.
        do {
            candidate += direction
            if (candidate !in Pitch.LOWEST..Pitch.HIGHEST) {
                return fromMidi // hit the edge, don't move
            }
        } while ((candidate % 12) !in inKey)
        return candidate
    }
}
