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

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.runicgateway.app.ui.LocalAssetResolver

/**
 * The two brand assets an instance can upload — the logo and the hero
 * (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
 * M1 and neither has ever been drawn; the app has always spelled the instance
 * out in text wherever the website shows a mark.
 *
 * **The rule that governs this whole file: an empty slot renders nothing.** Not
 * a placeholder, not a reserved gap, not the app's own emblem — an instance
 * that has uploaded no logo must lay out exactly as it did before this phase
 * existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
 * with the same `if (!brand.logo) return null`.
 *
 * **A failed load is an empty slot.** No broken-image icon and no retry: an
 * asset that 404s, or that can't be reached because the shard is down, must
 * degrade to the same layout as an instance that never uploaded one. That is
 * why nothing here reserves its space up front — every size modifier hangs off
 * the image itself, so when the image isn't composed neither is its padding.
 * A caller that wants space *below* a hero passes it as `Modifier.padding`
 * rather than a sibling `Spacer`, and gets both cases right for free.
 */

/**
 * Widest a logo may draw, as a multiple of its height. Mirrors the website's
 * `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
 * scaled down rather than pushing the drawer header or the top bar's title out
 * of shape.
 */
private const val LOGO_MAX_ASPECT = 6f

/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
private val HERO_HEIGHT = 180.dp

/**
 * The instance's uploaded logo at [height], or [fallback] when there is none.
 *
 * [fallback] defaults to drawing nothing, which is what the drawer header wants:
 * the instance name sits directly below it, so an instance with no logo simply
 * has the name where it has always been. The top bar passes the name itself,
 * because there the logo *replaces* the title — leaving that blank on a failed
 * load would strand the app in an unnamed shell until the next resume refresh,
 * and "a failed load is an empty slot" means the slot falls back to whatever
 * empty would have shown, which for the top bar is the text.
 *
 * There is deliberately no fallback while the load is still in flight. Drawing
 * the text first would flash text → logo on every navigation for the sake of
 * one frame, since Coil serves the second and later reads from its memory cache.
 *
 * Pass [contentDescription] only where the logo stands alone. Beside or above
 * the name in text it is decorative, and describing it would have a screen
 * reader say the instance's name twice — the same call the website's `alt=''`
 * makes.
 */
@Composable
fun BrandLogo(
    logo: String?,
    height: Dp,
    modifier: Modifier = Modifier,
    contentDescription: String? = null,
    fallback: @Composable () -> Unit = {},
) {
    val url = brandAssetUrl(logo, LocalAssetResolver.current)
    // Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
    // a fresh attempt rather than inheriting the old one's failure.
    var failed by remember(url) { mutableStateOf(false) }

    if (url == null || failed) {
        fallback()
        return
    }
    AsyncImage(
        model = url,
        contentDescription = contentDescription,
        contentScale = ContentScale.Fit,
        onError = { failed = true },
        modifier = modifier
            .height(height)
            .widthIn(max = height * LOGO_MAX_ASPECT),
    )
}

/**
 * The instance's hero image as a full-width band above Home's title block, or
 * nothing when there is none.
 *
 * **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
 * other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
 * CSS background driven by `hero_layout`, which the app does not port, so the
 * app needs its own rule — and the website's *default* hero is a square emblem,
 * so an uploaded square is a case to expect rather than an edge one. At the
 * intrinsic aspect that square would be a ~360dp block that pushes the status
 * card off the first screenful; cropped to a band, a wide banner and a square
 * both give the same frame above the title.
 *
 * Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
 * like every other surface the admin can round off (§5.2).
 *
 * Decorative: Home spells the instance's name and tagline out in text directly
 * below, so the hero carries no content description.
 */
@Composable
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
    val url = brandAssetUrl(hero, LocalAssetResolver.current)
    var failed by remember(url) { mutableStateOf(false) }

    if (url == null || failed) return
    AsyncImage(
        model = url,
        contentDescription = null,
        contentScale = ContentScale.Crop,
        onError = { failed = true },
        modifier = modifier
            .fillMaxWidth()
            .height(HERO_HEIGHT)
            .clip(MaterialTheme.shapes.medium),
    )
}

/**
 * Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
 *
 * The blank check has to happen on **both** sides of [resolve]: `BrandDto`
 * defaults every asset field to `""` rather than null (the server publishes the
 * empty string for "not set"), and a resolver given a path it cannot make
 * absolute may hand one straight back. Null out of here is the signal for "draw
 * nothing", so a blank slipping through would put a zero-size image request in
 * the layout instead of no image at all.
 *
 * Pulled out of the composables purely so it can be tested: the app has no
 * Robolectric, so a composable body cannot run in a JVM unit test, but this rule
 * is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
 */
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
    path?.takeIf { it.isNotBlank() }
        ?.let(resolve)
        ?.takeIf { it.isNotBlank() }
