package com.ponzischeme89.memby.ui

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.data.toMembyPalette
import com.ponzischeme89.memby.ui.seasonal.Decorations
import com.ponzischeme89.memby.ui.seasonal.drawSeasonalField
import com.ponzischeme89.memby.ui.settings.SettingsPanelActions
import com.ponzischeme89.memby.ui.settings.SettingsPanelContent
import com.ponzischeme89.memby.ui.settings.SettingsPanelState
import com.ponzischeme89.memby.ui.settings.SettingsPage
import com.ponzischeme89.memby.ui.settings.ChoiceOption
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

/**
 * Every colour scheme on a real screen, and the picker that chooses between them, to
 * `build/screenshots/themes/`.
 *
 * ```powershell
 * .\gradlew.bat :app:testDebugUnitTest --tests "*ThemeScreenshotTest"
 * ```
 *
 * This is the feature that needs looking at more than any other, because the only thing a
 * unit test can check about a palette is that its hex parses. What it cannot check is whether
 * the pale accent on Easter is legible against its own near-black, whether Halloween's quiet
 * text survives on an orange-tinted surface, or whether the hairline under the tab strip
 * still exists on Forest. Those are single hex digits in `themes.go` and every one of them is
 * a judgement — a scheme nobody has looked at is a scheme shipped on faith.
 *
 * The sweep deliberately renders **one screen** across every theme rather than several
 * screens on one theme. A series page is the densest thing in the app for this purpose: it
 * puts the surface, the accent (Play), all three text weights and the hairline in one frame,
 * so the whole palette can be judged side by side across nine images that differ in nothing
 * but colour.
 *
 * The palettes are duplicated here from the gateway's catalogue as a **fixture**, not as a
 * second copy of a rule — nothing in the app derives a colour from this list, and if it
 * drifts the cost is a picture of a theme that has changed, which is the ordinary risk every
 * screenshot fixture carries. Which theme is actually in force is decided in exactly one
 * place, on the server, and is pinned there by `themes_test.go`.
 */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ThemeScreenshotTest {

    @get:Rule
    val compose = createComposeRule()

    @Before
    fun locator() {
        ServiceLocator.init(ApplicationProvider.getApplicationContext())
    }

    /**
     * The palette is process-wide state, which is the whole reason a theme can repaint five
     * separate composition roots. It also means a test that left one applied would tint every
     * screenshot taken after it in the same JVM, so this reset is load-bearing rather than
     * tidiness.
     */
    @After
    fun untheme() {
        applyMembyPalette(MembyPalette())
    }

    /** Every scheme an operator can hand out, on the same page, in catalogue order. */
    @Test
    fun `every selectable scheme on a series page`() {
        sweep("scheme", Themes.selectable)
    }

    /**
     * The three nobody chooses. Worth its own test rather than folding into the sweep above,
     * because these are the only palettes that appear on a television without anybody asking
     * for them — so "would I be annoyed to find my launcher like this" is a question that has
     * to be answered by looking, before a date makes it everybody's problem at once.
     */
    @Test
    fun `the seasonal schemes`() {
        sweep("season", Themes.seasonal)
    }

    /** The picker as an unrestricted viewer sees it, on the theme they have chosen. */
    @Test
    fun `the picker`() {
        applyMembyPalette(theme("indigo").palette.toMembyPalette())
        capture("picker-open") {
            appearancePage(selected = "indigo", options = Themes.selectable)
        }
    }

    /**
     * The operator has left this person two schemes. The row is drawn from what the server
     * sent, so a withheld theme is absent rather than greyed — which is the thing to check
     * here, along with whether two chips look deliberate or look like something failed to
     * load.
     */
    @Test
    fun `a viewer the operator has restricted`() {
        applyMembyPalette(theme("graphite").palette.toMembyPalette())
        capture("picker-restricted") {
            appearancePage(
                selected = "graphite",
                options = Themes.selectable.filter { it.id in setOf("midnight", "graphite") },
            )
        }
    }

    /**
     * December. The page is painted Christmas, the viewer's own chip is still the one
     * selected, and the line underneath is the server's explanation.
     *
     * This is the case worth looking at hardest, because it is the only setting in the app
     * that can be overruled and it has one job: to not read as broken. A viewer who presses
     * a chip and sees nothing happen, with no sentence saying why, files a bug.
     */
    @Test
    fun `the picker while a season is on`() {
        applyMembyPalette(theme("christmas").palette.toMembyPalette())
        capture("picker-locked") {
            appearancePage(
                selected = "plum",
                options = Themes.selectable,
                locked = true,
                notice = "Christmas is on for everyone until it is over.",
            )
        }
    }

    /**
     * A gateway that predates themes, or the direct path with no gateway at all: nothing was
     * offered, so the row is not drawn and Appearance is the page it always was. Compare with
     * `picker-open` — the difference must be one missing row and nothing shifted.
     */
    @Test
    fun `no schemes offered`() {
        capture("picker-absent") { appearancePage(selected = "midnight", options = emptyList()) }
    }

    /**
     * The decorations, three frames each, over the palette they belong to.
     *
     * Three rather than one because the only thing worth checking about an animation in a
     * still is that it *is* one — that the field is spread rather than clumped at any given
     * moment, and that a bat's wings are somewhere different in each. `drawSeasonalField`
     * takes the progress, so this needs no animation clock and cannot be flaky: the same
     * number always draws the same picture.
     *
     * `0.0` and `1.0` are both captured for snow, and they must come out **identical**. That
     * is the seamless wrap the whole particle scheme is built around, and it is the one
     * property nobody could confirm by watching a television for less than a minute.
     */
    @Test
    fun `snow`() {
        frames("christmas", Decorations.SNOW, listOf(0f, 0.37f, 1f))
    }

    /** One test each, because a compose rule allows one `setContent` per test. */
    @Test
    fun `bats`() {
        frames("halloween", Decorations.BATS, listOf(0f, 0.37f, 0.74f))
    }

    @Test
    fun `blossom`() {
        frames("easter", Decorations.BLOSSOM, listOf(0f, 0.37f, 0.74f))
    }

    /**
     * Snow over a page with content on it — the only view that answers the question that
     * actually matters. Decoration competing with the title, the facts and the Play button is
     * worse than no decoration, and no amount of looking at particles on plain black would
     * ever show it.
     */
    @Test
    fun `a decoration over a page somebody is reading`() {
        val palette = theme("christmas").palette.toMembyPalette()
        applyMembyPalette(palette)
        capture("decoration-over-content") {
            Box {
                seriesPage()
                Canvas(Modifier.fillMaxSize()) {
                    drawSeasonalField(Decorations.SNOW, 0.37f, palette.accent, palette.onSurface)
                }
            }
        }
    }

    // ---------------------------------------------------------------------------

    /**
     * One field, composed once, captured at several points through its cycle.
     *
     * The progress is snapshot state read inside the draw lambda — the same arrangement the
     * real animation uses, so moving it here invalidates the draw and nothing else. That is
     * not incidental to the test: if advancing the frame recomposed anything, this would be
     * quietly measuring a different thing from what ships.
     */
    private fun frames(themeId: String, decoration: String, progressions: List<Float>) {
        val palette = theme(themeId).palette.toMembyPalette()
        applyMembyPalette(palette)
        val progress = mutableFloatStateOf(progressions.first())
        compose.setContent {
            Box(Modifier.fillMaxSize().background(palette.surface)) {
                Canvas(Modifier.fillMaxSize()) {
                    drawSeasonalField(decoration, progress.floatValue, palette.accent, palette.onSurface)
                }
            }
        }
        progressions.forEach { at ->
            progress.floatValue = at
            compose.waitForIdle()
            compose.onRoot().captureRoboImage(
                "build/screenshots/themes/decoration-$decoration-${(at * 100).toInt()}.png",
            )
        }
    }

    /**
     * One page, composed once, captured under each palette in turn.
     *
     * Composed once because `setContent` may only be called once per test — but it is also
     * the more honest picture. Repainting a live composition is exactly what happens on a
     * television when a season begins under somebody who is already looking at the screen,
     * and it only works because the tokens are snapshot state: nothing here re-composes the
     * page or tells it anything changed.
     */
    private fun sweep(prefix: String, themes: List<ThemeFixture>) {
        compose.setContent {
            PreviewSurface(alignment = Alignment.TopStart) { seriesPage() }
        }
        themes.forEach { theme ->
            applyMembyPalette(theme.palette.toMembyPalette())
            compose.waitForIdle()
            compose.onRoot().captureRoboImage("build/screenshots/themes/$prefix-${theme.id}.png")
        }
    }

    private fun capture(name: String, content: @Composable () -> Unit) {
        compose.setContent {
            PreviewSurface(alignment = Alignment.TopStart) { content() }
        }
        compose.onRoot().captureRoboImage("build/screenshots/themes/$name.png")
    }

    @Composable
    private fun seriesPage() {
        SeriesDetailContent(
            item = series,
            episodes = episodes,
            loadFailed = false,
            onPlay = {},
            onToggleFavorite = { _, _ -> },
            isMyShow = false,
            onToggleMyShow = { _, _ -> },
        )
    }

    @Composable
    private fun appearancePage(
        selected: String,
        options: List<ThemeFixture>,
        locked: Boolean = false,
        notice: String = "",
    ) {
        SettingsPanelContent(
            state = SettingsPanelState(
                selectedPage = SettingsPage.APPEARANCE,
                themeId = selected,
                themeOptions = options.map { theme ->
                    ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent))
                },
                themeLocked = locked,
                themeNotice = notice,
                installedVersion = "0.2.28",
            ),
            actions = SettingsPanelActions(),
            overlay = false,
        )
    }

    private fun theme(id: String): ThemeFixture =
        (Themes.selectable + Themes.seasonal).first { it.id == id }

    private val series = BaseItem(
        id = "series-1",
        name = "Signal Hill",
        type = "Series",
        overview = "A coastal radio station keeps receiving a broadcast that has not been " +
            "transmitted yet. Six weeks before the storm, the night operator starts writing " +
            "down what she hears.",
        productionYear = 2022,
        officialRating = "TV-MA",
        genres = listOf("Thriller", "Drama"),
        studios = listOf(Studio(name = "Harbour Line")),
        status = "Ended",
    )

    private val episodes = (1..8).map { number ->
        BaseItem(
            id = "s1e$number",
            name = EPISODE_TITLES[number - 1],
            type = "Episode",
            seriesName = "Signal Hill",
            parentIndexNumber = 1,
            indexNumber = number,
            runTimeTicks = 48L * 600_000_000L,
        )
    }

    /** One theme as the gateway describes it. A fixture; see the note at the top. */
    internal data class ThemeFixture(
        val id: String,
        val name: String,
        val palette: GatewayPalette,
    )

    private object Themes {
        val selectable = listOf(
            fixture(
                "midnight", "Midnight",
                "#FF090B0D", "#FF101418", "#FF52B54B", "#FFE2E5E8",
                "#FFD0D6DB", "#FFAEB7BF", "#28FFFFFF", "#FF20252A",
            ),
            fixture(
                "graphite", "Graphite",
                "#FF0D0C0A", "#FF181614", "#FFE0A33C", "#FFE9E5DE",
                "#FFD8D2C8", "#FFB6AEA1", "#28FFF3DC", "#FF262320",
            ),
            fixture(
                "indigo", "Indigo",
                "#FF07090F", "#FF111726", "#FF5C8DFF", "#FFE1E6F0",
                "#FFCBD4E4", "#FFA5B0C6", "#28C7D8FF", "#FF1D2435",
            ),
            fixture(
                "ember", "Ember",
                "#FF0C0808", "#FF181111", "#FFE05B4A", "#FFEDE3E1",
                "#FFDACECB", "#FFB8A7A3", "#28FFD5CE", "#FF261B1A",
            ),
            fixture(
                "forest", "Forest",
                "#FF080B09", "#FF111713", "#FF7FC08A", "#FFE3E8E3",
                "#FFCFD8CF", "#FFA9B5AA", "#28D2F0D6", "#FF1E2620",
            ),
            fixture(
                "plum", "Plum",
                "#FF0B080D", "#FF171020", "#FFB37FE0", "#FFE7E2EC",
                "#FFD5CCDD", "#FFB0A4BC", "#28E4D2FF", "#FF241B2D",
            ),
        )

        val seasonal = listOf(
            fixture(
                "halloween", "Halloween",
                "#FF0A0704", "#FF17100A", "#FFFF8A1F", "#FFF2E7DA",
                "#FFE2D2BE", "#FFBBA48C", "#28FFB870", "#FF26190E",
            ),
            fixture(
                "christmas", "Christmas",
                "#FF060A07", "#FF0E1710", "#FFE0403F", "#FFEAF0E9",
                "#FFD6E0D5", "#FFAEBCAE", "#28CFE8CF", "#FF19261B",
            ),
            fixture(
                "easter", "Easter",
                "#FF0A0910", "#FF15131F", "#FF9BD3F0", "#FFEDE9F2",
                "#FFDCD6E4", "#FFB6AEC4", "#28D8E9F7", "#FF211E2E",
            ),
        )

        @Suppress("LongParameterList")
        private fun fixture(
            id: String,
            name: String,
            surface: String,
            surfaceRaised: String,
            accent: String,
            onSurface: String,
            mutedText: String,
            quietText: String,
            hairline: String,
            ratingsSurface: String,
        ) = ThemeFixture(
            id, name,
            GatewayPalette(
                surface = surface, surfaceRaised = surfaceRaised, accent = accent,
                onSurface = onSurface, mutedText = mutedText, quietText = quietText,
                hairline = hairline, ratingsSurface = ratingsSurface,
            ),
        )
    }

    private companion object {
        val EPISODE_TITLES = listOf(
            "Carrier Wave", "Dead Air", "The Long Count", "Nightingale",
            "Six Weeks Out", "Landfall", "The Shipping Forecast", "Quiet Hours",
        )
    }
}
