package com.ponzischeme89.memby.ui

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.floorMod
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import java.util.TimeZone

internal const val HOME_HERO_ROW_ID = "home-movie-hero"

/**
 * The current local day, re-read when the clock passes midnight.
 *
 * A television is not a phone: it is quite normal for this launcher to still be on screen
 * at one in the morning, or to be woken from the screensaver into a composition that
 * started yesterday. Reading the day once at composition would mean the "new releases" a
 * household sees are the ones that were new whenever they last cold-started the app. The
 * timer sleeps until the next local midnight rather than polling, so the cost of this is
 * one suspended coroutine.
 */
@Composable
internal fun rememberHomeHeroDay(): Long {
    var day by remember { mutableStateOf(currentLocalDay()) }
    LaunchedEffect(Unit) {
        while (true) {
            val now = System.currentTimeMillis()
            // A floor keeps a clock correction that lands exactly on the boundary from
            // turning this into a busy loop.
            delay(
                millisUntilNextLocalDay(now, TimeZone.getDefault().getOffset(now))
                    .coerceAtLeast(MIN_DAY_TICK_MS),
            )
            day = currentLocalDay()
        }
    }
    return day
}

private fun currentLocalDay(): Long = System.currentTimeMillis().let { now ->
    localEpochDay(now, TimeZone.getDefault().getOffset(now))
}

private const val MIN_DAY_TICK_MS = 1_000L

/**
 * The movie feature owns the header until a shelf below it is being browsed.
 *
 * Scroll position alone was not enough. Continue Watching is the first row, so moving down
 * into it scrolls nothing — the hero stayed while the viewer walked along a row whose cards
 * had nowhere to describe themselves, which is the one thing the metadata panel exists for.
 * [rowFocused] is what stands the hero down, and pressing Up out of the first row is what
 * brings it back; the scroll test remains because a viewer who is somewhere down the
 * launcher should not have the hero returned to them by a row losing focus.
 */
internal fun shouldShowHomeMovieHero(
    hasMovies: Boolean,
    listAtTop: Boolean,
    rowFocused: Boolean = false,
): Boolean = hasMovies && listAtTop && !rowFocused

/**
 * A hero card, the caption it wears and — when the gateway composed it — the one line
 * saying why it is there.
 *
 * The label used to be the card's *position* — `listOf("POPULAR", "NEW RELEASE",
 * "TRENDING")[index]` — while the selection below interleaves two sources and then falls
 * back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
 * one POPULAR. A caption that can be wrong is worse than no caption.
 */
internal data class HomeHeroPick(
    val item: BaseItem,
    val label: String,
    val reason: String? = null,
)

private const val LABEL_NEW = "NEW RELEASE"
private const val LABEL_POPULAR = "POPULAR"
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"

/** The `kind` of the server-composed hero row. It is consumed here, never drawn as a row. */
internal const val SERVER_HERO_ROW_KIND = "hero"

/**
 * The hero the gateway composed, if it sent one.
 *
 * This is the preferred path and it is deliberately a *verbatim* read: the server has
 * Radarr's digital release dates, Sonarr's premieres and the stored review scores, none of
 * which reach the television, so second-guessing its order here would only ever be able to
 * throw that evidence away. No day rotation either — the underlying facts already change
 * daily, and rotating a merit ranking is precisely how the best-reviewed release of the
 * week ends up in the fourth slot.
 *
 * Each card carries its own caption, so a kind of hero the server invents tomorrow reads
 * correctly on this build. A card that somehow arrives without one falls back to the
 * neutral library caption rather than to a claim nobody made.
 */
internal fun serverHeroPicks(rows: List<HomeRow>): List<HomeHeroPick> =
    rows.asSequence()
        .filter { it.kind == SERVER_HERO_ROW_KIND }
        .flatMap { it.items.asSequence() }
        .filter { it.membyPlayable }
        .distinctBy(BaseItem::id)
        .take(4)
        .map { item ->
            HomeHeroPick(
                item = item,
                label = item.membyHeroLabel?.takeIf(String::isNotBlank) ?: LABEL_LIBRARY,
                reason = item.membyHeroReason?.takeIf(String::isNotBlank),
            )
        }
        .toList()

/**
 * Picks a deliberate mix of fresh and popular movies while preserving server ranking.
 *
 * This is the **direct path's** rule, and the fallback whenever the gateway sends no hero
 * row — a container that is down, or one older than the feature. It ranks on the only
 * evidence a television has, which is which shelf a title was drawn from; where the
 * gateway is answering, [serverHeroPicks] wins because it can see rather more than that.
 *
 * [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
 * each candidate list, so a household that leaves the launcher on the same four films for
 * a fortnight instead sees a different set every morning. It is a rotation rather than a
 * shuffle on purpose: the server's ranking is still the order, so the titles it thinks are
 * worth leading with keep coming round, and yesterday's hero is merely one place further
 * down rather than somewhere unpredictable. Passing the same day twice always produces the
 * same four cards, which is what keeps the launcher from reshuffling under a viewer who
 * simply walked back into the room.
 */
