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

package space.rcmd.android.sizzle.ui.tracker

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import space.rcmd.android.sizzle.model.Cell
import space.rcmd.android.sizzle.model.CellColumn
import space.rcmd.android.sizzle.model.Pattern
import space.rcmd.android.sizzle.model.Pitch
import space.rcmd.android.sizzle.ui.AppViewModel
import space.rcmd.android.sizzle.ui.components.GlyphCache
import space.rcmd.android.sizzle.ui.theme.LocalRetro

/**
 * The demoscene pattern grid, drawn as ONE [Canvas] instead of a tree of ~180
 * per-cell composables. Collapsing it into a single draw pass (with a cached
 * [rememberTextMeasurer] so repeated cell strings like "···" are measured once)
 * removes the composition/layout churn that made the grid feel sluggish as the
 * playhead moved — the whole grid is now a couple of hundred draw calls per frame.
 *
 * Visuals: beat and bar rows are tinted distinctly; the four vertical tracks get
 * an alternating tint + separator lines; the cursor cell and playhead row are
 * highlighted. The only touch gesture is tap-to-focus; notes are entered with the
 * punch-in keyboard or external controllers.
 */
@Composable
fun PatternGrid(vm: AppViewModel, modifier: Modifier = Modifier) {
    val c = LocalRetro.current
    // Read in composition so structural changes (edits, resize, active-block swap —
    // all of which bump revision) re-fetch `pattern`/`sig` and recompose. Playback
    // does NOT bump revision, so this never fires per frame during playback.
    val structureKey = vm.revision
    @Suppress("UNUSED_EXPRESSION") structureKey
    // Held as a State and read ONLY inside the draw/gesture lambdas below, never in
    // composition: a playhead tick then invalidates the draw phase alone, skipping
    // recomposition + layout entirely.
    val transportState = vm.transport.collectAsState()
    val pattern = vm.project.activePattern()
    val sig = vm.project.timeSignature
    val measurer = rememberTextMeasurer()
    val glyphs = remember(measurer) { GlyphCache(measurer) }

    // Cache the text styles per palette (fontSize is resolved with the ambient density).
    val mono = FontFamily.Monospace
    val styleText = remember(c) { TextStyle(color = c.text, fontFamily = mono, fontSize = 12.sp) }
    val styleDimNote = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
    val styleVel = remember(c) { TextStyle(color = c.accent, fontFamily = mono, fontSize = 12.sp) }
    val styleChan = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 12.sp) }
    val styleGutter = remember(c) { TextStyle(color = c.textDim, fontFamily = mono, fontSize = 11.sp) }

    BoxWithConstraints(modifier.fillMaxSize().background(c.background)) {
        val density = LocalDensity.current
        val rowPx = with(density) { 20.dp.toPx() }
        val gutterPx = with(density) { 30.dp.toPx() }
        val widthPx = with(density) { maxWidth.toPx() }
        val heightPx = with(density) { maxHeight.toPx() }

        val trackPx = (widthPx - gutterPx) / Pattern.TRACK_COUNT
        val notePx = trackPx * 0.5f
        val velPx = trackPx * 0.25f

        val visibleRows = (heightPx / rowPx).toInt().coerceAtLeast(1)
        val maxScrollPx = (pattern.length * rowPx - heightPx).coerceAtLeast(0f)

        // Vertical scroll model. By default the view FOLLOWS — it auto-centres on the
        // playhead while playing, else on the cursor. A finger flick detaches it into
        // free inertial scrolling; switching to another block re-attaches. Both states
        // are keyed on `pattern`, so selecting a different block re-inits them (== the
        // "resume following on block switch" behaviour).
        val following = remember(pattern) { mutableStateOf(true) }
        val scrollPx = remember(pattern) { mutableStateOf(0f) }

        // Pixel scroll offset the view auto-follows (top of viewport, 0 == line 0).
        fun autoScrollPx(): Float {
            val t = transportState.value
            val focusLine = if (t.isPlaying) t.currentLine else vm.cursorLine
            return (focusLine * rowPx + rowPx / 2f - heightPx / 2f).coerceIn(0f, maxScrollPx)
        }
        // The offset actually shown: the followed one, or the free one once detached.
        fun effScrollPx(): Float =
            if (following.value) autoScrollPx() else scrollPx.value.coerceIn(0f, maxScrollPx)

        // Fling/drag scrolling. The first delta seeds the free position from wherever
        // the followed view currently sits, then detaches following.
        val scrollState = remember(pattern, rowPx, heightPx, maxScrollPx) {
            ScrollableState { delta ->
                val old = if (following.value) autoScrollPx() else scrollPx.value
                following.value = false
                val next = (old - delta).coerceIn(0f, maxScrollPx)
                scrollPx.value = next
                old - next // consumed (partial at the ends stops the fling)
            }
        }

        fun hitTest(x: Float, y: Float): Triple<Int, CellColumn, Int>? {
            if (x < gutterPx) return null
            val line = ((effScrollPx() + y) / rowPx).toInt().coerceIn(0, pattern.length - 1)
            val tx = x - gutterPx
            val track = (tx / trackPx).toInt().coerceIn(0, Pattern.TRACK_COUNT - 1)
            val within = tx - track * trackPx
            val col = when {
                within < notePx -> CellColumn.NOTE
                within < notePx + velPx -> CellColumn.VELOCITY
                else -> CellColumn.CHANNEL
            }
            return Triple(track, col, line)
        }

        Canvas(
            Modifier
                .fillMaxSize()
                // Inertial vertical scrolling (fling), detaching the auto-follow.
                .scrollable(orientation = Orientation.Vertical, state = scrollState)
                .pointerInput(pattern, widthPx, heightPx) {
                    // Tap moves the cursor to the cell/column. Note entry is done with
                    // the punch-in keyboard or external controllers — there is no
                    // long-press cell editor.
                    detectTapGestures { off ->
                        hitTest(off.x, off.y)?.let { (t, col, line) -> vm.focusCell(t, line, col) }
                    }
                },
        ) {
            // Subscribe the DRAW PHASE to edit revisions. Model mutations (edit,
            // clear, DEL/paste) bump revision but touch no other snapshot state the
            // draw reads, so without this a delete would not invalidate the Canvas
            // even though composition re-ran (the draw lambda is memoised identical).
            // Reading it here redraws on every edit, draw-only.
            @Suppress("UNUSED_EXPRESSION") vm.revision
            // Draw-phase reads: transport (playhead) and, via firstRow(), the cursor
            // scroll position. A change to either invalidates THIS DRAW only — no
            // recomposition, no relayout — which is what keeps the playhead smooth.
            val transport = transportState.value
            // Fractional scroll: the first visible line plus a sub-row pixel shift so
            // flinging scrolls smoothly rather than snapping row-by-row.
            val scroll = effScrollPx()
            val firstLine = (scroll / rowPx).toInt()
            val yShift = -(scroll - firstLine * rowPx)
            // Selection rectangle (draw-phase reads → redraw as the cursor drags it).
            val selActive = vm.hasSelection
            val selTracks = vm.selTrackRange
            val selLines = vm.selLineRange

            // 1) Alternating vertical track tints + separators (full height).
            for (t in 0 until Pattern.TRACK_COUNT) {
                val x0 = gutterPx + t * trackPx
                if (t % 2 == 1) {
                    drawRect(c.grid.copy(alpha = 0.35f), Offset(x0, 0f), Size(trackPx, heightPx))
                }
                drawRect(c.grid, Offset(x0, 0f), Size(1f, heightPx)) // separator line
            }

            // 2) Rows: beat/bar/playhead backgrounds + cursor + text. One extra row so
            // the partially-scrolled bottom row is filled; y may start slightly above 0.
            for (screen in 0..visibleRows) {
                val line = firstLine + screen
                if (line >= pattern.length) break
                if (line < 0) continue
                val y = yShift + screen * rowPx

                val rowColor = when {
                    transport.isPlaying && line == transport.currentLine -> c.playhead.copy(alpha = 0.22f)
                    line % sig.linesPerBar == 0 -> c.bar.copy(alpha = 0.7f)
                    line % sig.linesPerBeat == 0 -> c.beat.copy(alpha = 0.7f)
                    else -> Color.Transparent
                }
                if (rowColor != Color.Transparent) {
                    drawRect(rowColor, Offset(0f, y), Size(widthPx, rowPx))
                }

                // Gutter line number (hex), from the precomputed table.
                drawCentered(glyphs, lineText(line), styleGutter, 2f, y, gutterPx, rowPx)

                for (t in 0 until Pattern.TRACK_COUNT) {
                    val cell = pattern.cell(t, line)
                    val baseX = gutterPx + t * trackPx
                    val focused = vm.cursorTrack == t && vm.cursorLine == line

                    // Selection wash (whole cell, under the cursor highlight).
                    if (selActive && t in selTracks && line in selLines) {
                        drawRect(c.accent.copy(alpha = 0.18f), Offset(baseX, y), Size(trackPx, rowPx))
                    }

                    // Cursor cell highlight.
                    if (focused) {
                        val (cx, cw) = when (vm.cursorColumn) {
                            CellColumn.NOTE -> baseX to notePx
                            CellColumn.VELOCITY -> (baseX + notePx) to velPx
                            CellColumn.CHANNEL -> (baseX + notePx + velPx) to velPx
                        }
                        drawRect(c.accent.copy(alpha = 0.30f), Offset(cx, y), Size(cw, rowPx))
                    }

                    // NOTE column reads bright even when empty (bright placeholder
                    // dots, per spec). VEL/CHAN use dim dots when empty and their
                    // coloured value when filled, so only the note column is bright.
                    drawCentered(glyphs, noteText(cell), styleText, baseX + 2f, y, notePx, rowPx)
                    drawCentered(glyphs, velText(cell), if (cell.isEmpty) styleDimNote else styleVel,
                        baseX + notePx + 2f, y, velPx, rowPx)
                    drawCentered(glyphs, chanText(cell), if (cell.isEmpty) styleDimNote else styleChan,
                        baseX + notePx + velPx + 2f, y, velPx, rowPx)
                }
            }
        }
    }
}

