Pith - grain
package com.vgmlr.grain

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.ContentPaste
import androidx.compose.material.icons.outlined.Park
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.isSpecified
import kotlin.math.roundToInt
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.style.TextAlign

internal const val TITLE_IDLE_TEXT = "grain"
private const val CLEAR_HOLD_MS = 500L
private const val GROUP_IDLE_MS = 5_000L
private const val ANIM_IN = 220
private const val FADE_IN = 180
private const val ANIM_OUT = 220
private const val FADE_OUT = 140
private const val KEY_FILL = 0.9f // key/cell
internal val KEY_BUMP = 1.dp
private const val CLEAR_ALPHA = 0.4f
private val BAR_LABEL_PAD = 14.dp
private val CLEAR_SLOT = 48.dp
private val MENU_ICON = STACK_ICON + 2.dp
private val COPY_ICON = STACK_ICON - 2.dp
internal val UNIT_ARROW = 18.dp
private val UNIT_ITEM_PAD = 14.dp
private val UNIT_ITEM_PAD_V = 12.dp
private val TIP_PAD_H = 10.dp
private val TIP_PAD_V = 4.dp
private val TIP_FS = 15.sp
private val TIP_ARROW_W = 8.dp
private val TIP_RADIUS = 4.dp
private val TIP_ARROW_H = 14.dp
private val UNIT_WEIGHT = FontWeight.Medium
internal val UNIT_STYLE = TextStyle(
    fontSize = UNIT_FS,
    fontFamily = FontFamily.SansSerif,
)
private val MINOR_KEYS = setOf(Pad.CLR, Pad.MEM, Pad.PAR)

enum class StackGroup { NONE, CONVERT, MENU }

// unit-agnostic
internal fun keyArea(w: Float, pad: Float, bias: Bias): Float = when {
    bias == Bias.FULL -> w
    else -> minOf(w * 3 / 4, w - if (bias.padded) pad.coerceIn(0f, w) else 0f)
}

internal fun keyAuto(w: Float, pad: Float, bias: Bias, bump: Float): Float =
    keyArea(w, pad, bias) / 4 * KEY_FILL + bump

@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun PressBox(
    size: Dp,
    bg: Color,
    modifier: Modifier = Modifier,
    border: BorderStroke? = null,
    enabled: Boolean = true,
    onLongClick: (() -> Unit)? = null,
    longPressMs: Long = CLEAR_HOLD_MS,
    onClick: () -> Unit,
    content: @Composable BoxScope.() -> Unit
) {
    val haptic = LocalHapticFeedback.current
    val base = LocalViewConfiguration.current
    val config = remember(base, longPressMs) {
        object : ViewConfiguration by base {
            override val longPressTimeoutMillis = longPressMs
        }
    }
    val shape = RoundedCornerShape(BTN_RADIUS)
    val press = if (onLongClick == null) {
        Modifier.clickable(enabled = enabled, onClick = onClick)
    } else {
        Modifier.combinedClickable(
            enabled = enabled,
            onLongClick = {
                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
                onLongClick()
            },
            onClick = onClick
        )
    }
    CompositionLocalProvider(
        LocalViewConfiguration provides if (onLongClick == null) base else config
    ) {
        Box(
            modifier
                .then(if (size.isSpecified) Modifier.size(size) else Modifier)
                .clip(shape)
                .background(bg)
                .then(if (border != null) Modifier.border(border, shape) else Modifier)
                .then(press),
            contentAlignment = Alignment.Center,
            content = content
        )
    }
}

@Composable
internal fun StackButton(
    icon: ImageVector,
    desc: String,
    enabled: Boolean = true,
    iconSize: Dp = STACK_ICON,
    onClick: () -> Unit
) = PressBox(STACK_BTN, BAR_BG, enabled = enabled, onClick = onClick) {
    Icon(
        icon,
        contentDescription = desc,
        modifier = Modifier.size(iconSize),
        tint = BAR_INK.copy(alpha = if (enabled) 1f else DISABLED_ALPHA)
    )
}

