package com.ponzischeme89.memby.ui

import androidx.compose.ui.unit.dp
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class HomeMovieHeroTest {
    @Test
    fun `home hero follows the vertical scroll position`() {
        assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true))
        assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, listAtTop = false))
        assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
    }

    @Test
    fun `a card in a shelf stands the hero down without any scrolling`() {
        // Continue Watching is the first row, so browsing it moves the list not at all.
        assertEquals(
            false,
            shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = true),
        )
        // And Up out of that row, which clears the flag, is what brings the hero back.
        assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = false))
        // A viewer partway down the launcher still gets no hero for losing row focus.
        assertEquals(
            false,
            shouldShowHomeMovieHero(hasMovies = true, listAtTop = false, rowFocused = false),
        )
    }

    @Test
    fun `home hero leaves room for a complete shelf on a 540dp tv`() {
        val heroHeight = homeHeaderHeight(540.dp, showHero = true)

        assertEquals(248.4f, heroHeight.value, 0.01f)
        assertTrue(540.dp - heroHeight >= 288.dp)
    }

    @Test
    fun `hero alternates new releases and popular movies`() {
        val rows = listOf(
            row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
            row("popular", "Popular Movies", "popular-1", "popular-2"),
        )

        assertEquals(
            listOf("new-1", "popular-1", "new-2", "popular-2"),
            selectHomeHeroMovies(rows).map { it.item.id },
        )
    }

    /**
     * The caption used to be the card's slot, so the third card was always "TRENDING"
     * whatever it was. It now names the row the title was actually drawn from.
     */
    @Test
    fun `hero labels each pick by the row it came from`() {
        val rows = listOf(
            row("latest-movies", "Recently Added Movies", "new-1"),
            row("popular", "Popular Movies", "popular-1"),
            row("comedy", "Comedy", "other-1"),
        )

        assertEquals(
            listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"),
            selectHomeHeroMovies(rows).map { it.label },
        )
    }

    @Test
    fun `hero removes repeated movies across source rows`() {
        val rows = listOf(
            row("latest", "New releases", "shared", "new-2"),
            row("recommended", "Recommended", "shared", "popular-2", "popular-3"),
        )

        assertEquals(4, selectHomeHeroMovies(rows).size)
        assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
    }

    // --- The gateway's hero ------------------------------------------------------

    /**
     * The server can see Radarr's digital release dates, Sonarr's premieres and the
     * review scores; the television can see which shelf a title came off. So where the
     * gateway has composed a hero, its order wins outright — reordering it here could
     * only ever throw that evidence away.
     */
    @Test
    fun `the server's hero row wins over the local rule`() {
        val browseRows = listOf(row("latest-movies", "Recently Added Movies", "local-1", "local-2"))
        val serverRows = listOf(
            heroRow(
                heroItem("server-1", "SERIES PREMIERE", "A new series premiered yesterday"),
                heroItem("server-2", "NEW RELEASE", "Well reviewed, released on Monday"),
            ),
        )

        val picks = selectHomeHeroMovies(browseRows, day = 5, serverRows = serverRows)

        assertEquals(listOf("server-1", "server-2"), picks.map { it.item.id })
        assertEquals(listOf("SERIES PREMIERE", "NEW RELEASE"), picks.map { it.label })
        assertEquals("A new series premiered yesterday", picks.first().reason)
    }

    /**
     * No day rotation on the server's hero. The facts behind it already change daily, and
     * rotating a merit ranking is exactly how the best-reviewed release of the week ends
     * up in the fourth slot.
     */
    @Test
    fun `the server's hero keeps its ranking on every day`() {
        val serverRows = listOf(
            heroRow(
                heroItem("first", "NEW RELEASE", null),
                heroItem("second", "NEW RELEASE", null),
                heroItem("third", "HIGHLY RATED", null),
            ),
        )

        (0L..7L).forEach { day ->
            assertEquals(
                "day $day",
                listOf("first", "second", "third"),
                selectHomeHeroMovies(emptyList(), day, serverRows).map { it.item.id },
            )
        }
    }

    /** The direct path has no gateway to ask, and an older one sends no hero row. */
    @Test
    fun `the local rule is the fallback when no hero row arrives`() {
        val browseRows = listOf(row("latest-movies", "Recently Added Movies", "new-1", "new-2"))

        assertEquals(
            selectHomeHeroMovies(browseRows, day = 0),
            selectHomeHeroMovies(browseRows, day = 0, serverRows = listOf(heroRow())),
        )
        assertEquals(
            listOf("new-1", "new-2"),
            selectHomeHeroMovies(browseRows, day = 0, serverRows = emptyList())
                .map { it.item.id },
        )
    }

    /** A card that cannot be pressed is news for the schedule row, not a hero. */
    @Test
    fun `the server's hero drops anything that is not playable`() {
        val serverRows = listOf(
            heroRow(
                heroItem("upcoming", "NEW RELEASE", null).copy(membyPlayable = false),
                heroItem("playable", "NEW RELEASE", null),
            ),
        )

        assertEquals(
            listOf("playable"),
            selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.item.id },
        )
    }

    /**
     * A caption is the gateway's wording, so this build must survive one it has never
     * heard of — and a card that somehow arrives with none falls back to the neutral
     * caption rather than claiming something nobody said.
     */
    @Test
    fun `an unfamiliar or missing caption still draws a card`() {
        val serverRows = listOf(
            heroRow(
                heroItem("future", "STAFF PICK OF THE WEEK", null),
                heroItem("bare", null, null),
            ),
        )

        assertEquals(
            listOf("STAFF PICK OF THE WEEK", "FROM YOUR LIBRARY"),
            selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.label },
        )
    }

    /** Four cards is what the hero draws, however many the row carries. */
    @Test
    fun `the server's hero is capped at four cards`() {
        val serverRows = listOf(
            heroRow(*(1..8).map { heroItem("item-$it", "NEW RELEASE", null) }.toTypedArray()),
        )

        assertEquals(4, selectHomeHeroMovies(emptyList(), 0, serverRows).size)
    }

    // --- Daily variants ----------------------------------------------------------

    @Test
    fun `each day leads with a different new release`() {
        val rows = listOf(
            row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
            row("popular", "Popular Movies", "popular-1", "popular-2"),
        )

        assertEquals("new-1", selectHomeHeroMovies(rows, day = 0).first().item.id)
        assertEquals("new-2", selectHomeHeroMovies(rows, day = 1).first().item.id)
        assertEquals("new-3", selectHomeHeroMovies(rows, day = 2).first().item.id)
    }

    /** The pool is finite, so it has to come round rather than run out. */
    @Test
    fun `the rotation wraps once the pool is exhausted`() {
        val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))

        assertEquals(
            selectHomeHeroMovies(rows, day = 0).map { it.item.id },
            selectHomeHeroMovies(rows, day = 3).map { it.item.id },
        )
    }

    /**
     * The launcher rebuilds constantly — every home refresh, every focus change. Asking
     * twice within a day has to give the same four cards or the hero would churn under a
     * viewer who was only walking past.
     */
    @Test
    fun `the same day always picks the same cards`() {
        val rows = listOf(
            row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
            row("popular", "Popular Movies", "popular-1", "popular-2"),
        )

        assertEquals(
            selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
            selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
        )
    }

    /** A clock that has not been set yet reports an instant before the epoch. */
    @Test
    fun `a negative day still fills the hero`() {
        val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))

        assertEquals(3, selectHomeHeroMovies(rows, day = -1).size)
        assertEquals("new-3", selectHomeHeroMovies(rows, day = -1).first().item.id)
    }

    @Test
    fun `rotation never drops or duplicates a title`() {
        val rows = listOf(
            row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
            row("popular", "Popular Movies", "popular-1", "popular-2"),
        )

        (0L..7L).forEach { day ->
            val picked = selectHomeHeroMovies(rows, day).map { it.item.id }
            assertEquals("day $day", picked.size, picked.distinct().size)
        }
    }

    // --- Midnight ----------------------------------------------------------------

    /** Local, not UTC: the day must turn over at the viewer's midnight. */
    @Test
    fun `the day is counted in the viewer's own time zone`() {
        val newYearUtc = 1_735_689_600_000L // 2025-01-01T00:00:00Z

        // An hour ahead: already the 1st. An hour behind: still New Year's Eve.
        assertEquals(
            localEpochDay(newYearUtc, HOUR_MS.toInt()) - 1,
            localEpochDay(newYearUtc, -HOUR_MS.toInt()),
        )
    }

    @Test
    fun `midnight is a whole day away from itself, never zero`() {
        val midnightUtc = 1_735_689_600_000L

        assertEquals(DAY_MS, millisUntilNextLocalDay(midnightUtc, 0))
    }

    @Test
    fun `the wait shortens as the evening goes on`() {
        val midnightUtc = 1_735_689_600_000L

        assertEquals(DAY_MS - HOUR_MS, millisUntilNextLocalDay(midnightUtc + HOUR_MS, 0))
        assertEquals(HOUR_MS, millisUntilNextLocalDay(midnightUtc + 23 * HOUR_MS, 0))
    }

    /** Waiting the reported time must always land on the following day, in any zone. */
    @Test
    fun `waiting the reported time advances the day exactly once`() {
        val offsets = listOf(0, HOUR_MS.toInt(), -5 * HOUR_MS.toInt(), 13 * HOUR_MS.toInt())
        val instants = listOf(0L, 1_735_689_600_000L, 1_735_689_600_000L + 37L, -86_400_001L)

        offsets.forEach { offset ->
            instants.forEach { now ->
                val before = localEpochDay(now, offset)
                val after = localEpochDay(now + millisUntilNextLocalDay(now, offset), offset)
                assertEquals("offset=$offset now=$now", before + 1, after)
            }
        }
    }

    private companion object {
        const val HOUR_MS = 60L * 60L * 1000L
        const val DAY_MS = 24L * HOUR_MS
    }

    private fun heroItem(id: String, label: String?, reason: String?) = BaseItem(
        id = id,
        name = id,
        type = "Movie",
        membyHeroLabel = label,
        membyHeroReason = reason,
    )

    private fun heroRow(vararg items: BaseItem) = HomeRow(
        id = "hero",
        title = "Featured",
        kind = SERVER_HERO_ROW_KIND,
        items = items.toList(),
    )

    private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
        id = id,
        title = title,
        items = ids.map { BaseItem(id = it, name = it, type = "Movie") },
        kind = MediaRowKind.MOVIES,
        emptyMessage = "",
    )
}