/** Draw [text] vertically centred in a cell of the given width/height. */
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCentered(
    glyphs: GlyphCache,
    text: String,
    style: TextStyle,
    x: Float,
    y: Float,
    cellWidth: Float,
    cellHeight: Float,
) {
    val layout = glyphs.measure(text, style)
    drawText(layout, topLeft = Offset(x, y + (cellHeight - layout.size.height) / 2f))
}

// Precomputed fixed-width text tables. The grid draws on every playhead line and
// every scroll pixel, so formatting these on the fly (toString/padStart) allocated a
// fresh String per cell per frame — steady draw-phase garbage that drove GC. Building
// each once at class-load makes the whole draw allocation-free.
private val HEX2 = Array(256) { it.toString(16).uppercase().padStart(2, '0') }   // 00..FF
private val NOTE_NAMES = Array(128) { Pitch.name(it) }                           // MIDI 0..127
private val CHAN2 = Array(100) { it.toString().padStart(2, '0') }               // 00..99

/** Gutter line number (hex), from the [HEX2] table (falls back for out-of-range). */
private fun lineText(line: Int): String =
    if (line in HEX2.indices) HEX2[line] else line.toString(16).uppercase().padStart(2, '0')

// ---- cell -> text helpers (the tracker's fixed-width formatting) ----
private fun noteText(cell: Cell): String = when {
    cell.isEmpty -> "···"
    cell.isNoteOff -> "==="
    else -> NOTE_NAMES.getOrElse(cell.note) { Pitch.name(cell.note) }
}
// A note-off ("===") has no velocity/channel of its own — it's tied to its track —
// so those columns read ".." just like an empty cell.
private fun velText(cell: Cell): String =
    if (cell.isEmpty || cell.isNoteOff) ".." else HEX2.getOrElse(cell.velocity) { cell.velocity.toString(16).uppercase().padStart(2, '0') }
private fun chanText(cell: Cell): String =
    if (cell.isEmpty || cell.isNoteOff) ".." else CHAN2.getOrElse(cell.channel) { cell.channel.toString().padStart(2, '0') }