// right pointer
private object TipShape : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline = with(density) {
        val r = TIP_RADIUS.toPx()
        val aw = TIP_ARROW_W.toPx()
        val ah = TIP_ARROW_H.toPx() / 2
        val bw = size.width - aw
        val cy = size.height / 2
        val body = Path().apply {
            addRoundRect(RoundRect(0f, 0f, bw, size.height, CornerRadius(r)))
        }
        val tip = Path().apply {
            moveTo(bw - 1f, cy - ah) // overlap
            lineTo(size.width, cy)
            lineTo(bw - 1f, cy + ah)
            close()
        }
        Outline.Generic(Path().apply { op(body, tip, PathOperation.Union) })
    }
}

@Composable
private fun TipBubble(text: String) {
    Text(
        text,
        modifier = Modifier
            .clip(TipShape)
            .background(PLATE_BG)
            .border(EDGE, TipShape)
            .padding(
                start = TIP_PAD_H,
                end = TIP_PAD_H + TIP_ARROW_W - 1.dp,
                top = TIP_PAD_V,
                bottom = TIP_PAD_V
            ),
        color = PAGE_INK,
        fontSize = TIP_FS,
        fontFamily = FontFamily.SansSerif,
        maxLines = 1,
        softWrap = false
    )
}

@Composable
private fun StackSlot(
    visible: Boolean,
    tip: String? = null,
    content: @Composable () -> Unit
) {
    AnimatedVisibility(
        visible = visible,
        modifier = Modifier
            .width(STACK_BTN)
            .wrapContentWidth(Alignment.End, unbounded = true), // overhang left
        enter = expandVertically(tween(ANIM_IN), Alignment.Top) + fadeIn(tween(FADE_IN)),
        exit = shrinkVertically(tween(ANIM_OUT), Alignment.Top) + fadeOut(tween(FADE_OUT))
    ) {
        Row(
            Modifier.padding(top = STACK_GAP),
            verticalAlignment = Alignment.CenterVertically
        ) {
            if (tip != null) {
                TipBubble(tip)
                Spacer(Modifier.width(STACK_GAP - TIP_ARROW_W))
            }
            content()
        }
    }
}

@Composable
private fun BarStack(
    onTouch: () -> Unit,
    trigger: @Composable () -> Unit,
    slots: @Composable ColumnScope.() -> Unit
) {
    Column(
        modifier = Modifier.pointerInput(Unit) {
            awaitEachGesture {
                awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
                onTouch()
            }
        },
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        trigger()
        slots()
    }
}

@Composable
internal fun BarPlate(
    label: String,
    modifier: Modifier = Modifier,
    align: Alignment = Alignment.CenterEnd,
    ink: Color = PAGE_INK,
    onClear: (() -> Unit)? = null,
    onClick: (() -> Unit)? = null
) {
    val shape = RoundedCornerShape(BTN_RADIUS)
    Box(
        modifier
            .height(STACK_BTN)
            .clip(shape)
            .background(PLATE_BG)
            .border(EDGE, shape)
            .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
    ) {
        if (onClear != null) {
            IconButton(
                onClick = onClear,
                modifier = Modifier.align(Alignment.CenterStart)
            ) {
                Icon(
                    Icons.Default.Clear,
                    contentDescription = "Escape",
                    tint = PAGE_INK.copy(alpha = CLEAR_ALPHA)
                )
            }
        }
        Text(
            label,
            style = TextStyle(
                color = ink,
                fontSize = TITLE_FS,
                fontFamily = FontFamily.SansSerif,
                letterSpacing = TITLE_TRACK
            ),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier
                .align(align)
                .padding(
                    start = if (onClear != null) CLEAR_SLOT else BAR_LABEL_PAD,
                    end = BAR_LABEL_PAD
                )
        )
    }
}

