/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.ui.theme

import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt

/**
 * The shard's resolved corner radii and card depth — the `structure` half of the
 * admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
 * [ShardPalette].
 *
 * **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
 * from the M5 mockup and the website's from `theme.css`; the two scales genuinely
 * differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
 * would restyle an untouched app the day this milestone shipped, so each field is
 * scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
 * theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
 * field and is a provable no-op (§2, AC-1).
 *
 * Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
 */
@Immutable
data class ShardStructure(
    /** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
    val shapes: Shapes = ShippedShapes,
    /**
     * `--radius-pill`. Not part of [shapes]: the app draws its chips with
     * [CircleShape], which is a percentage and so has no dp for a ratio to scale.
     * Resolved as a literal instead — the only rule available — see [pillShape].
     */
    val pill: Shape = CircleShape,
    /** `--shadow-card`, mapped onto Material elevation (§5.4). */
    val cardElevation: Dp = ShippedCardElevation,
) {
    companion object {
        /** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
        val Shipped = ShardStructure()

        /**
         * Resolve a `theme` token map into a structure, **field by field** (§2):
         * a `--radius-panel` the server never validated must not cost the
         * `--radius-card` beside it, exactly as in [ShardPalette.resolve].
         */
        fun resolve(theme: Map<String, String>): ShardStructure {
            if (theme.isEmpty()) return Shipped
            val input = ratio(theme["--radius-input"], BaseInputPx)
            val card = ratio(theme["--radius-card"], BaseCardPx)
            val panel = ratio(theme["--radius-panel"], BasePanelPx)
            return ShardStructure(
                shapes = Shapes(
                    extraSmall = corner(ShippedExtraSmallDp, input),
                    small = corner(ShippedSmallDp, input),
                    medium = corner(ShippedMediumDp, card),
                    // extraLarge has no web counterpart and follows the panel
                    // ratio, since it is the panel family.
                    large = corner(ShippedLargeDp, panel),
                    extraLarge = corner(ShippedExtraLargeDp, panel),
                ),
                pill = pillShape(theme["--radius-pill"]),
                cardElevation = elevation(theme["--shadow-card"]),
            )
        }
    }
}

/**
 * The live structure, for the two things Material's theme cannot carry: the pill
 * shape, and a card elevation ([androidx.compose.material3.Card] takes its
 * elevation as a default argument, not from a composition local). The shape
 * scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
 * here.
 */
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }

// ── the shipped scale ──────────────────────────────────────────────────────
//
// The app's own dp values, which the ratios scale. Kept here rather than in
// Theme.kt so the resolution and the thing it resolves back to sit together.

private const val ShippedExtraSmallDp = 8
private const val ShippedSmallDp = 8
private const val ShippedMediumDp = 12
private const val ShippedLargeDp = 16
private const val ShippedExtraLargeDp = 24

/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
internal val ShippedShapes = Shapes(
    extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
    small = RoundedCornerShape(ShippedSmallDp.dp),
    medium = RoundedCornerShape(ShippedMediumDp.dp),
    large = RoundedCornerShape(ShippedLargeDp.dp),
    extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
)

/**
 * The depth an unthemed instance draws its cards at.
 *
 * **This is the one field of this milestone that is deliberately not a no-op.**
 * The app has been flat since M5 — Material's filled `Card` is `Level0` and
 * `FeatureCard` never had the shadow its own docs claimed — while the
 * `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
 * applied as written rather than rebased on the app's flat baseline, so every
 * card gains this depth and the admin's four-step control reads the same on the
 * phone as on the web. Approved by the org lead as an amendment to §2.
 */
private val ShippedCardElevation = 4.dp

// ── the runic-gateway baselines ───────────────────────────────────────────
//
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
// A resolved value is meaningful only against these: the ratio, not the number,
// is what crosses from the web scale to the app's.

private const val BaseInputPx = 8f
private const val BaseCardPx = 10f
private const val BasePanelPx = 12f
private const val BasePillPx = 999f

/**
 * Below half the pill baseline the chip stops reading as a pill and becomes a
 * rounded rectangle, so an admin who squares the site off squares off the app's
 * chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
 * 999px does not.
 */
private const val PillCircleFloorPx = BasePillPx / 2f

// A radius as the server writes it: an integer count of px, 0..999, always with
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
// this app can scale, and falls back to the shipped dp on its own.
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")

// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
// unit, so the blur is the second px length.
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")

/**
 * `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
 * string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
 * but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
 * `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
 * through that dropdown. An exact match would have missed the one preset whose
 * point is a heavier shadow. Matching the blur puts any future preset on the
 * nearest step instead of silently on the default.
 */
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)

private fun parseRadiusPx(raw: String?): Float? =
    raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()

private fun ratio(raw: String?, baselinePx: Float): Float =
    parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f

/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
private fun corner(shippedDp: Int, ratio: Float) =
    RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)

private fun pillShape(raw: String?): Shape {
    val px = parseRadiusPx(raw) ?: return CircleShape
    return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
}

private fun elevation(raw: String?): Dp {
    val value = raw?.trim() ?: return ShippedCardElevation
    if (value.equals("none", ignoreCase = true)) return 0.dp
    val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
        ?.groupValues?.get(1)?.toFloatOrNull()
        ?: return ShippedCardElevation
    return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
}
