package com.ponzischeme89.memby.ui.seasonal

import android.provider.Settings as AndroidSettings
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin

/**
 * Snow, bats and blossom, drifting over the launcher for the few days a year a seasonal
 * theme is on.
 *
 * A palette on its own is a thin idea of Christmas — the colours change and nothing about
 * the television says why. This is the half that does. It is also, by a distance, the most
 * expensive thing in the app: the only animation that runs continuously while somebody is
 * simply browsing, on boxes that struggle with the launcher as it is. Everything below is
 * shaped by that.
 *
 * **Nothing here recomposes.** The repo rule (see CLAUDE.md, "Animations must not
 * recompose") is the whole design: one `Canvas`, one animated `State<Float>` that is never
 * read in a composable body, and every particle's position derived arithmetically from it
 * inside the draw lambda. So a full field of decorations costs *zero* recompositions and one
 * draw pass — a scope that already redraws whenever the launcher does.
 *
 * **The field is a pure function of one number**, which is what makes it both cheap and
 * testable: [drawSeasonalField] takes a progress in `0..1` and draws exactly one frame, so a
 * screenshot test can render the moment without an animation clock. There is no per-particle
 * state, no list to allocate and nothing to keep in step — a particle is `index` and
 * `progress` and nothing else.
 *
 * **Everything wraps seamlessly.** Each particle's cycle counts are whole numbers, so at the
 * instant the driving value rolls 1 → 0 every position, sway and rotation is exactly where
 * it was. Without that the entire field would visibly jump once a minute, which is worse
 * than no animation at all.
 */

/** What the gateway asks for. An unknown slug draws nothing; see [drawSeasonalField]. */
object Decorations {
    const val SNOW = "snow"
    const val BATS = "bats"
    const val BLOSSOM = "blossom"
}

/**
 * How many of the things are on screen at once.
 *
 * Deliberately low. This is decoration behind a launcher somebody is trying to read, and the
 * failure mode is not "too few" — it is a set that drops frames while scrolling a row, which
 * nobody would connect to Christmas. Twenty-two is enough to read as weather at 1080p and
 * cheap enough to draw in a handful of paths.
 */
private const val ParticleCount = 26

/** One full cycle of the field, in milliseconds. Long, because this is meant to be barely noticed. */
private const val CycleMillis = 48_000

/**
 * The decoration layer for the launcher.
 *
 * Never focusable, never clickable, and drawn with no scrim of its own — it sits over
 * artwork somebody is choosing from, so anything that dimmed the page to make the snow read
 * better would have the priority exactly backwards.
 *
 * Call it with the gateway's slug. Empty, unknown, or a platform with animations turned off
 * all produce nothing at all — not an empty Canvas, but no node, so there is nothing in the
 * tree for the other 51 weeks of the year.
 */
@Composable
fun SeasonalDecorations(decoration: String, modifier: Modifier = Modifier) {
    if (!hasField(decoration)) return
    // Somebody who has turned animations off at the platform level has said something about
    // every animation on the device, and this is the least important one on it. Honoured
    // here rather than exposed as a Memby setting, because a seasonal theme is deliberately
    // not the viewer's to decline — but an accessibility choice is not a preference, and
    // "no animations" has to mean no animations.
    val context = LocalContext.current
    val animationsOn = remember(context) {
        AndroidSettings.Global.getFloat(
            context.contentResolver,
            AndroidSettings.Global.ANIMATOR_DURATION_SCALE,
            1f,
        ) > 0f
    }
    if (!animationsOn) return

    val progress = rememberInfiniteTransition(label = "seasonal").animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(CycleMillis, easing = LinearEasing),
            repeatMode = RepeatMode.Restart,
        ),
        label = "drift",
    )

    // The accent and the body colour are read here, in the composable, on purpose: they are
    // palette state, and reading them in composition means a theme change recomposes this
    // one node rather than being missed. The *animated* value is the one that must not be
    // read here, and is not — `progress` is passed down as State and unwrapped in the draw
    // lambda below.
    val accent = MembyAccent
    val body = MembyOnSurface

    Canvas(modifier = modifier.fillMaxSize().testTag(DecorationTestTag)) {
        drawSeasonalField(decoration, progress.value, accent, body)
    }
}

/** So a screenshot test can find the layer without reaching into the drawing. */
const val DecorationTestTag = "seasonal-decorations"

/** Whether [decoration] is one this build knows how to draw. */
fun hasField(decoration: String): Boolean = when (decoration) {
    Decorations.SNOW, Decorations.BATS, Decorations.BLOSSOM -> true
    else -> false
}

/**
 * One frame of the field, at [progress] through a cycle.
 *
 * Pure with respect to everything except the canvas: the same progress always draws the same
 * picture, which is what the screenshot test relies on and what lets the animation be
 * verified by looking at three still images rather than by watching a television.
 */
