package com.ponzischeme89.memby.data

import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
import com.ponzischeme89.memby.data.model.GatewayMovieRatings
import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayPreferences
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayIntro
import com.ponzischeme89.memby.data.model.GatewayTrickplay
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Contract tests for the gateway wire format.
 *
 * The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope
 * fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either
 * side, this fails before a TV ever sees it.
 */
class GatewayPayloadTest {
    @Test
    fun `partially populated items decode without inventing an identity`() {
        val item = json.decodeFromString<BaseItem>(
            """{"Name":"Incomplete","Type":"Movie"}""",
        )

        assertEquals("", item.id)
        assertEquals("Incomplete", item.name)
    }

    @Test
    fun `decodes server formatted movie rating sources and scales`() {
        val response = json.decodeFromString<GatewayMovieRatings>(
            """{"ratings":[{"source":"imdb","name":"IMDb","score":"8.2","scale":"/10"},{"source":"tomatoes","name":"Rotten Tomatoes","score":"91","scale":"%"}]}""",
        )

        assertEquals(listOf("IMDb", "Rotten Tomatoes"), response.ratings.map { it.name })
        assertEquals(listOf("8.2/10", "91%"), response.ratings.map { it.score + it.scale })
    }

    @Test
    fun `decodes signed in devices without an allowance`() {
        val response = json.decodeFromString<GatewayDevices>(
            """{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""",
        )

        assertEquals(1, response.devices.size)
        assertEquals("Living room", response.devices.single().deviceName)
        assertTrue(response.devices.single().current)
    }


    @Test
    fun `decodes ratings carried on a row item and defaults them when absent`() {
        val carried = json.decodeFromString<BaseItem>(
            """{"Id":"7","Name":"Arrival","Type":"Movie","MembyRatings":[{"source":"imdb","name":"IMDb","score":"7.9","scale":"/10"}]}""",
        )
        assertEquals(listOf("IMDb"), carried.membyRatings.map { it.name })
        assertEquals("7.9", carried.membyRatings.single().score)

        // A gateway that has never looked the title up, an older build, and every cached
        // home payload written before this field existed all decode to no ratings rather
        // than failing — the card then falls back to the dedicated request on focus.
        val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
        assertTrue(bare.membyRatings.isEmpty())
    }

    /**
     * The hero row's caption and reason. Both are the gateway's wording, so this only has
     * to carry them through — and both must be absent-safe, because the direct path picks
     * the hero itself and every home cache written before the feature has neither.
     */
    @Test
    fun `decodes the hero row's caption and reason and defaults them when absent`() {
        val payload = """
            {
              "rows":[{
                "id":"hero",
                "title":"Featured",
                "kind":"hero",
                "items":[{
                  "Id":"emby-9",
                  "Name":"The Return",
                  "Type":"Series",
                  "MembyHeroLabel":"SERIES PREMIERE",
                  "MembyHeroReason":"A new series premiered yesterday"
                }]
              }],
              "continueWatching":[],
              "nextUp":[],
              "favorites":[],
              "latestMovies":[],
              "partial":false
            }
        """.trimIndent()

        val row = json.decodeFromString<GatewayHome>(payload).rows.single()
        assertEquals("hero", row.kind)
        val item = row.items.single()
        assertEquals("SERIES PREMIERE", item.membyHeroLabel)
        assertEquals("A new series premiered yesterday", item.membyHeroReason)
        // A hero card is playable by construction; the field is defaulted so a payload
        // that omits it is not mistaken for a schedule card the viewer cannot press.
        assertTrue(item.membyPlayable)

        val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
        assertEquals(null, bare.membyHeroLabel)
        assertEquals(null, bare.membyHeroReason)
    }

    private val json = Json {
        ignoreUnknownKeys = true
        coerceInputValues = true
        isLenient = true
        explicitNulls = false
    }

    @Test
    fun `decodes explicit client server protocol mismatch`() {
        val status = json.decodeFromString<GatewayServiceStatus>(
            """{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1}""",
        )

        assertEquals(false, status.compatible)
        assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
        assertEquals("0.1.29", status.gatewayVersion)
        assertEquals(1, status.serverProtocol)
    }