internal fun selectHomeHeroMovies(
    rows: List<HomeBrowseRow>,
    day: Long = 0L,
    serverRows: List<HomeRow> = emptyList(),
): List<HomeHeroPick> {
    serverHeroPicks(serverRows).takeIf(List<HomeHeroPick>::isNotEmpty)?.let { return it }

    fun HomeBrowseRow.matches(vararg words: String): Boolean {
        val label = "$id $title".lowercase()
        return words.any(label::contains)
    }

    val newReleases = rows
        .filter { it.matches("latest", "recent", "new release", "just added") }
        .flatMap(HomeBrowseRow::items)
        .filter(BaseItem::isMovie)
        .rotatedBy(day)
    val popular = rows
        .filter { it.matches("popular", "trending", "recommended", "top pick") }
        .flatMap(HomeBrowseRow::items)
        .filter(BaseItem::isMovie)
        .rotatedBy(day)
    val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).rotatedBy(day)

    fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }

    return buildList {
        addAll(
            listOfNotNull(
                newReleases.getOrNull(0)?.let { HomeHeroPick(it, LABEL_NEW) },
                popular.getOrNull(0)?.let { HomeHeroPick(it, LABEL_POPULAR) },
            ),
        )
        addAll(
            listOfNotNull(
                newReleases.getOrNull(1)?.let { HomeHeroPick(it, LABEL_NEW) },
                popular.getOrNull(1)?.let { HomeHeroPick(it, LABEL_POPULAR) },
            ),
        )
        addAll(newReleases.labelled(LABEL_NEW))
        addAll(popular.labelled(LABEL_POPULAR))
        addAll(everyMovie.labelled(LABEL_LIBRARY))
        // distinctBy keeps the first appearance, so a title that is both new and popular
        // keeps the label of the row it was drawn from first.
    }.distinctBy { it.item.id }.take(4)
}

/**
 * Moves the start of the list on by [by] places, wrapping. A negative day is as valid as a
 * positive one — a television whose clock has not yet been set can report an instant before
 * the epoch, and the launcher must still draw four cards rather than throw.
 */
private fun <T> List<T>.rotatedBy(by: Long): List<T> {
    if (size <= 1) return this
    val offset = floorMod(by, size.toLong()).toInt()
    if (offset == 0) return this
    return subList(offset, size) + subList(0, offset)
}

@Composable
internal fun HomeMovieHero(
    movies: List<HomeHeroPick>,
    navigationFocusRequester: FocusRequester,
    contentEntryFocusRequester: FocusRequester? = null,
    returnFocusItemId: String? = null,
    returnFocusRequester: FocusRequester? = null,
    downFocusRequester: FocusRequester? = null,
    onItemFocused: (BaseItem) -> Unit,
    onItemSelected: (BaseItem) -> Unit,
    modifier: Modifier = Modifier,
    previewArtwork: ImageBitmap? = null,
) {
    if (movies.isEmpty()) return

    BoxWithConstraints(modifier.fillMaxWidth()) {
        val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
        Row(
            modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            val featured = movies.first()
            // Down is stated rather than left to Compose's spatial search. The hero cards
            // are far wider than the cards below them, so the nearest-centre rule reaches
            // past the first card of the shelf — Continue Watching would open on the second
            // title when the whole point of that row is the first.
            var featuredModifier: Modifier = Modifier
                .weight(1f)
                .fillMaxHeight()
                .focusProperties {
                    left = navigationFocusRequester
                    if (downFocusRequester != null) down = downFocusRequester
                }
            if (contentEntryFocusRequester != null) {
                featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
            }
            if (featured.item.id == returnFocusItemId && returnFocusRequester != null) {
                featuredModifier = featuredModifier.focusRequester(returnFocusRequester)
            }
            FeaturedMovieCard(
                pick = featured,
                onFocused = { onItemFocused(featured.item) },
                onClick = { onItemSelected(featured.item) },
                modifier = featuredModifier,
                previewArtwork = previewArtwork,
            )

            Column(
                modifier = Modifier.width(miniWidth).fillMaxHeight(),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                val minis = movies.drop(1).take(3)
                minis.forEachIndexed { index, pick ->
                    var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
                    // Only the bottom mini leaves the hero by Down; the ones above it are
                    // still walking their own column.
                    if (downFocusRequester != null && index == minis.lastIndex) {
                        miniModifier = miniModifier.focusProperties { down = downFocusRequester }
                    }
                    if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
                        miniModifier = miniModifier.focusRequester(returnFocusRequester)
                    }
                    MiniMovieCard(
                        pick = pick,
                        onFocused = { onItemFocused(pick.item) },
                        onClick = { onItemSelected(pick.item) },
                        modifier = miniModifier,
                        previewArtwork = previewArtwork,
                    )
                }
            }
        }
    }
}

