package com.ponzischeme89.memby.data

import com.ponzischeme89.memby.data.model.BaseItem
import kotlin.math.ceil

/**
 * When a viewer is likely to finish the episodes of a series they already have.
 *
 * Everything here is derived from the episode list the detail page already holds — Emby's
 * `UserData.Played` and `UserData.LastPlayedDate`, one per episode — so there is no new
 * storage, nothing to invalidate, and no second implementation on the direct path. It
 * recalculates for free: finishing an episode, marking one watched, a history sync from
 * another Emby client and a newly imported episode all change that list and nothing else.
 * Because the state is Emby's per-user data, it is per viewer and per series by
 * construction, including for two people sharing one television.
 *
 * It is deliberately apart from the UI. [estimateSeriesPace] answers with numbers and
 * [seriesPaceLabel] turns them into a sentence, so a "finish this weekend" row or a
 * completion reminder can use the first without inheriting the second's wording.
 *
 * The honest answer is often *nothing*. Every guard below returns null rather than a
 * confident-looking date: a viewer told they will finish on a day they will not is worse
 * off than one told nothing at all.
 */
data class SeriesPaceEstimate(
    /** Unwatched episodes the household actually holds. Never fewer than two. */
    val remainingEpisodes: Int,
    /** The measured rate over the recent window, in episodes per local day. */
    val episodesPerDay: Double,
    /** Local days from today until the finish. 0 is today, 1 tomorrow. */
    val daysAway: Int,
    /** The finish, in local days since the epoch — the form the calendar wording needs. */
    val finishEpochDay: Long,
    /**
     * Whether the show is still being made. It changes the verb, because "finish" is a
     * claim about a series and only "catch up" is true of one that is still running.
     */
    val catchUp: Boolean,
)

// The most recent completions worth measuring. Old enough behaviour is not this viewer's
// current pace — somebody who took a year over season one and is now watching nightly is
// watching nightly.
private const val PACE_WINDOW_EPISODES = 10
private const val PACE_WINDOW_DAYS = 30L

/**
 * A gap this long ends the window. Somebody returning to a show after a fortnight away is
 * establishing a new pace, and averaging the silence in would predict a finish years out.
 */
private const val PACE_BREAK_DAYS = 14L

/** Nothing watched in this long and there is no current pace to project at all. */
private const val PACE_STALE_DAYS = 30L

private const val PACE_MIN_EPISODES = 3

/**
 * Two completions are enough only when they are on separate days *and* close enough
 * together to read as a rhythm rather than as two unrelated evenings.
 */
private const val PACE_PAIR_MAX_SPAN_DAYS = 7L

/** Beyond a year the arithmetic is still valid and the answer is still useless. */
private const val PACE_MAX_HORIZON_DAYS = 365L

/**
 * The estimate, or null when there is nothing trustworthy to say.
 *
 * [episodes] is the whole series as the library holds it — which is what makes the answer
 * "the episodes available to you", since Emby lists nothing it has not imported. [ongoing]
 * says the show is still in production; see [SeriesPaceEstimate.catchUp].
 */
fun estimateSeriesPace(
    episodes: List<BaseItem>,
    nowMs: Long,
    zoneOffsetMs: Int,
    ongoing: Boolean = false,
): SeriesPaceEstimate? {
    if (episodes.isEmpty()) return null

    // Specials are bonus material, not the thing being caught up on: counting them would
    // put a finish date beyond the last real episode for a viewer who is never going to
    // watch the making-of. The same rule `nextEpisodeToWatch` follows — unless specials
    // are all the library has, in which case they are the show.
    val numbered = episodes.filter { (it.parentIndexNumber ?: 0) > 0 }
    val considered = if (numbered.isNotEmpty()) numbered else episodes

    // A part-watched episode is not a completion: it counts as remaining and contributes
    // nothing to the pace, which is exactly what "has not crossed the threshold" means.
    val remaining = considered.count { it.userData?.played != true }
    // One episode left needs no date, and none left needs no estimate.
    if (remaining <= 1) return null

    val completions = considered
        .filter { it.userData?.played == true }
        .mapNotNull { playedAtMillis(it.userData?.lastPlayedDate) }
        .sorted()
    if (completions.isEmpty()) return null

    val mostRecent = completions.last()
    // A stamp in the future is a clock that has not been set, not a viewing habit.
    if (mostRecent - nowMs > DAY_MS) return null
    if (nowMs - mostRecent > PACE_STALE_DAYS * DAY_MS) return null

    val recent = completions
        .takeLast(PACE_WINDOW_EPISODES)
        .filter { mostRecent - it <= PACE_WINDOW_DAYS * DAY_MS }
    val window = sinceLastBreak(recent)

    val days = window.map { localEpochDay(it, zoneOffsetMs) }
    // Everything known happening on one day is one sitting, and a sitting has no daily
    // rate: three episodes on a Sunday would read as three a day and promise a finish
    // this week. This is the guard that stops the opening of a binge projecting one.
    val distinctDays = days.distinct().size
    if (distinctDays < 2) return null

    val spanDays = days.last() - days.first() + 1
    if (window.size < PACE_MIN_EPISODES && spanDays > PACE_PAIR_MAX_SPAN_DAYS) return null

    // Episodes per day over the whole span, inclusive of both ends — the count of viewing
    // days is what a rate is measured against, so a binge of three on Saturday and three
    // on Sunday is three a day rather than six.
    val perDay = window.size.toDouble() / spanDays.toDouble()
    if (perDay <= 0.0) return null

    val daysNeeded = ceil(remaining / perDay).toLong()
    if (daysNeeded > PACE_MAX_HORIZON_DAYS) return null

    // The first of those days is today: at one a day with three left, the third is two
    // days from now, not three.
    val daysAway = (daysNeeded - 1L).coerceAtLeast(0L)
    return SeriesPaceEstimate(
        remainingEpisodes = remaining,
        episodesPerDay = perDay,
        daysAway = daysAway.toInt(),
        finishEpochDay = localEpochDay(nowMs, zoneOffsetMs) + daysAway,
        catchUp = ongoing,
    )
}