fun DrawScope.drawSeasonalField(
    decoration: String,
    progress: Float,
    accent: Color,
    body: Color,
) {
    if (!hasField(decoration)) return
    for (index in 0 until ParticleCount) {
        drawParticle(decoration, index, progress, accent, body)
    }
}

private fun DrawScope.drawParticle(
    decoration: String,
    index: Int,
    progress: Float,
    accent: Color,
    body: Color,
) {
    // Everything about a particle comes out of its index through these two hashes. A stored
    // array of particles would be the obvious shape and is the wrong one here: it is state to
    // allocate, keep and re-seed on every configuration change, in exchange for randomness
    // nobody can tell from this.
    val a = noise(index * 2)
    val b = noise(index * 2 + 1)

    // **Stratified, not random.** The hash alone put visible bands and a bare patch through
    // the middle of the screen — twenty-two samples is far too few for a hash to look evenly
    // spread, and the eye finds a clump in a snowfield immediately. So each particle owns a
    // slice of the axis and the hash only jitters it within that slice, which is what makes
    // this read as weather rather than as a handful of dots.
    val lane = (index + 0.5f) / ParticleCount
    val jitter = (a - 0.5f) / ParticleCount

    // Whole numbers, so the field is exactly where it started when progress rolls over.
    val speed = 2 + index % 4
    val swayCycles = 1 + index % 3

    val scale = 0.82f + b * 0.55f
    // Faint on purpose, and this is the number that was tuned by looking rather than
    // reasoned about. The layer sits *over* the launcher — it has to, since the surface
    // beneath it is opaque — so every so often a flake lands on the Play button. At this
    // alpha that reads as snow passing in front of the screen; a few points higher and it
    // reads as something wrong with the rendering. See `decoration-over-content.png`, which
    // exists for exactly this judgement.
    val alpha = 0.17f + a * 0.18f
    val sway = sin((progress * swayCycles + a) * 2f * PI.toFloat())

    when (decoration) {
        Decorations.SNOW -> {
            val x = (lane + jitter + sway * 0.035f) * size.width
            val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
            place(x, y, (progress * (1 + index % 2) + a) * 360f) {
                drawSnowflake(14f * scale, body.copy(alpha = alpha))
            }
        }
        Decorations.BLOSSOM -> {
            val x = (lane + jitter + sway * 0.06f) * size.width
            val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
            // Blossom tumbles rather than spinning flat, so it turns faster than snow —
            // the rotation is what sells it as falling rather than sliding down the screen.
            place(x, y, (progress * (2 + index % 3) + b) * 360f) {
                // Barely blended toward the body colour. A single petal was drawn at half
                // the way and came out a grey seed: these palettes are pastels already, and
                // mixing a pastel with a near-white leaves nothing of the colour behind.
                drawBlossom(13f * scale, accent.copy(alpha = alpha + 0.08f), body)
            }
        }
        Decorations.BATS -> {
            // Bats fly across rather than fall, and alternate direction so the screen does
            // not read as everything leaving one side of the room.
            val leftward = index % 2 == 0
            val travel = wrap(lane + progress * (1 + index % 3))
            val x = (if (leftward) travel else 1f - travel) * (size.width + 220f) - 110f
            // Banded down the screen rather than hashed, for the same reason the fall is:
            // the flock has to look like it is crossing the whole room. The top and bottom
            // eighths are left clear so nothing collides with the row titles or the rail.
            val band = 0.12f + ((index * 7 % ParticleCount) + b) / ParticleCount * 0.76f
            val y = (band + sin((progress * (2 + index % 2) + a) * 2f * PI.toFloat()) * 0.04f) *
                size.height
            place(x, y, 0f) {
                // The wings beat by squeezing the shape horizontally — six flaps a cycle,
                // whole-numbered like everything else. A cheaper animation than redrawing a
                // wing path, and at this size it is the only cue that reads as alive.
                val flap = 0.45f + abs(sin((progress * 6 + a) * 2f * PI.toFloat())) * 0.55f
                drawBat(22f * scale, flap, blend(accent, body, 0.12f).copy(alpha = alpha))
            }
        }
    }
}

/** Moves to a point and turns, so each shape can be drawn around its own origin. */
private inline fun DrawScope.place(x: Float, y: Float, degrees: Float, draw: DrawScope.() -> Unit) {
    translate(x, y) { rotate(degrees, pivot = Offset.Zero) { draw() } }
}

