package com.ponzischeme89.memby.data

import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * When Memby is willing to guess a finish date, and when it holds its tongue.
 *
 * The guards matter more than the arithmetic here: an estimate is a promise made to
 * somebody about their own evening, so most of these tests are about the cases that must
 * produce nothing at all rather than about the ones that produce a date.
 */
class SeriesPaceTest {

    // A fixed "now" so every case reads as a calendar rather than as offsets: midday on
    // Wednesday 5 August 2026, UTC, with the television in UTC too.
    private val now = playedAtMillis("2026-08-05T12:00:00Z")!!
    private val utc = 0

    // ---------------------------------------------------------------------------
    // Ordinary viewing
    // ---------------------------------------------------------------------------

    @Test
    fun `an episode a night projects a night per remaining episode`() {
        val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc)
        assertNotNull(estimate)
        assertEquals(5, estimate!!.remainingEpisodes)
        assertEquals(1.0, estimate.episodesPerDay, 0.001)
        // Five left at one a day: one tonight and the last four days from now.
        assertEquals(4, estimate.daysAway)
        assertEquals("Estimated finish: 9 August", seriesPaceLabel(estimate))
    }

    @Test
    fun `a binge across days is measured as a binge`() {
        // Three on Monday, three on Tuesday: three a day, not six.
        val episodes = library(
            watched = listOf(
                "2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T21:00:00Z",
                "2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
            ),
            remaining = 6,
        )
        val estimate = estimateSeriesPace(episodes, now, utc)!!
        assertEquals(3.0, estimate.episodesPerDay, 0.001)
        assertEquals(1, estimate.daysAway)
        assertEquals("You'll likely finish tomorrow", seriesPaceLabel(estimate))
    }

    @Test
    fun `a fast pace with little left finishes today`() {
        val episodes = library(
            watched = listOf(
                "2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z",
                "2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z",
            ),
            remaining = 2,
        )
        assertEquals("You'll likely finish today", seriesPaceLabel(estimateSeriesPace(episodes, now, utc)))
    }

    @Test
    fun `a slow pace over a long tail is rounded to weeks rather than dated`() {
        // One a week, forty left: naming a Tuesday nine months out would be a fiction.
        val episodes = library(
            watched = listOf("2026-07-15T20:00:00Z", "2026-07-22T20:00:00Z", "2026-07-29T20:00:00Z"),
            remaining = 8,
        )
        val label = seriesPaceLabel(estimateSeriesPace(episodes, now, utc))
        assertEquals("At your current pace: about 6 weeks remaining", label)
    }

    // ---------------------------------------------------------------------------
    // Not enough to go on
    // ---------------------------------------------------------------------------

    @Test
    fun `one episode is never a pace`() {
        val episodes = library(watched = listOf("2026-08-04T20:00:00Z"), remaining = 6)
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `the opening of a binge does not project one`() {
        // Three episodes, one evening. There is no daily rate in a single sitting, and
        // reading one off it would promise a finish this week.
        val episodes = library(
            watched = listOf(
                "2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
            ),
            remaining = 20,
        )
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `two episodes on separate days are enough when they are close together`() {
        val episodes = library(
            watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z"),
            remaining = 4,
        )
        val estimate = estimateSeriesPace(episodes, now, utc)
        assertNotNull(estimate)
        assertEquals(1.0, estimate!!.episodesPerDay, 0.001)
    }

    @Test
    fun `two episodes a fortnight apart are not a rhythm`() {
        val episodes = library(
            watched = listOf("2026-07-24T20:00:00Z", "2026-08-04T20:00:00Z"),
            remaining = 4,
        )
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `nothing watched at all says nothing`() {
        assertNull(estimateSeriesPace(library(watched = emptyList(), remaining = 8), now, utc))
    }

    // ---------------------------------------------------------------------------
    // Breaks and returns
    // ---------------------------------------------------------------------------

    @Test
    fun `a long silence is not averaged into the current pace`() {
        // A season watched last summer, then a return this week. Including the gap would
        // put the finish years out; only the return counts.
        val episodes = library(
            watched = listOf(
                "2025-08-01T20:00:00Z", "2025-08-02T20:00:00Z", "2025-08-03T20:00:00Z",
                "2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z",
            ),
            remaining = 4,
        )
        val estimate = estimateSeriesPace(episodes, now, utc)!!
        assertEquals(1.0, estimate.episodesPerDay, 0.001)
        assertEquals(3, estimate.daysAway)
    }

    @Test
    fun `a viewer who has not come back has no current pace`() {
        val episodes = library(
            watched = listOf(
                "2026-05-01T20:00:00Z", "2026-05-02T20:00:00Z", "2026-05-03T20:00:00Z",
            ),
            remaining = 6,
        )
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `speeding up moves the date in`() {
        val steady = library(
            watched = listOf("2026-08-01T20:00:00Z", "2026-08-02T20:00:00Z", "2026-08-03T20:00:00Z"),
            remaining = 6,
        )
        val quickened = library(
            watched = listOf(
                "2026-08-03T18:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T22:00:00Z",
                "2026-08-04T18:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T22:00:00Z",
            ),
            remaining = 6,
        )
        val before = estimateSeriesPace(steady, now, utc)!!
        val after = estimateSeriesPace(quickened, now, utc)!!
        assertTrue(after.episodesPerDay > before.episodesPerDay)
        assertTrue(after.daysAway < before.daysAway)
    }

    // ---------------------------------------------------------------------------
    // Nothing worth saying
    // ---------------------------------------------------------------------------

    @Test
    fun `a finished series has no estimate`() {
        assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 0), now, utc))
    }

    @Test
    fun `one episode left needs no date`() {
        assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 1), now, utc))
    }

    @Test
    fun `a horizon beyond a year is not offered`() {
        // An episode every couple of weeks against a very long show. The arithmetic is
        // sound and the answer — some day in 2031 — is not one to put on a television.
        val episodes = library(
            watched = listOf("2026-07-08T20:00:00Z", "2026-07-21T20:00:00Z", "2026-08-03T20:00:00Z"),
            remaining = 200,
        )
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `a label is null when the estimate is`() {
        assertNull(seriesPaceLabel(null))
    }

    // ---------------------------------------------------------------------------
    // Ongoing shows
    // ---------------------------------------------------------------------------

    @Test
    fun `a show still in production is caught up with, not finished`() {
        val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc, ongoing = true)!!
        assertTrue(estimate.catchUp)
        assertEquals("You'll catch up around 9 August", seriesPaceLabel(estimate))
    }

    @Test
    fun `catching up tomorrow is worded as catching up`() {
        val episodes = library(
            watched = listOf(
                "2026-08-03T19:00:00Z", "2026-08-03T21:00:00Z",
                "2026-08-04T19:00:00Z", "2026-08-04T21:00:00Z",
            ),
            remaining = 4,
        )
        assertEquals(
            "You'll likely catch up tomorrow",
            seriesPaceLabel(estimateSeriesPace(episodes, now, utc, ongoing = true)),
        )
    }

    // ---------------------------------------------------------------------------
    // Awkward data
    // ---------------------------------------------------------------------------

    @Test
    fun `specials count towards neither the pace nor what is left`() {
        val episodes = nightly(watched = 4, remaining = 5) +
            listOf(
                episode(season = 0, number = 1, playedAt = "2026-08-04T23:00:00Z"),
                episode(season = 0, number = 2, playedAt = null),
                episode(season = 0, number = 3, playedAt = null),
            )
        val estimate = estimateSeriesPace(episodes, now, utc)!!
        assertEquals(5, estimate.remainingEpisodes)
        assertEquals(1.0, estimate.episodesPerDay, 0.001)
    }

    @Test
    fun `a library of nothing but specials is treated as the show`() {
        val episodes = listOf(
            episode(season = 0, number = 1, playedAt = "2026-08-03T20:00:00Z"),
            episode(season = 0, number = 2, playedAt = "2026-08-04T20:00:00Z"),
            episode(season = 0, number = 3, playedAt = null),
            episode(season = 0, number = 4, playedAt = null),
        )
        assertNotNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `a part-watched episode is remaining and is not a completion`() {
        val episodes = nightly(watched = 3, remaining = 3).map { item ->
            if (item.indexNumber == 4) {
                item.copy(userData = UserItemData(played = false, playbackPositionTicks = 5_000_000_000L))
            } else {
                item
            }
        }
        val estimate = estimateSeriesPace(episodes, now, utc)!!
        // Three unwatched, one of them the episode the viewer is part-way through, and a
        // pace measured only from the three that were actually completed.
        assertEquals(3, estimate.remainingEpisodes)
        assertEquals(1.0, estimate.episodesPerDay, 0.001)
    }

    @Test
    fun `out of order and unstamped history is tolerated`() {
        // Emby's "never played" sentinel on one watched episode, and the list shuffled.
        val episodes = listOf(
            episode(1, 3, "2026-08-04T20:00:00Z"),
            episode(1, 1, "2026-08-02T20:00:00Z"),
            episode(1, 5, null),
            episode(1, 2, "0001-01-01T00:00:00Z", played = true),
            episode(1, 4, "2026-08-03T20:00:00Z"),
            episode(1, 6, null),
            episode(1, 7, null),
        )
        val estimate = estimateSeriesPace(episodes, now, utc)!!
        assertEquals(3, estimate.remainingEpisodes)
        // Three dated completions across three days.
        assertEquals(1.0, estimate.episodesPerDay, 0.001)
    }

    @Test
    fun `a timestamp from the future is a clock, not a habit`() {
        val episodes = library(
            watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z", "2027-01-01T20:00:00Z"),
            remaining = 4,
        )
        assertNull(estimateSeriesPace(episodes, now, utc))
    }

    @Test
    fun `an empty library says nothing`() {
        assertNull(estimateSeriesPace(emptyList(), now, utc))
    }

    // ---------------------------------------------------------------------------
    // The viewer's own midnight
    // ---------------------------------------------------------------------------

    @Test
    fun `days are counted in the television's zone, not Greenwich`() {
        // Two episodes at 13:00 UTC on consecutive days. In UTC+13 that is one o'clock the
        // following morning, still two separate local days — but the *finish* is counted
        // from the local today, which is already 6 August in Auckland.
        val nz = 13 * 60 * 60 * 1000
        val episodes = library(
            watched = listOf("2026-08-03T13:00:00Z", "2026-08-04T13:00:00Z"),
            remaining = 3,
        )
        val here = estimateSeriesPace(episodes, now, utc)!!
        val there = estimateSeriesPace(episodes, now, nz)!!
        assertEquals(here.finishEpochDay + 1, there.finishEpochDay)
    }

    @Test
    fun `a date is written day first with the month named`() {
        // 1 March 2028 — a leap year, which is where the calendar arithmetic goes wrong.
        assertEquals("1 March", formatPaceDate(playedAtMillis("2028-03-01T00:00:00Z")!! / 86_400_000L))
        assertEquals("29 February", formatPaceDate(playedAtMillis("2028-02-29T00:00:00Z")!! / 86_400_000L))
        assertEquals("31 December", formatPaceDate(playedAtMillis("2026-12-31T00:00:00Z")!! / 86_400_000L))
    }

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

    /** [watched] episodes on consecutive nights ending yesterday, then [remaining] unwatched. */
    private fun nightly(watched: Int, remaining: Int): List<BaseItem> {
        val stamps = (0 until watched).map { index ->
            val day = 4 - (watched - 1 - index)
            "2026-08-%02dT20:00:00Z".format(day)
        }
        return library(stamps, remaining)
    }

    private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
        watched.mapIndexed { index, at -> episode(1, index + 1, at) } +
            (0 until remaining).map { episode(1, watched.size + it + 1, null) }

    private fun episode(
        season: Int,
        number: Int,
        playedAt: String?,
        played: Boolean = playedAt != null,
    ) = BaseItem(
        id = "s${season}e$number",
        name = "Episode $number",
        type = "Episode",
        parentIndexNumber = season,
        indexNumber = number,
        userData = UserItemData(played = played, lastPlayedDate = playedAt),
    )
}