@Composable
fun CalcTopBar(
    title: String,
    live: Boolean,
    group: StackGroup,
    onGroupChange: (StackGroup) -> Unit,
    onPick: (Category) -> Unit,
    onEscape: () -> Unit,
    onCopy: () -> Unit,
    onPaste: () -> Unit,
    onSettings: () -> Unit,
    onVersion: () -> Unit,
    modifier: Modifier = Modifier,
    onTitle: (() -> Unit)? = null
) {
    var touchStamp by remember { mutableIntStateOf(0) }

    LaunchedEffect(group, touchStamp) {
        if (group == StackGroup.NONE) return@LaunchedEffect
        delay(GROUP_IDLE_MS)
        onGroupChange(StackGroup.NONE)
    }

    val toggle: (StackGroup) -> Unit = { g ->
        onGroupChange(if (group == g) StackGroup.NONE else g)
    }
    val fire: (() -> Unit) -> () -> Unit = { f -> { onGroupChange(StackGroup.NONE); f() } }
    val bump: () -> Unit = { touchStamp++ }
    val onConvert = group == StackGroup.CONVERT
    val onMenu = group == StackGroup.MENU

    Row(
        modifier
            .fillMaxWidth()
            .statusBarsPadding()
            .padding(horizontal = BAR_EDGE, vertical = STACK_GAP),
        horizontalArrangement = Arrangement.spacedBy(STACK_GAP),
        verticalAlignment = Alignment.Top
    ) {
        BarPlate(
            title,
            Modifier.weight(1f),
            ink = if (live) TITLE_LIVE else TITLE_IDLE,
            onClear = if (live) fire(onEscape) else null,
            onClick = onTitle?.let(fire)
        )

        BarStack(
            onTouch = bump,
            trigger = {
                StackButton(Icons.Outlined.Tune, "Convert") { toggle(StackGroup.CONVERT) }
            }
        ) {
            CATEGORIES.forEach { c ->
                StackSlot(onConvert, c.title) {
                    StackButton(c.icon, c.title, onClick = fire { onPick(c) })
                }
            }
        }

        BarStack(
            onTouch = bump,
            trigger = {
                StackButton(
                    ImageVector.vectorResource(R.drawable.ic_grain_menu),
                    "Menu",
                    iconSize = MENU_ICON
                ) { toggle(StackGroup.MENU) }
            }
        ) {
            StackSlot(onMenu) {
                StackButton(
                    Icons.Outlined.ContentCopy,
                    "Copy",
                    iconSize = COPY_ICON,
                    onClick = fire(onCopy)
                )
            }
            StackSlot(onMenu) {
                StackButton(Icons.Outlined.ContentPaste, "Paste", onClick = fire(onPaste))
            }
            StackSlot(onMenu) {
                StackButton(Icons.Outlined.Settings, "Settings", onClick = fire(onSettings))
            }
            StackSlot(onMenu) {
                StackButton(Icons.Outlined.Park, "Version", onClick = fire(onVersion))
            }
            StackSlot(onMenu) {
                StackButton(Icons.Outlined.Block, "Exit", enabled = false) {}
            }
        }
    }
}

@Composable
fun SettingsTopBar(
    title: String,
    onBack: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier
            .fillMaxWidth()
            .statusBarsPadding()
            .padding(horizontal = BAR_EDGE, vertical = STACK_GAP),
        horizontalArrangement = Arrangement.spacedBy(STACK_GAP),
        verticalAlignment = Alignment.Top
    ) {
        StackButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onClick = onBack)
        BarPlate(title, Modifier.weight(1f), Alignment.CenterStart)
    }
}