/**
 * A wash over the artwork, keyed to why the card is on the shelf rather than to its slot.
 *
 * The gateway's captions are matched here as strings rather than as an enum for the same
 * reason their wording lives on the server: a caption this build has never heard of gets
 * the neutral wash and reads correctly, where a `when` over a sealed type would have to be
 * taught every new one in an app release.
 */
private fun labelTint(label: String): Color = when (label) {
    LABEL_NEW -> Color(0x667253B7)
    LABEL_POPULAR -> Color(0x66499BD5)
    // A premiere is a different kind of news from a film, and reads as one.
    "SERIES PREMIERE", "NEW SEASON" -> Color(0x664F9E7A)
    "HIGHLY RATED" -> Color(0x66B8873F)
    else -> Color(0x66C67A42)
}

@Composable
private fun FeaturedMovieCard(
    pick: HomeHeroPick,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    modifier: Modifier,
    previewArtwork: ImageBitmap?,
) {
    val item = pick.item
    FocusScaleContainer(
        onFocused = onFocused,
        onClick = onClick,
        // Not "Featured movie": a series premiere can lead, and a screen reader announcing
        // one as a movie is worse than one announcing it by the caption it is wearing.
        contentDescription = "Featured, ${pick.label}, ${item.name}",
        modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
    ) { focused ->
        Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
            HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
            Box(
                Modifier.fillMaxSize().background(
                    Brush.horizontalGradient(
                        0f to MembySurface.copy(alpha = 0.95f),
                        0.48f to MembySurface.copy(alpha = 0.66f),
                        1f to MembySurface.copy(alpha = 0.09f),
                    ),
                ),
            )
            Box(
                Modifier.fillMaxSize().background(
                    Brush.verticalGradient(
                        0f to Color.Transparent,
                        0.72f to Color.Transparent,
                        1f to Color(0xD9000000),
                    ),
                ),
            )
            // Play is measured before the words, and that is the whole layout.
            //
            // This column is the height of a fixed-height card. A Column hands each child
            // the height left after the ones before it, so the chip — being last — was
            // given whatever a two-line title had not already taken, and rendered as a
            // green sliver with its label squeezed out of it. Not clipped: *compressed*,
            // which is why it looked malformed rather than missing.
            //
            // Putting the text in a `weight(1f, fill = false)` child inverts the order:
            // weighted children are measured from what is left over, so the spacer and the
            // chip take their natural size first and the prose is what gives way. The
            // synopsis still stands down on its own when the title wraps, so in practice
            // nothing has to be cut at all — but the button can no longer be the thing that
            // pays for a long title, whatever the artwork, the ratings strip or the
            // viewport do.
            var titleLines by remember(item.id) { mutableIntStateOf(1) }
            Column(
                modifier = Modifier
                    .align(Alignment.CenterStart)
                    .fillMaxWidth(0.58f)
                    .fillMaxHeight()
                    .padding(22.dp),
                verticalArrangement = Arrangement.Center,
            ) {
                Column(Modifier.weight(1f, fill = false)) {
                    // No eyebrow on the featured card. "NEW RELEASE" over a fact line that
                    // already prints the year was a whole line of the card's height spent
                    // on the least specific thing on it — and it is the line that pushed a
                    // wrapped title into the button. The three minis beside it keep theirs:
                    // they have no fact line, and there the label is the only reason given.
                    Text(
                        item.name,
                        color = Color.White,
                        fontSize = 30.sp,
                        lineHeight = 32.sp,
                        fontWeight = FontWeight.Bold,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        onTextLayout = { titleLines = it.lineCount },
                    )
                    Spacer(Modifier.height(9.dp))
                    HeroFactLine(item)
                    // The reason takes the synopsis's place rather than adding a line to
                    // it. It is why this card is leading — "Well reviewed, released
                    // yesterday" — which is more use here than the first two lines of a
                    // plot the detail page prints in full, and one line where the synopsis
                    // is two, so preferring it can only make the card shorter. There is
                    // still no eyebrow above the title: that was the line that pushed a
                    // wrapped title into the button.
                    //
                    // It sits *above* the ratings strip, and that order is load-bearing.
                    // This column is the one that gives way when a title wraps onto two
                    // lines, and whatever is last in it is what gets cut — with the reason
                    // below the strip, the one line explaining why this card is leading
                    // the launcher was silently dropped on exactly the long-titled films
                    // most likely to be leading it. The scores are also on the detail page
                    // this card opens; the reason is not anywhere else.
                    val reason = pick.reason?.takeIf(String::isNotBlank)
                    if (reason != null) {
                        Spacer(Modifier.height(7.dp))
                        Text(
                            reason,
                            color = MembyAccent,
                            fontSize = 13.sp,
                            lineHeight = 17.sp,
                            fontWeight = FontWeight.SemiBold,
                            maxLines = 1,
                            overflow = TextOverflow.Ellipsis,
                        )
                    }
                    ItemRatingsStrip(
                        item = item,
                        load = true,
                        modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
                    )
                    if (reason == null && titleLines == 1) {
                        item.overview?.takeIf(String::isNotBlank)?.let { overview ->
                            Spacer(Modifier.height(9.dp))
                            Text(
                                overview,
                                color = MembyMutedText,
                                fontSize = 13.sp,
                                lineHeight = 17.sp,
                                maxLines = 2,
                                overflow = TextOverflow.Ellipsis,
                            )
                        }
                    }
                }
                Spacer(Modifier.height(14.dp))
                MembyPlayChip(label = "Play", focused = focused)
            }
        }
    }
}