/** Six spokes and a centre — the shape everybody draws, because at 18px nothing else reads. */
private fun DrawScope.drawSnowflake(radius: Float, color: Color) {
    val stroke = (radius * 0.16f).coerceAtLeast(1f)
    for (spoke in 0 until 3) {
        val angle = spoke * PI.toFloat() / 3f
        val dx = cos(angle) * radius
        val dy = sin(angle) * radius
        drawLine(color, Offset(-dx, -dy), Offset(dx, dy), strokeWidth = stroke)
        // The little barbs. Without them a snowflake at this size is a three-line asterisk,
        // which reads as a scratch on the panel rather than as snow.
        val barb = radius * 0.34f
        for (side in listOf(1f, -1f)) {
            val tip = Offset(dx * side, dy * side)
            val inner = Offset(dx * side * 0.55f, dy * side * 0.55f)
            drawLine(
                color,
                inner,
                Offset(
                    inner.x + cos(angle + side * 1.05f) * barb,
                    inner.y + sin(angle + side * 1.05f) * barb,
                ),
                strokeWidth = stroke * 0.8f,
            )
            drawLine(color, inner, tip, strokeWidth = stroke)
        }
    }
    drawCircle(color, radius * 0.18f, Offset.Zero)
}

/**
 * A five-petal blossom: five overlapping discs and a centre.
 *
 * It began as a single petal, which at this size rendered as a grey seed — recognisable as
 * *something falling* and as nothing else. A whole flower is barely more expensive (five
 * circles against a two-curve path) and is read instantly, which for the one decoration
 * whose season lasts four days is the difference between the feature landing and not.
 */
private fun DrawScope.drawBlossom(radius: Float, color: Color, body: Color) {
    val petal = radius * 0.44f
    val reach = radius * 0.56f
    for (index in 0 until 5) {
        val angle = index * 2f * PI.toFloat() / 5f
        drawCircle(color, petal, Offset(cos(angle) * reach, sin(angle) * reach))
    }
    // The centre is warmer and a touch stronger, which is what stops five discs reading as
    // a cluster of bubbles.
    drawCircle(
        blend(color, body, 0.45f).copy(alpha = (color.alpha * 1.5f).coerceAtMost(1f)),
        radius * 0.3f,
        Offset.Zero,
    )
}

/**
 * A bat silhouette, [flap] squeezing the wings between folded and spread.
 *
 * Drawn as a body plus two scalloped wings rather than as one path, because the scallop is
 * the only thing separating a bat from a bird at fourteen pixels.
 */
private fun DrawScope.drawBat(radius: Float, flap: Float, color: Color) {
    val span = radius * flap
    val path = Path().apply {
        moveTo(0f, -radius * 0.18f)
        // Right wing: out along the top, back in through two scallops.
        cubicTo(span * 0.5f, -radius * 0.75f, span * 0.85f, -radius * 0.5f, span, -radius * 0.1f)
        lineTo(span * 0.72f, radius * 0.22f)
        lineTo(span * 0.6f, radius * 0.02f)
        lineTo(span * 0.34f, radius * 0.3f)
        lineTo(span * 0.22f, radius * 0.08f)
        lineTo(0f, radius * 0.34f)
        // Left wing: the mirror of it.
        lineTo(-span * 0.22f, radius * 0.08f)
        lineTo(-span * 0.34f, radius * 0.3f)
        lineTo(-span * 0.6f, radius * 0.02f)
        lineTo(-span * 0.72f, radius * 0.22f)
        lineTo(-span, -radius * 0.1f)
        cubicTo(-span * 0.85f, -radius * 0.5f, -span * 0.5f, -radius * 0.75f, 0f, -radius * 0.18f)
        close()
    }
    drawPath(path, color)
    drawCircle(color, radius * 0.2f, Offset(0f, -radius * 0.12f))
    // Two ears. Tiny, and the reason the silhouette is legible at all.
    drawPath(
        Path().apply {
            moveTo(-radius * 0.2f, -radius * 0.22f)
            lineTo(-radius * 0.1f, -radius * 0.5f)
            lineTo(-radius * 0.02f, -radius * 0.24f)
            close()
            moveTo(radius * 0.2f, -radius * 0.22f)
            lineTo(radius * 0.1f, -radius * 0.5f)
            lineTo(radius * 0.02f, -radius * 0.24f)
            close()
        },
        color,
    )
}

/**
 * A deterministic value in `0..1` for a particle's index.
 *
 * A hash rather than `Random`, so the field is identical on every television, on every
 * launch, and in a screenshot — which is what makes a still image of it worth looking at.
 */
private fun noise(seed: Int): Float {
    var value = seed * 374_761_393 + 668_265_263
    value = (value xor (value shr 13)) * 1_274_126_177
    return (abs(value xor (value shr 16)) % 10_000) / 10_000f
}

/** The fractional part, so a particle leaving the bottom re-enters at the top. */
private fun wrap(value: Float): Float = value - kotlin.math.floor(value)

private fun blend(from: Color, to: Color, amount: Float): Color = Color(
    red = from.red + (to.red - from.red) * amount,
    green = from.green + (to.green - from.green) * amount,
    blue = from.blue + (to.blue - from.blue) * amount,
)