@Composable
fun Keypad(
    bias: Bias,
    edgePad: Dp,
    keyDp: Dp,
    opsSide: Side,
    memSet: Boolean,
    onKey: (Pad) -> Unit,
    onClearAll: () -> Unit,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Pad.entries.forEach { k -> KeyButton(k, memSet, onKey, onClearAll) }
        },
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = BAR_EDGE)
    ) { items, c ->
        val w = c.maxWidth.toFloat()
        val pad = if (bias.padded) edgePad.toPx().coerceIn(0f, w) else 0f
        val areaW = keyArea(w, pad, bias)
        val cell = areaW / 4
        val auto = keyAuto(w, pad, bias, KEY_BUMP.toPx())
        val range = KEY_RANGE.dp.toPx()
        val want = if (keyDp.isSpecified) keyDp.toPx().coerceIn(auto - range, auto + range) else auto
        val key = want.roundToInt().coerceAtMost(cell.toInt()).coerceAtLeast(1) // gap >= 0
        val gap = (areaW - key * 4) / 3
        val h = (key * 5 + gap * 4).roundToInt()
        val x0 = when (bias) {
            Bias.LEFT -> pad
            Bias.CENTER -> (w - areaW) / 2
            Bias.RIGHT -> w - areaW - pad
            Bias.FULL -> 0f
        }
        val shift = if (opsSide == Side.LEFT) 1 else 0 // ops col
        val fixed = Constraints.fixed(key, key)
        val keys = items.map { it.measure(fixed) }
        layout(c.maxWidth, h) {
            keys.forEachIndexed { i, p ->
                val col = (i % 4 + shift) % 4
                p.place(
                    (x0 + col * (key + gap)).roundToInt(),
                    ((i / 4) * (key + gap)).roundToInt()
                )
            }
        }
    }
}

@Composable
private fun KeyButton(
    k: Pad,
    memSet: Boolean,
    onKey: (Pad) -> Unit,
    onClearAll: () -> Unit
) {
    val bg = when {
        k.num -> NUM_BG
        k == Pad.MEM && memSet -> MEM_BG
        else -> OP_BG
    }
    PressBox(
        size = Dp.Unspecified,
        bg = bg,
        border = if (k.num) null else EDGE,
        onLongClick = if (k == Pad.CLR) onClearAll else null,
        longPressMs = CLEAR_HOLD_MS,
        onClick = { onKey(k) }
    ) {
        Text(
            k.label,
            color = if (k.num) NUM_INK else OP_INK,
            fontSize = if (k in MINOR_KEYS) KEY_FS_MINOR else KEY_FS,
            fontFamily = FontFamily.SansSerif,
            fontWeight = UNIT_WEIGHT,
            maxLines = 1
        )
    }
}

@Composable
internal fun UnitPicker(
    abbr: String,
    onOpen: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier.clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null,
            onClick = onOpen
        ),
        horizontalArrangement = Arrangement.End,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            abbr,
            modifier = Modifier.weight(1f, fill = false),
            style = UNIT_STYLE,
            color = PAGE_INK,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis
        )
        Icon(
            Icons.Default.KeyboardArrowDown,
            contentDescription = null,
            tint = MUTED,
            modifier = Modifier.size(UNIT_ARROW)
        )
    }
}

// side list
@Composable
internal fun UnitPanel(
    units: List<MUnit>,
    index: Int,
    onPick: (Int) -> Unit,
    modifier: Modifier = Modifier
) {
    Box(
        modifier
            .fillMaxHeight()
            .background(PAGE_BG)
            .drawBehind {
                val w = EDGE.width.toPx()
                drawLine(EDGE.brush, Offset(w / 2, 0f), Offset(w / 2, size.height), w)
            }
            .statusBarsPadding()
            .navigationBarsPadding()
            .verticalScroll(rememberScrollState())
    ) {
        Column(Modifier.width(IntrinsicSize.Max)) {
            units.forEachIndexed { i, u ->
                Text(
                    "${u.name} (${u.abbr})",
                    modifier = Modifier
                        .fillMaxWidth()
                        .clickable { onPick(i) }
                        .padding(horizontal = UNIT_ITEM_PAD, vertical = UNIT_ITEM_PAD_V),
                    color = if (i == index) MUTED else PAGE_INK,
                    fontSize = UNIT_LIST_FS,
                    fontWeight = UNIT_WEIGHT,
                    textAlign = TextAlign.Start,
                    maxLines = 1,
                    softWrap = false
                )
            }
        }
    }
}