package com.ponzischeme89.memby.data

import com.ponzischeme89.memby.data.model.EmbyChapter

/**
 * Where a title's closing credits begin, and whether they are worth doing anything about.
 *
 * Two sources, in order of trust, because Emby gives one and the media gives the other:
 *
 * - `CreditsStart`, a marker Emby's own detector writes. It is in Emby's `MarkerType`
 *   enumeration and is what this feature was originally built on — but **Emby 4.10 does not
 *   write it**. A survey of a 20,000-item library found `Chapter`, `IntroStart` and `IntroEnd`
 *   and nothing else, so the enum value existing is not the detector populating it. It is
 *   still read first, so the day a version does write it this needs no change.
 * - A chapter *named* like credits. Plenty of media carries "Credits" or "End Credits" as
 *   ordinary chapter metadata, and in that same library 216 items had one, clustered at 90–98%
 *   of runtime and consistent within a show. That is where the coverage comes from today.
 *
 * Both come out of the same `Fields=Chapters` response [introSegmentFrom] already reads, so
 * this costs no request the intro was not already making.
 *
 * There is deliberately no end marker in either source. Credits run to the end of the file by
 * definition, so nothing writes one and this must not invent one.
 */

private const val MARKER_CREDITS_START = "CreditsStart"

/**
 * How far into a file a credit roll has to begin.
 *
 * **This is the load-bearing guard, and it exists because of one observed case.** Chapter names
 * are not a vocabulary anybody agreed on, and real media carries "Opening Credits" — Belfast at
 * 1% of runtime, Game of Thrones at 0%. A name match without a position test therefore starts
 * the credits pane in the *first minute* of a film and runs its opening at double speed, which
 * is the worst thing this feature could possibly do.
 *
 * Three quarters is deliberately far below the evidence rather than near it: every genuine
 * credit roll in that survey began at 90% or later, so this leaves fifteen points of headroom
 * for a long roll while rejecting the whole first half of a file outright.
 */
private const val CREDITS_MINIMUM_POSITION_FRACTION = 0.75

/** Words that mark an *opening* sequence, never a closing one. */
private val OPENING_CHAPTER_WORDS =
    listOf("opening", "main title", "title sequence", "intro")

/** Words that name a credit roll. */
private val CREDITS_CHAPTER_WORDS = listOf("credit", "end titles", "closing")

/**
 * The least amount of credits worth shrinking the picture for.
 *
 * A marker twenty seconds from the end is not a credit roll to sit beside something else;
 * it is the last card of one, and the transition would be most of what was left. This is
 * the guard the gateway deliberately does not apply — it knows where the marker is but not
 * how long the file runs, and the duration is exact here.
 */
const val CREDITS_MINIMUM_TAIL_MS = 45_000L

/**
 * Finds where the closing credits begin in an item's chapter list.
 *
 * The rule exists twice — the gateway's copy is `creditsFromChapters` in
 * `server/internal/api/credits.go` — and the two are pinned by deliberately parallel tests
 * (`CreditsTest`, `credits_test.go`). With no gateway there is nobody to ask, and the
 * picture must not start shrinking at a different moment depending on whether the container
 * is up.
 *
 * Like the intro rule, most of this is about refusing to answer, and null is a perfectly
 * good answer: the player never shrinks anything and the credits play out full size, which
 * is what every other client does anyway.
 *
 * [runtimeMs] may be zero when Emby reports no runtime. An explicit marker is still honoured
 * then — it is Emby asserting a position rather than this inferring one — but a *named* chapter
 * is refused outright, because the name alone cannot tell an opening credit sequence from a
 * closing one and [CREDITS_MINIMUM_POSITION_FRACTION] is the only thing that can.
 */
fun creditsStartFrom(chapters: List<EmbyChapter>, runtimeMs: Long): Long? {
    val floorMs =
        if (runtimeMs > 0L) (runtimeMs * CREDITS_MINIMUM_POSITION_FRACTION).toLong() else -1L

    // An explicit marker first. **The last one wins, where [introSegmentFrom] takes the
    // first.** That inversion is deliberate: two starts mean the markers are already
    // untrustworthy, so each rule picks whichever risks least, and the two features are damaged
    // in opposite directions. An intro skip that fires late throws somebody past the start of
    // the story, so the earlier marker is safer there; the credits pane firing early runs the
    // last scene of an episode past somebody at double speed, so the later marker is safer
    // here. Neither is a preference for a position in the list.
    var markedMs = -1L
    for (chapter in chapters) {
        if (chapter.markerType != MARKER_CREDITS_START) continue
        // A marker at or before zero says the whole file is credits, which is not something
        // Emby means and not something worth acting on.
        if (chapter.startPositionTicks <= 0L) continue
        markedMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
    }
    // A marker below the floor is a mis-detection whoever wrote it, so it falls through to the
    // names rather than being honoured — but with no runtime to measure against, an explicit
    // assertion gets the benefit of the doubt.
    if (markedMs > 0L && (floorMs < 0L || markedMs >= floorMs)) return markedMs

    if (floorMs < 0L) return null

    // Then the names. The *earliest* qualifying chapter wins here, the opposite of the marker
    // rule above, and that is not an inconsistency: several credits-named chapters are ordinary
    // rather than suspicious — "The Pitt" carries both "Credits" and "End Credits" — and they
    // describe one roll, which begins at the first of them.
    var namedMs = -1L
    for (chapter in chapters) {
        if (!isCreditsChapterName(chapter.name) || chapter.startPositionTicks <= 0L) continue
        val at = chapter.startPositionTicks / TICKS_PER_MILLISECOND
        if (at < floorMs) continue
        if (namedMs < 0L || at < namedMs) namedMs = at
    }
    return namedMs.takeIf { it > 0L }
}

/**
 * Recognises a chapter that names a credit roll.
 *
 * The exclusions are belt-and-braces beside [CREDITS_MINIMUM_POSITION_FRACTION], which is what
 * actually stops an opening sequence being read as a closing one — a position test catches
 * wordings nobody thought of, where a list of them only catches the ones on the list. They are
 * here so the trap is stated where the next reader will look for it.
 */
private fun isCreditsChapterName(name: String): Boolean {
    val lowered = name.trim().lowercase()
    if (lowered.isEmpty()) return false
    if (OPENING_CHAPTER_WORDS.any { it in lowered }) return false
    return CREDITS_CHAPTER_WORDS.any { it in lowered }
}

/**
 * Whether a credits marker is worth acting on in a file of this length.
 *
 * Separate from finding the marker, and deliberately on this end of the wire: the gateway
 * knows where `CreditsStart` sits but not how long the file runs, while the player has the
 * exact duration the decoder reported. Both guards refuse rather than guess — a marker past
 * the end of the file, or one so near it that the transition would outlast the credits, is
 * a detection to ignore rather than a picture to shrink.
 */
fun creditsWorthShowing(startMs: Long?, durationMs: Long): Boolean {
    if (startMs == null || startMs <= 0L || durationMs <= 0L) return false
    if (startMs >= durationMs) return false
    return durationMs - startMs >= CREDITS_MINIMUM_TAIL_MS
}

private const val TICKS_PER_MILLISECOND = 10_000L
