Pith - grain
package com.vgmlr.grain

import java.math.BigDecimal
import java.math.RoundingMode

private const val OPS = "+-×/"
private const val MAX_SCALE = 12
internal const val ERR = "Error"

private val NUM = Regex("""(?:\d+\.?\d*|\.\d+)(?:E-?\d+)?%?""")
private val TAIL = Regex("""\d*\.?\d*(?:E-?\d+)?%?$""")

private data class Num(val v: Double, val pct: Boolean)

internal object GrainCalc {

    fun isOp(c: Char) = c in OPS

    // trailing number
    fun tail(expr: String): String = TAIL.find(expr)?.value ?: ""

    // groups first
    fun eval(expr: String): Double? = run(expr)?.first

    // has ops
    fun isCompound(expr: String): Boolean = run(expr)?.second ?: false

    // auto-close
    fun close(expr: String): String {
        val t = trim(expr)
        return t + ")".repeat((t.count { it == '(' } - t.count { it == ')' }).coerceAtLeast(0))
    }

    fun openCount(expr: String): Int = expr.count { it == '(' } - expr.count { it == ')' }

    private fun trim(expr: String) = expr.trimEnd { isOp(it) || it == '(' }

    private fun run(expr: String): Pair<Double, Boolean>? {
        val p = Parser(trim(expr))
        val v = p.expr() ?: return null
        if (!p.done) return null // stray )
        return v to p.compound
    }

    // plain, half-up
    fun fmt(v: Double, places: Int? = null): String {
        if (v.isNaN() || v.isInfinite()) return ERR
        if (v == 0.0) return "0"
        val b = BigDecimal.valueOf(v)
        val r = places?.let { round(b, it) } ?: b
        return if (r.signum() == 0) "0" else r.stripTrailingZeros().toPlainString()
    }

    private fun round(b: BigDecimal, places: Int): BigDecimal {
        if (places == 0) return b.setScale(0, RoundingMode.HALF_UP)
        val a = b.abs()
        val lead = if (a < BigDecimal.ONE) a.scale() - a.precision() else 0 // zero run
        val scale = minOf(lead + places, MAX_SCALE)
        val r = b.setScale(scale, RoundingMode.HALF_UP)
        val whole = { x: BigDecimal -> x.abs().setScale(0, RoundingMode.DOWN) }
        return if (whole(r) > whole(b)) b.setScale(scale, RoundingMode.DOWN) else r // int bump
    }

    fun parse(raw: String): Double? =
        raw.trim().replace(",", "").replace(" ", "")
            .toDoubleOrNull()?.takeIf { it.isFinite() }

    private class Parser(private val s: String) {
        private var i = 0
        var compound = false
            private set
        val done: Boolean get() = i >= s.length

        // linear
        fun expr(): Double? {
            val first = operand() ?: return null
            if (first.pct) compound = true
            var acc = if (first.pct) first.v / 100 else first.v
            while (i < s.length && s[i] != ')') {
                val op = s[i]
                if (!isOp(op)) return null
                i++
                val n = operand() ?: return null
                compound = true
                val b = when {
                    !n.pct -> n.v
                    op == '+' || op == '-' -> acc * n.v / 100
                    else -> n.v / 100
                }
                acc = when (op) {
                    '+' -> acc + b
                    '-' -> acc - b
                    '×' -> acc * b
                    else -> acc / b
                }
            }
            return acc
        }

        private fun operand(): Num? {
            if (i >= s.length) return null
            return when (s[i]) {
                '-' -> {
                    i++
                    operand()?.let { Num(-it.v, it.pct) } // unary
                }
                '(' -> {
                    i++
                    compound = true
                    val v = expr() ?: return null
                    if (i < s.length) i++ // consume )
                    Num(v, false)
                }
                else -> {
                    val m = NUM.find(s, i)?.takeIf { it.range.first == i } ?: return null
                    i = m.range.last + 1
                    val v = m.value.removeSuffix("%").toDoubleOrNull() ?: return null
                    Num(v, m.value.endsWith('%'))
                }
            }
        }
    }
}