/**
 * Year, length and certificate — the same wording and order as the card directly beneath
 * it and the detail page it opens. The hero used to carry its own formatter and print
 * "2026 • M • 124m" over a row printing "2026 • 2h 4m". Scores are not here: they belong
 * to the ratings strip, which names the provider behind each one.
 */
@Composable
private fun HeroFactLine(item: BaseItem) {
    val facts = heroFacts(item)
    if (facts.isEmpty()) return
    Text(
        facts.joinToString(FactSeparator),
        color = MembyMutedText,
        fontSize = 13.sp,
        fontWeight = FontWeight.SemiBold,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
}

@Composable
private fun MiniMovieCard(
    pick: HomeHeroPick,
    onFocused: () -> Unit,
    onClick: () -> Unit,
    modifier: Modifier,
    previewArtwork: ImageBitmap?,
) {
    val item = pick.item
    FocusScaleContainer(
        onFocused = onFocused,
        onClick = onClick,
        contentDescription = "${pick.label} movie, ${item.name}",
        modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
    ) { focused ->
        Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
            HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
            Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
            Box(
                Modifier.fillMaxSize().background(
                    Brush.horizontalGradient(
                        0f to MembySurface.copy(alpha = 0.91f),
                        0.78f to MembySurface.copy(alpha = 0.52f),
                        1f to MembySurface.copy(alpha = 0.19f),
                    ),
                ),
            )
            Column(Modifier.align(Alignment.CenterStart).padding(horizontal = 14.dp, vertical = 10.dp)) {
                Text(
                    pick.label,
                    color = if (focused) MembyAccent else MembyQuietText,
                    fontSize = 9.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 0.8.sp,
                )
                Spacer(Modifier.height(4.dp))
                Text(
                    item.name,
                    color = Color.White,
                    fontSize = 15.sp,
                    lineHeight = 17.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
                item.productionYear?.let { year ->
                    Spacer(Modifier.height(3.dp))
                    Text(year.toString(), color = MembyQuietText, fontSize = 10.sp)
                }
            }
            if (focused) {
                Box(
                    modifier = Modifier
                        .align(Alignment.CenterEnd)
                        .padding(end = 16.dp)
                        .size(38.dp)
                        .shadow(14.dp, CircleShape)
                        .clip(CircleShape)
                        .background(MembyAccent)
                        .border(2.dp, Color.White, CircleShape),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        Icons.Default.PlayArrow,
                        contentDescription = null,
                        tint = Color.White,
                        modifier = Modifier.size(24.dp),
                    )
                }
            }
        }
    }
}

@Composable
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
    if (previewArtwork != null) {
        Image(
            bitmap = previewArtwork,
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = modifier,
        )
        return
    }
    val repo = ServiceLocator.repository
    val density = LocalDensity.current
    val artworkWidth = with(density) { 760.dp.roundToPx() }.coerceIn(480, 1280)
    val artwork = remember(item.id, artworkWidth) {
        repo.backdropUrl(item, artworkWidth) ?: repo.primaryUrl(item, artworkWidth)
    }
    if (artwork != null) {
        AsyncImage(
            model = artwork,
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = modifier,
        )
    } else {
        Box(
            modifier.background(
                Brush.linearGradient(
                    listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)),
                ),
            ),
        )
    }
}
