Pith - kiln
package com.vgmlr.kiln

import androidx.compose.ui.graphics.Color
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import kotlin.math.roundToInt

object KilnGradientPalette {
    
    const val PRE_MENSTRUAL_BUCKET = 2

    const val GREEN_PEAK = 7

    private const val PMS_EDGE_LO = 40f
    private const val PMS_EDGE_HI = 45f
    
    fun hueFor(date: LocalDate, lastStart: LocalDate?, cycle: Int, moodShift: Float = 0f): Float {
        if (lastStart == null) return 120f
        val phase = Math.floorMod(
            ChronoUnit.DAYS.between(lastStart, date), cycle.toLong()
        ).toFloat()
        val greenPeak = 7f
        val redStart = (cycle - 6f).coerceAtLeast(greenPeak)
        val redEnd = cycle - 1f
        val base = when {
            phase in greenPeak..redStart ->
                120f * (1f - (phase - greenPeak) / (redStart - greenPeak))
            phase > redStart && phase < redEnd -> 0f
            else -> {
                val fromRed = if (phase >= redEnd) phase - redEnd else phase + (cycle - redEnd)
                120f * (fromRed / ((cycle - redEnd) + greenPeak))
            }
        }
        return (base + moodShift).coerceIn(0f, 120f)
    }

    fun colorFor(hue: Float): Color = Color.hsv(hue, 0.50f, 0.95f)

    fun bucket(hue: Float): Int = ((hue / 120f) * 7f).roundToInt().coerceIn(0, 7)

    fun isPreMenstrual(hue: Float): Boolean = bucket(hue) <= PRE_MENSTRUAL_BUCKET

    fun lookup(
        records: List<KilnDayRecord>,
        buttons: KilnButtonSet,
        starts: List<LocalDate>,
        cycle: Int,
        today: LocalDate = LocalDate.now()
    ): (LocalDate) -> Float {
        if (starts.isEmpty()) return { 120f }
        val model = KilnMoodInfluence.model(records, buttons, starts, cycle)
        return { date -> hueAt(date, starts, cycle, today, model) }
    }

    fun baseLead(cycle: Int): Int {
        val anchor = LocalDate.ofEpochDay(0)
        return (GREEN_PEAK until cycle).count {
            isPreMenstrual(hueFor(anchor.plusDays(it.toLong()), anchor, cycle))
        }
    }

    private fun hueAt(
        date: LocalDate,
        starts: List<LocalDate>,
        cycle: Int,
        today: LocalDate,
        model: KilnPmsModel
    ): Float {
        val i = starts.indexOfLast { !it.isAfter(date) }
        if (i < 0) {
            val back = ChronoUnit.DAYS.between(date, starts[0])
            val s = starts[0].minusDays((back + cycle - 1) / cycle * cycle)
            return shape(ChronoUnit.DAYS.between(s, date), cycle, model.lead)
        }
        val a = starts[i]
        val b = starts.getOrNull(i + 1)
        if (b != null) {
            val len = ChronoUnit.DAYS.between(a, b)
            if (len in KilnCyclePredictor.PLAUSIBLE) {
                return shape(ChronoUnit.DAYS.between(a, date), len.toInt(), model.leadBefore(b))
            }
        }
        val e = date.minusDays(overdueHold(starts, cycle, today, a, date))
        return shape(Math.floorMod(ChronoUnit.DAYS.between(a, e), cycle.toLong()), cycle, model.lead)
    }

    // backward-anchored
    private fun shape(phase: Long, len: Int, lead: Int): Float {
        if (phase < GREEN_PEAK) return 120f * (phase + 1) / (GREEN_PEAK + 1)
        val du = (len - phase).toInt()
        val top = len - GREEN_PEAK
        val l = lead.coerceIn(1, maxOf(1, top - 1))
        if (du > l) {
            val f = ((du - l - 1).toFloat() / maxOf(1, top - l - 1)).coerceIn(0f, 1f)
            return PMS_EDGE_HI + (120f - PMS_EDGE_HI) * f
        }
        val r = l / 2
        return if (du <= r) 0f else PMS_EDGE_LO * (du - r) / (l - r)
    }

    private fun overdueHold(
        starts: List<LocalDate>,
        cycle: Int,
        today: LocalDate,
        last: LocalDate?,
        date: LocalDate
    ): Long {
        val anchor = last ?: return 0L
        val pin = anchor.plusDays(cycle - 1L)
        val end = starts.firstOrNull { it.isAfter(anchor) } ?: today
        val lag = ChronoUnit.DAYS.between(pin, end)
        if (lag <= 0L) return 0L
        return ChronoUnit.DAYS.between(pin, date).coerceIn(0L, lag)
    }

    fun bucketToday(records: List<KilnDayRecord>, buttons: KilnButtonSet, today: LocalDate): Int {
        val starts = KilnCyclePredictor.periodStarts(records, buttons)
        val cycle = KilnCyclePredictor.avgCycleLength(starts)
        return bucket(lookup(records, buttons, starts, cycle, today)(today))
    }
}