/**
 * The trailing run of [completions] with no [PACE_BREAK_DAYS] gap in it.
 *
 * Walking back from the most recent rather than forward from the oldest is the point: what
 * is wanted is the pace the viewer is on *now*, so the window ends at the present and stops
 * at whatever break precedes it.
 */
private fun sinceLastBreak(completions: List<Long>): List<Long> {
    if (completions.size <= 1) return completions
    var start = completions.size - 1
    while (start > 0 && completions[start] - completions[start - 1] < PACE_BREAK_DAYS * DAY_MS) {
        start--
    }
    return completions.subList(start, completions.size)
}

/**
 * The sentence the detail page shows, or null for an absent estimate.
 *
 * Near dates are named, because "18 August" is something a viewer can hold a weekend up
 * against. Far ones are rounded to weeks or months instead: a pace measured over a
 * fortnight cannot honestly pick a day four months out, and printing one anyway is false
 * precision dressed as helpfulness.
 */
fun seriesPaceLabel(estimate: SeriesPaceEstimate?): String? {
    if (estimate == null) return null
    val verb = if (estimate.catchUp) "catch up" else "finish"
    return when {
        estimate.daysAway == 0 -> "You'll likely $verb today"
        estimate.daysAway == 1 -> "You'll likely $verb tomorrow"
        estimate.daysAway <= 30 -> {
            val date = formatPaceDate(estimate.finishEpochDay)
            if (estimate.catchUp) "You'll catch up around $date" else "Estimated finish: $date"
        }
        else -> {
            val tail = if (estimate.catchUp) "to catch up" else "remaining"
            "At your current pace: about ${roughDuration(estimate.daysAway)} $tail"
        }
    }
}

/** "6 weeks", "3 months" — the coarse end of the wording, never a day count. */
private fun roughDuration(days: Int): String {
    val months = (days + 15) / 30
    if (days >= 70 && months >= 2) return "$months months"
    val weeks = (days + 3) / 7
    return if (weeks == 1) "1 week" else "$weeks weeks"
}

/**
 * "18 August". Day first and no ordinal suffix, which is how a date is written here, and
 * with the month named from this table rather than from the platform's locale — a set
 * configured in US English must not start printing "August 18" into New Zealand copy.
 *
 * No year: this is only reached inside a month, where the next 18 August is unambiguous.
 */
internal fun formatPaceDate(epochDay: Long): String {
    val (_, month, day) = civilFromEpochDay(epochDay)
    return "$day ${MONTH_NAMES[month - 1]}"
}

private val MONTH_NAMES = listOf(
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
)

/**
 * Year, month and day from a count of days since 1970-01-01, by the usual civil-calendar
 * algorithm. Hand-rolled because `java.time` needs API 26 and this app ships to 23, and
 * because a `Calendar` would drag the device's default zone into a pure function that has
 * already been handed the offset it needs.
 */
private fun civilFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
    // Shift the epoch to 0000-03-01 so leap days land at the end of the cycle.
    val shifted = epochDay + 719_468L
    val era = floorDiv(shifted, 146_097L)
    val dayOfEra = shifted - era * 146_097L
    val yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36_524L - dayOfEra / 146_096L) / 365L
    val year = yearOfEra + era * 400L
    val dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L)
    val monthPrime = (5L * dayOfYear + 2L) / 153L
    val day = (dayOfYear - (153L * monthPrime + 2L) / 5L + 1L).toInt()
    val month = (if (monthPrime < 10L) monthPrime + 3L else monthPrime - 9L).toInt()
    return Triple((if (month <= 2) year + 1L else year).toInt(), month, day)
}

/**
 * Emby's UTC timestamp as epoch milliseconds, or null for anything unrecognisable.
 *
 * [normalizePlayedAt] already rejects the malformed and the "never played" sentinel and
 * hands back a fixed-width `yyyy-MM-ddTHH:mm:ss`; all that is left is to read the digits.
 * Doing it by hand rather than with `SimpleDateFormat` keeps the function pure and free of
 * that class's thread-safety problem, which the analytics buffer has to work around.
 */
internal fun playedAtMillis(value: String?): Long? {
    val stamp = normalizePlayedAt(value) ?: return null
    val year = stamp.substring(0, 4).toIntOrNull() ?: return null
    val month = stamp.substring(5, 7).toIntOrNull() ?: return null
    val day = stamp.substring(8, 10).toIntOrNull() ?: return null
    val hour = stamp.substring(11, 13).toIntOrNull() ?: return null
    val minute = stamp.substring(14, 16).toIntOrNull() ?: return null
    val second = stamp.substring(17, 19).toIntOrNull() ?: return null
    if (month !in 1..12 || day !in 1..31) return null
    return epochDayFromCivil(year, month, day) * DAY_MS +
        (hour * 3_600L + minute * 60L + second) * 1_000L
}

/** The inverse of [civilFromEpochDay], by the same algorithm. */
private fun epochDayFromCivil(year: Int, month: Int, day: Int): Long {
    val y = (if (month <= 2) year - 1 else year).toLong()
    val era = floorDiv(y, 400L)
    val yearOfEra = y - era * 400L
    val monthPrime = if (month > 2) month - 3 else month + 9
    val dayOfYear = (153L * monthPrime + 2L) / 5L + day - 1L
    val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
    return era * 146_097L + dayOfEra - 719_468L
}