    @Test
    fun `decodes live feature policy and recovery state`() {
        val status = json.decodeFromString<GatewayServiceStatus>(
            """{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
        )
        val policy = json.decodeFromString<GatewayFeatures>(
            """{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""",
        )

        assertEquals(7L, status.featureRevision)
        assertEquals(false, status.features["sonarr_preroll"])
        assertTrue(policy.safeMode)
        assertTrue(policy.canRollback)
        assertEquals("safe_mode", policy.features.single().source)
    }

    @Test
    fun `decodes the live emby reachability the outage bar renders`() {
        val status = json.decodeFromString<GatewayServiceStatus>(
            """{"maintenance":false,"emby":{"monitored":true,"reachable":false,"since":"2026-08-04T19:04:00Z","checkedAt":"2026-08-04T19:05:00Z","retrySeconds":60}}""",
        )

        assertTrue(status.emby.isOutage)
        assertEquals(60, status.emby.retrySeconds)
    }

    /**
     * A server predating the field must not produce a red bar. The default is a healthy,
     * unmonitored reading precisely so an app that cannot tell says nothing.
     */
    @Test
    fun `a status payload without emby health reports no outage`() {
        val status = json.decodeFromString<GatewayServiceStatus>("""{"maintenance":false}""")

        assertEquals(false, status.emby.isOutage)
        assertEquals(0L, status.preferencesRevision)
    }

    @Test
    fun `decodes the settings revision an operator push arrives as`() {
        val status = json.decodeFromString<GatewayServiceStatus>(
            """{"maintenance":false,"preferencesRevision":12}""",
        )

        assertEquals(12L, status.preferencesRevision)
    }

    /**
     * The document stays a JsonObject on the wire so a server that has learned a setting
     * this build has never heard of is still decodable — the unknown key is simply carried
     * past by [decodeUserPreferences].
     */
    @Test
    fun `decodes a settings document containing an unknown setting`() {
        val payload = json.decodeFromString<GatewayPreferences>(
            """{"schemaVersion":1,"revision":4,"source":"admin","preferences":{"homeCardDensity":"large","hideWatchedMovies":true,"somethingNewer":"value"}}""",
        )
        val decoded = decodeUserPreferences(payload.preferences)

        assertEquals(4L, payload.revision)
        assertEquals("admin", payload.source)
        assertEquals("large", decoded.homeCardDensity)
        assertTrue(decoded.hideWatchedMovies)
    }

    @Test
    fun `decodes recommendation onboarding ratings and emby items`() {
        val onboarding = json.decodeFromString<RecommendationOnboarding>(
            """{
                "completed":false,
                "prompted":true,
                "ratings":{"movie-1":5},
                "items":[
                    {"Id":"movie-1","Name":"Arrival","Type":"Movie"},
                    {"Id":"series-1","Name":"Severance","Type":"Series"}
                ],
                "movies":[{"Id":"movie-1","Name":"Arrival","Type":"Movie"}],
                "shows":[{"Id":"series-1","Name":"Severance","Type":"Series"}],
                "actors":[{"id":"actor-1","name":"Jeremy Renner","imageTag":"portrait-1"}],
                "actresses":[{"id":"actor-2","name":"Amy Adams","imageTag":"portrait-2"}],
                "directors":[{"id":"director-1","name":"Denis Villeneuve"}]
            }""",
        )

        assertEquals(false, onboarding.completed)
        assertTrue(onboarding.prompted)
        assertEquals(5, onboarding.ratings["movie-1"])
        assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
        assertEquals(listOf("Arrival"), onboarding.movies.map { it.name })
        assertEquals(listOf("Severance"), onboarding.shows.map { it.name })
        assertEquals("Jeremy Renner", onboarding.actors.single().name)
        assertEquals("Amy Adams", onboarding.actresses.single().name)
        assertEquals("Denis Villeneuve", onboarding.directors.single().name)
    }

    @Test
    fun `legacy onboarding response remains promptable`() {
        val onboarding = json.decodeFromString<RecommendationOnboarding>(
            """{"completed":false,"items":[]}""",
        )

        assertTrue(onboarding.prompted)
    }

    @Test
    fun `decodes a home payload with emby-shaped items`() {
        val payload = """
            {
              "continueWatching": [
                {"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}
              ],
              "nextUp": [
                {"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"}
              ],
              "favorites": [
                {"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}}
              ],
              "latestMovies": [],
              "partial": false
            }
        """.trimIndent()

        val home = json.decodeFromString<GatewayHome>(payload)

        assertEquals("Dune", home.continueWatching.single().name)
        assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L)
        assertEquals("Severance", home.nextUp.single().seriesName)
        assertTrue(home.favorites.single().isFavorite)
        assertTrue(home.latestMovies.isEmpty())
        assertEquals(false, home.partial)
    }

    @Test
    fun `decodes server-composed rows including recommendations`() {
        val payload = """
            {
              "rows": [
                {"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]},
                {"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]},
                {"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]},
                {"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]}
              ],
              "continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}],
              "nextUp": [],
              "favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}],
              "latestMovies": [],
              "partial": false
            }
        """.trimIndent()

        val home = json.decodeFromString<GatewayHome>(payload)

        assertEquals(
            listOf("continue", "favorites", "similar:sev", "recommended"),
            home.rows.map { it.id },
        )
        assertEquals("Because you watched Severance", home.rows[2].title)
        assertEquals("Solaris", home.rows.last().items.single().name)
    }

    @Test
    fun `decodes explainable For You metadata`() {
        val payload = """
            {
              "rows":[{
                "id":"for-you:picks",
                "title":"Top picks that fit in 60 minutes",
                "kind":"for-you",
                "items":[{
                  "Id":"9","Name":"Pilot","Type":"Episode",
                  "MembyRecommendationReason":"Matches your Drama viewing · fits your 60-minute window",
                  "MembyCompatibility":"Direct plays well on this TV"
                }]
              }]
            }
        """.trimIndent()

        val item = json.decodeFromString<com.ponzischeme89.memby.data.model.GatewayRows>(payload)
            .rows.single().items.single()

        assertTrue(item.membyRecommendationReason!!.contains("60-minute"))
        assertEquals("Direct plays well on this TV", item.membyCompatibility)
    }

    @Test
    fun `decodes the informational TV schedule row`() {
        val payload = """
            {
              "rows": [{
                "id":"sonarr-airing-today",
                "title":"Shows airing in the next 5 days",
                "kind":"schedule",
                "items":[{
                  "Id":"sonarr:7:42",
                  "Name":"Northbound",
                  "Type":"MembySonarrEpisode",
                  "ImageTags":{"Primary":"sonarr"},
                  "MembySource":"sonarr",
                  "MembyEpisodeTitle":"The Crossing",
                  "MembyEpisodeCode":"S02E04",
                  "MembyAirDayLabel":"Tomorrow",
                  "MembyAirLabel":"Tomorrow: 8:00 PM",
                  "MembyAvailability":"downloading",
                  "MembyAvailabilityText":"Downloading",
                  "MembyLifecycle":"continuing",
                  "MembyLifecycleText":"CONTINUING",
                  "MembyPlayable":false,
                  "MembySeriesItemId":"emby-1041"
                }]
              }],
              "continueWatching":[],
              "nextUp":[],
              "favorites":[],
              "latestMovies":[],
              "partial":false
            }
        """.trimIndent()

        val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()

        assertTrue(item.isTvSchedule)
        assertEquals("S02E04", item.membyEpisodeCode)
        assertEquals("Tomorrow", item.membyAirDayLabel)
        assertEquals("Downloading", item.membyAvailabilityText)
        // Sonarr's lifecycle for the show, which is a different fact from this episode's
        // availability and wears its own badge.
        assertEquals("continuing", item.membyLifecycle)
        assertEquals("CONTINUING", item.membyLifecycleText)
        assertEquals(false, item.membyPlayable)
        // The link that lets an unplayable card open the show's own page.
        assertEquals("emby-1041", item.membySeriesItemId)
    }

    @Test
    fun `a schedule card for a show Emby has not imported carries no series link`() {
        val payload = """
            {
              "rows":[{
                "id":"sonarr-airing-today",
                "title":"Shows airing in the next 5 days",
                "kind":"schedule",
                "items":[{
                  "Id":"sonarr:9:11",
                  "Name":"Unimported",
                  "Type":"MembySonarrEpisode",
                  "MembySource":"sonarr",
                  "MembyAirDayLabel":"Friday",
                  "MembyAirLabel":"Friday: 8:00 PM",
                  "MembyPlayable":false
                }]
              }]
            }
        """.trimIndent()

        val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()

        assertTrue(item.isTvSchedule)
        assertNull(item.membySeriesItemId)
        assertNull(item.membyLifecycle)
    }

    @Test
    fun `decodes the informational Radarr digital release row`() {
        val payload = """
            {
              "rows":[{
                "id":"radarr-upcoming-movies",
                "title":"Upcoming Movie releases",
                "kind":"movie-schedule",
                "items":[{
                  "Id":"radarr:7",
                  "Name":"Arrival",
                  "Type":"MembyRadarrMovie",
                  "ImageTags":{"Primary":"radarr"},
                  "MembySource":"radarr",
                  "MembyAirsAt":"2026-08-01T00:00:00+12:00",
                  "MembyAirDayLabel":"Saturday",
                  "MembyAirLabel":"Digital release Saturday",
                  "MembyAvailability":"upcoming",
                  "MembyAvailabilityText":"Upcoming digital release",
                  "MembyLifecycle":"incinemas",
                  "MembyLifecycleText":"IN CINEMAS",
                  "MembyPlayable":false
                }]
              }]
            }
        """.trimIndent()

        val row = json.decodeFromString<GatewayHome>(payload).rows.single()
        val item = row.items.single()

        assertEquals("movie-schedule", row.kind)
        assertTrue(item.isMovieSchedule)
        assertTrue(item.isSchedule)
        assertEquals("Digital release Saturday", item.membyAirLabel)
        assertEquals("incinemas", item.membyLifecycle)
        assertEquals("IN CINEMAS", item.membyLifecycleText)
        assertEquals(false, item.membyPlayable)
    }

    @Test
    fun `a home payload without rows still decodes`() {
        // The gateway omits recommendation rows while they are still building, and an
        // older gateway would not send `rows` at all.
        val home = json.decodeFromString<GatewayHome>(
            """{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
        )
        assertTrue(home.rows.isEmpty())
    }

    @Test
    fun `a partial home payload still decodes`() {
        val home = json.decodeFromString<GatewayHome>(
            """{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
        )
        assertTrue(home.partial)
    }

    @Test
    fun `decodes persisted recent searches`() {
        val history = json.decodeFromString<GatewaySearchHistory>(
            """{"queries":["severance","slow horses","arrival"]}""",
        )

        assertEquals(listOf("severance", "slow horses", "arrival"), history.queries)
    }

    @Test
    fun `decodes a playback response`() {
        val playback = json.decodeFromString<GatewayPlayback>(
            """{"itemId":"9","title":"Severance – Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
        )
        assertEquals("9", playback.itemId)
        // Absent on an older gateway: subtitles on, nothing chosen, so the television falls
        // back to its own pick rather than being told to turn them off.
        assertTrue(playback.subtitlesEnabled)
        assertEquals("", playback.selectedSubtitleId)
        assertEquals(42_000L, playback.resumePositionMs)
        assertEquals("S01E01", playback.episodeCode)
        assertEquals(3_420_000L, playback.runtimeMs)
        assertEquals(false, playback.prerollEnabled)
        assertEquals(4_000L, playback.prerollDurationMs)
        assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
        // Absent on a gateway that predates seek previews, and the default must stay false:
        // a missing field must never conjure a request the backend would answer with 404.
        assertEquals(false, playback.trickplayAvailable)
        // And the same for the skip button, for the same reason.
        assertEquals(false, playback.skipIntroAvailable)
    }

    @Test
    fun `decodes an intro segment`() {
        val intro = json.decodeFromString<GatewayIntro>(
            """{"available":true,"startMs":463000,"endMs":583000}""",
        )

        assertTrue(intro.available)
        assertEquals(463_000L, intro.startMs)
        assertEquals(583_000L, intro.endMs)
    }

    @Test
    fun `a title with no intro markers decodes as unavailable`() {
        // An episode Emby has not analysed, a film, and a feature the operator has turned
        // off are all the same empty object — and all three mean no button.
        val intro = json.decodeFromString<GatewayIntro>("{}")

        assertEquals(false, intro.available)
        assertEquals(0L, intro.startMs)
        assertEquals(0L, intro.endMs)
    }

    @Test
    fun `decodes a seek preview layout`() {
        val trickplay = json.decodeFromString<GatewayTrickplay>(
            """{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}""",
        )

        assertTrue(trickplay.available)
        assertEquals(10_000L, trickplay.intervalMs)
        assertEquals(817, trickplay.count)
        assertEquals(320, trickplay.width)
        assertEquals(172, trickplay.height)
    }

    @Test
    fun `a title with no seek previews decodes as unavailable`() {
        // The gateway answers a title with no thumbnails, a feature the operator has turned
        // off and an Emby that would not say with the same empty object. All three mean the
        // same thing to a television, and none of them is an error it should render.
        val trickplay = json.decodeFromString<GatewayTrickplay>("""{"available":false}""")

        assertEquals(false, trickplay.available)
        assertEquals(0, trickplay.count)
    }

    @Test
    fun `decodes a one-time auto-follow acknowledgement`() {
        val response = json.decodeFromString<GatewayPlaybackReportResponse>(
            """{"autoFollowedShowTitle":"Severance"}""",
        )

        assertEquals("Severance", response.autoFollowedShowTitle)
    }

    @Test
    fun `decodes the next-episode response the player counts down to`() {
        val next = json.decodeFromString<GatewayNextEpisode>(
            """
            {
              "item": {
                "Id":"11","Name":"The Bicameral Mind","Type":"Episode",
                "SeriesName":"Westworld","SeriesId":"7",
                "IndexNumber":5,"ParentIndexNumber":2,
                "ImageTags":{"Primary":"abc"}
              },
              "title": "Westworld – The Bicameral Mind",
              "url": "https://emby.example/Videos/11/stream?static=true",
              "resumePositionMs": 0,
              "subtitleDownloadAvailable": true,
              "trickplayAvailable": true,
              "skipIntroAvailable": true,
              "endCreditsAvailable": true
            }
            """.trimIndent(),
        )

        assertEquals("11", next.item.id)
        assertEquals("Westworld", next.item.seriesName)
        // The banner's subtitle is built from these, so they have to survive the wire.
        assertEquals("S2 · E5", next.item.episodeCode)
        assertEquals(0L, next.resumePositionMs)
        assertTrue(next.url.startsWith("https://emby.example/Videos/11/stream"))
        assertTrue(next.subtitleDownloadAvailable)
        assertTrue(next.trickplayAvailable)
        assertTrue(next.skipIntroAvailable)
        assertTrue(next.endCreditsAvailable)
    }

    @Test
    fun `an episode without a season still gets a usable code`() {
        val next = json.decodeFromString<GatewayNextEpisode>(
            """{"item":{"Id":"12","Name":"Special","Type":"Episode","IndexNumber":3},"url":"https://e/x"}""",
        )
        assertEquals("E3", next.item.episodeCode)
    }

    @Test
    fun `an item with no episode numbering has no code`() {
        val next = json.decodeFromString<GatewayNextEpisode>(
            """{"item":{"Id":"13","Name":"Arrival","Type":"Movie"},"url":"https://e/x"}""",
        )
        assertNull(next.item.episodeCode)
    }

}
