package com.ponzischeme89.memby.ui.detail

import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale

/**
 * Everything the detail pages need to *say*, as pure functions.
 *
 * None of this touches Compose, the repository or Android, so the whole vocabulary of the
 * two screens — which season opens first, what the primary button is called, which
 * technical facts are worth a line — is unit-testable without a device. The composables in
 * this package are deliberately thin over these.
 */

/** Emby stores durations and positions as 100-ns ticks. */
private const val TICKS_PER_MINUTE = 600_000_000L

/**
 * The video width at which a file is called 4K, by the spec row and the card badge alike.
 *
 * UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the
 * threshold sits just under it. The badge and the `(4K)` suffix used to disagree — 3800 and
 * 3400 — and a 3600-wide file was 4K on one screen and not the other.
 */
const val UHD_MIN_WIDTH = 3_800

/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
fun formatRuntime(minutes: Int): String {
    val hours = minutes / 60
    val remainder = minutes % 60
    return when {
        hours > 0 && remainder > 0 -> "${hours}h ${remainder}m"
        hours > 0 -> "${hours}h"
        else -> "${remainder}m"
    }
}

/** Position on the playhead, in the same wording as a runtime. */
fun formatPosition(ticks: Long): String =
    formatRuntime((ticks / TICKS_PER_MINUTE).toInt().coerceAtLeast(0))

val BaseItem.resumeTicks: Long get() = userData?.playbackPositionTicks ?: 0L

val BaseItem.isPlayed: Boolean get() = userData?.played == true

/** True when there is a meaningful position to resume from. */
val BaseItem.isResumable: Boolean get() = resumeTicks > 0L

/** 0f..1f through the item, or 0f when either end is unknown. */
fun playbackProgress(item: BaseItem): Float {
    val runtime = item.runTimeTicks ?: 0L
    if (runtime <= 0L) return 0f
    return (item.resumeTicks.toFloat() / runtime).coerceIn(0f, 1f)
}

/** "1h 12m left", or null when the remainder cannot be worked out. */
fun remainingLabel(item: BaseItem): String? {
    val runtime = item.runTimeTicks ?: return null
    val left = runtime - item.resumeTicks
    if (item.resumeTicks <= 0L || left <= TICKS_PER_MINUTE) return null
    return "${formatPosition(left)} left"
}

/**
 * The quiet line directly under the title: year, length, certificate. Deliberately short —
 * the score sits beside the title and the genres are a credit row, so this stays at three
 * items and never has to compete for the width.
 *
 * [seasonCount] replaces the runtime for a series; pass 0 for anything else.
 */
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
    item.productionYear?.let { add(it.toString()) }
    if (seasonCount > 0) {
        add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
    } else {
        item.runtimeMinutes?.let { add(formatRuntime(it)) }
    }
    item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
}

/**
 * "12 Mar 2024" from Emby's `PremiereDate`, or null when there is no usable date.
 *
 * Only the date half of the timestamp is read, deliberately. Emby writes a premiere as
 * midnight UTC, so resolving it against the set's own zone moves an episode a day earlier
 * everywhere west of Greenwich — and the day a programme aired is a fact about the
 * broadcast, not an instant to be converted. Parsing the literal `YYYY-MM-DD` prefix also
 * keeps this free of `java.time`, which this app's `minSdk` 23 cannot use.
 */
fun formatAirDate(raw: String?): String? {
    val date = raw?.trim()?.takeIf { it.length >= 10 } ?: return null
    if (date[4] != '-' || date[7] != '-') return null
    val year = date.substring(0, 4).toIntOrNull() ?: return null
    val month = date.substring(5, 7).toIntOrNull() ?: return null
    val day = date.substring(8, 10).toIntOrNull() ?: return null
    if (month !in 1..12 || day !in 1..31) return null
    return "$day ${MONTH_ABBREVIATIONS[month - 1]} $year"
}

private val MONTH_ABBREVIATIONS = listOf(
    "Jan", "Feb", "Mar", "Apr", "May", "Jun",
    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)

/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)

/**
 * The label/value credits block: who is in it, who made it, what it is. Kept separate from
 * [technicalSpecs] because these answer "do I want this?" and those answer "will it play?".
 */
fun creditRows(item: BaseItem): List<TechnicalSpec> = buildList {
    peopleNamed(item, "Actor", limit = 4)?.let { add(TechnicalSpec("Starring", it)) }
    val directors = peopleNamed(item, "Director", limit = 2)
    val creators = peopleNamed(item, "Writer", limit = 2)
    when {
        directors != null -> add(TechnicalSpec("Directed by", directors))
        creators != null -> add(TechnicalSpec("Written by", creators))
        else -> Unit
    }
    item.genres.take(3).joinToString(", ").takeIf(String::isNotBlank)
        ?.let { add(TechnicalSpec("Genre", it)) }
    item.studios.map { it.name }.filter(String::isNotBlank).take(2)
        .takeIf(List<String>::isNotEmpty)
        ?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
}

private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
    item.people
        .filter { it.type.equals(type, ignoreCase = true) }
        .map { it.name }
        .filter(String::isNotBlank)
        .distinct()
        .take(limit)
        .takeIf(List<String>::isNotEmpty)
        ?.joinToString(", ")

/**
 * The sections a detail page can show, in the order the strip lists them.
 *
 * Overview is always present and always first: it is the only one guaranteed to have
 * something in it, so it is what the page can safely open on.
 */
enum class DetailTab(val key: String, val label: String) {
    OVERVIEW("overview", "Overview"),
    MORE_LIKE_THIS("more-like-this", "More Like This"),
    EPISODES("episodes", "Episodes"),
    CAST_DETAILS("cast-details", "Cast & Details"),
}

/**
 * The strip for one item, decided by *what the item is* — never by what has arrived from
 * the network so far.
 *
 * This used to offer only the sections that already had content, on the reasoning that an
 * empty tab is worse than a missing one. On a real TV it was worse than either: a movie
 * opens with its list-row metadata, so Cast and Details appeared a second later and shoved
 * the strip sideways under the viewer's thumb, and a series that failed to load its
 * episodes lost a tab the household knows is there. A strip that depends only on
 * [isSeries] is decided on the first frame and never moves again; a section with nothing
 * in it yet says so in its own pane, where saying so costs nobody a keypress.
 */
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
    add(DetailTab.OVERVIEW)
    if (isSeries) add(DetailTab.EPISODES)
    add(DetailTab.MORE_LIKE_THIS)
    add(DetailTab.CAST_DETAILS)
}

/**
 * Resolves a remembered tab key against what is on offer. The strip no longer changes
 * under a page, so this only has to catch a key remembered from an item of the other kind
 * — a series' Episodes tab carried over to a movie.
 */
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
    available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW

/**
 * Resolution, codecs and studio — the things a viewer checks before settling in, kept out
 * of the headline because none of them decide what to watch.
 */
fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
    val video = item.mediaStreams.firstOrNull { it.type.equals("Video", ignoreCase = true) }
    val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", ignoreCase = true) }
    val subtitles = item.mediaStreams.count { it.type.equals("Subtitle", ignoreCase = true) }
    return buildList {
        video?.let { stream ->
            val width = stream.width
            val height = stream.height
            if (width != null && height != null) {
                add(TechnicalSpec("Video", "$width × $height${resolutionSuffix(width)}"))
            }
            listOfNotNull(
                stream.codec?.uppercase(Locale.US),
                dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
            ).takeIf(List<String>::isNotEmpty)?.let {
                add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
            }
        }
        audio?.let { stream ->
            listOfNotNull(
                stream.codec?.uppercase(Locale.US),
                stream.channels?.let(::channelLabel),
                stream.language?.takeIf(String::isNotBlank),
            ).takeIf(List<String>::isNotEmpty)?.let {
                add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
            }
        }
        if (subtitles > 0) {
            add(TechnicalSpec("Subtitles", "$subtitles ${if (subtitles == 1) "track" else "tracks"}"))
        }
        item.studios.map { it.name }.filter(String::isNotBlank).take(2)
            .takeIf(List<String>::isNotEmpty)
            ?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
    }
}

private fun resolutionSuffix(width: Int): String = when {
    width >= UHD_MIN_WIDTH -> "  (4K)"
    width >= 2_500 -> "  (1440p)"
    width >= 1_800 -> "  (1080p)"
    width >= 1_200 -> "  (720p)"
    else -> ""
}

/**
 * "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an
 * ordinary SDR file. Shared with the card badges so the two never name the same file
 * differently — HDR10+ used to collapse to a plain "HDR" badge.
 */
internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
    val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
    return when {
        "dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
        "hdr10+" in haystack -> "HDR10+"
        "hdr" in haystack -> "HDR"
        else -> null
    }
}

internal fun channelLabel(channels: Int): String = when (channels) {
    1 -> "Mono"
    2 -> "Stereo"
    6 -> "5.1"
    8 -> "7.1"
    else -> "${channels}ch"
}

/** The canonical first film in an Emby collection, when the collection has siblings. */
data class FranchiseStart(val name: String, val firstMovie: BaseItem)

fun franchiseStart(item: BaseItem, related: List<BaseItem>): FranchiseStart? {
    if (!item.isMovie) return null
    val collection = item.collectionName?.trim()?.takeIf(String::isNotEmpty) ?: return null
    val movies = (listOf(item) + related)
        .asSequence()
        .filter { candidate ->
            candidate.isMovie && candidate.collectionName?.trim().equals(collection, ignoreCase = true)
        }
        .distinctBy(BaseItem::id)
        .toList()
    if (movies.size < 2) return null
    val first = movies.minWithOrNull(
        compareBy<BaseItem>({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }),
    ) ?: return null
    return FranchiseStart(collection, first)
}

// ---------------------------------------------------------------------------
// Series structure
// ---------------------------------------------------------------------------

/** Running order: season, then episode, then title for anything unnumbered. */
val seriesEpisodeComparator = compareBy<BaseItem>(
    { it.parentIndexNumber ?: Int.MAX_VALUE },
    { it.indexNumber ?: Int.MAX_VALUE },
    { it.name },
)

/** Season numbers present in the library, specials (0) first. */
fun availableSeasons(episodes: List<BaseItem>): List<Int> =
    episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()

fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
    if (season == null) emptyList()
    else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)

/**
 * What the viewer should watch next, based on the furthest episode they have reached.
 * An old resume marker behind later viewing must not pull them backwards — this matters
 * when a schedule card opens a show whose early episode still has a stale position.
 * Specials are skipped while any numbered season exists because season 0 sorts first but
 * is almost never where someone is up to.
 */
fun nextEpisodeToWatch(episodes: List<BaseItem>): BaseItem? {
    if (episodes.isEmpty()) return null
    val ordered = episodes.sortedWith(seriesEpisodeComparator)
    val hasNumberedSeason = ordered.any { (it.parentIndexNumber ?: 0) > 0 }
    val candidates = if (hasNumberedSeason) {
        ordered.filter { (it.parentIndexNumber ?: 0) > 0 }
    } else {
        ordered
    }
    val furthestProgress = candidates.indexOfLast { it.isPlayed || it.isResumable }
    if (furthestProgress < 0) return candidates.firstOrNull { !it.isPlayed }

    val current = candidates[furthestProgress]
    return current.takeIf { it.isResumable && !it.isPlayed }
        ?: candidates.drop(furthestProgress + 1).firstOrNull { !it.isPlayed }
}

/** The season to open on: the one holding [nextEpisodeToWatch], else the earliest. */
fun defaultSeason(episodes: List<BaseItem>): Int? =
    nextEpisodeToWatch(episodes)?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()

/** How many episodes are still unwatched, for the series badge. */
fun unwatchedCount(episodes: List<BaseItem>): Int = episodes.count { !it.isPlayed }

/** "Season 3", or "Specials" for season 0. */
fun seasonLabel(season: Int): String = if (season == 0) "Specials" else "Season $season"

/** "S2 E4" — shorter than [BaseItem.episodeCode] and used where space is tight. */
fun episodeLabel(episode: BaseItem): String? {
    val number = episode.indexNumber ?: return null
    val season = episode.parentIndexNumber
    return if (season != null) "S$season E$number" else "E$number"
}

/**
 * "S04E04" — the zero-padded form, used on the primary button and nowhere else.
 *
 * A button is read at a glance from across a room, and the padded code is the shape a
 * viewer recognises without parsing it; [episodeLabel]'s spaced "S4 E4" is for running
 * prose like [episodeHeadline], where it sits beside a title. Null when Emby numbered
 * neither half, which is when the button has to fall back to a bare verb.
 */
fun paddedEpisodeCode(episode: BaseItem): String? {
    val number = episode.indexNumber ?: return null
    val season = episode.parentIndexNumber
        ?: return String.format(Locale.US, "E%02d", number)
    return String.format(Locale.US, "S%02dE%02d", season, number)
}

/** "S2 E4 · The Crossing", falling back to whichever half is known. */
fun episodeHeadline(episode: BaseItem): String =
    listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
        .joinToString(ValueSeparator)

// ---------------------------------------------------------------------------
// Episode pages
// ---------------------------------------------------------------------------

/**
 * "SEASON 3 · EPISODE 4" — the line under the series logo on an episode's own page.
 *
 * Spelled out rather than [episodeLabel]'s "S3 E4": this is the page's heading, where the
 * short form is a code the viewer has to decode, and there is width for the words. Null
 * when Emby numbered neither half, which is when the episode title has to carry the page
 * on its own.
 */
fun episodeEyebrow(episode: BaseItem): String? {
    val season = episode.parentIndexNumber?.let { if (it == 0) "SPECIALS" else "SEASON $it" }
    val number = episode.indexNumber?.let { "EPISODE $it" }
    return listOfNotNull(season, number).takeIf(List<String>::isNotEmpty)?.joinToString(ValueSeparator)
}

/** Where a season sits relative to the one the viewer is up to. */
enum class SeasonProgress { WATCHED, CURRENT, UPCOMING }

/**
 * One stop on the season scroller: what to call it, how far through it the viewer is, and
 * whether it is behind them, under them or ahead.
 */
data class SeasonMarker(
    val season: Int,
    val label: String,
    val progress: SeasonProgress,
    val watchedEpisodes: Int,
    val totalEpisodes: Int,
) {
    /** True when every episode the library holds for this season has been played. */
    val complete: Boolean get() = totalEpisodes > 0 && watchedEpisodes == totalEpisodes
}

/**
 * The scroller that replaces the tab strip on an episode page.
 *
 * A season counts as [SeasonProgress.WATCHED] when it is *behind* the episode being viewed
 * or when nothing in it is left unplayed — the two ways a household is done with a season.
 * Being behind is enough on its own deliberately: someone who skipped an episode of season
 * one and is now three seasons along has moved on, and greying it is the honest summary of
 * where they are. Everything after the current season stays
 * [SeasonProgress.UPCOMING] however much of it has been sampled, because a scroller that
 * dims what is still to come says the show is finished when it is not.
 */
fun seasonMarkers(episodes: List<BaseItem>, currentSeason: Int?): List<SeasonMarker> =
    availableSeasons(episodes).map { season ->
        val inSeason = episodes.filter { it.parentIndexNumber == season }
        val watched = inSeason.count(BaseItem::isPlayed)
        val progress = when {
            currentSeason != null && season == currentSeason -> SeasonProgress.CURRENT
            inSeason.isNotEmpty() && watched == inSeason.size -> SeasonProgress.WATCHED
            // Specials sort first but are not on the way to anywhere, so being "before"
            // the current season earns them nothing: a tick on a special nobody has
            // watched is the one claim this scroller must not make.
            currentSeason != null && season in 1 until currentSeason -> SeasonProgress.WATCHED
            else -> SeasonProgress.UPCOMING
        }
        SeasonMarker(
            season = season,
            label = seasonLabel(season),
            progress = progress,
            watchedEpisodes = watched,
            totalEpisodes = inSeason.size,
        )
    }

/** "5 of 8 watched", or null for a season the library has nothing for. */
fun seasonProgressLabel(marker: SeasonMarker): String? = when {
    marker.totalEpisodes <= 0 -> null
    marker.complete && marker.totalEpisodes == 1 -> "Watched"
    marker.complete -> "All ${marker.totalEpisodes} watched"
    marker.watchedEpisodes == 0 && marker.totalEpisodes == 1 -> "1 episode"
    marker.watchedEpisodes == 0 -> "${marker.totalEpisodes} episodes"
    else -> "${marker.watchedEpisodes} of ${marker.totalEpisodes} watched"
}

/**
 * The whole-series line beside the scroller: "Season 3 of 5  ·  12 episodes left".
 *
 * Null when there is nothing worth saying — a one-season show with everything watched has
 * no progress to report, and a bar that says so anyway is noise on every episode page.
 */
fun seriesProgressLabel(markers: List<SeasonMarker>, currentSeason: Int?): String? {
    if (markers.isEmpty()) return null
    // Counted over the numbered seasons only. "Season 3 of 5" on a four-season show with a
    // making-of special is wrong in the one place the viewer is most likely to read it.
    val numbered = markers.filter { it.season > 0 }
    val position = numbered.indexOfFirst { it.season == currentSeason }
    val place = if (position >= 0 && numbered.size > 1) {
        "${numbered[position].label} of ${numbered.size}"
    } else {
        null
    }
    val left = markers.sumOf { it.totalEpisodes - it.watchedEpisodes }
    val remaining = when {
        left <= 0 -> null
        left == 1 -> "1 episode left"
        else -> "$left episodes left"
    }
    return listOfNotNull(place, remaining).takeIf(List<String>::isNotEmpty)?.joinToString(FactSeparator)
}

/**
 * The episode that follows [episode] in the same library, or null at the end of the show.
 *
 * Position in running order decides it, never the length of the list — the same rule the
 * player's auto-advance follows, so the page's "Up next" and what actually plays cannot
 * disagree.
 */
fun episodeAfter(episodes: List<BaseItem>, episode: BaseItem): BaseItem? {
    val ordered = episodes.sortedWith(seriesEpisodeComparator)
    val index = ordered.indexOfFirst { it.id == episode.id }
    return if (index < 0) null else ordered.getOrNull(index + 1)
}

/**
 * The primary button. Series pass their next episode so the button can name it; a movie
 * passes itself; an episode names itself without being asked.
 *
 * An episode's button says "Play S04E04" because that page is reached from Continue
 * Watching, where the viewer chose a specific episode and the one thing the button has to
 * confirm is that it is the one they meant. A bare "Play" on a page headed by the series
 * logo could plausibly mean the show.
 */
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String {
    val episode = nextEpisode ?: item.takeIf(BaseItem::isEpisode)
    if (episode != null) {
        val verb = if (episode.isResumable) "Resume" else "Play"
        val code = paddedEpisodeCode(episode)
        return when {
            code != null -> "$verb $code"
            // An unnumbered episode of somebody else's show still has to say something.
            nextEpisode != null -> "$verb next episode"
            else -> verb
        }
    }
    return when {
        item.isResumable -> "Resume"
        item.isMovie && item.isPlayed -> "Rewatch"
        else -> "Play"
    }
}

/** The supporting line under the primary button, or null when there is nothing to add. */
fun primaryActionDetail(item: BaseItem, nextEpisode: BaseItem? = null): String? {
    val target = nextEpisode ?: item
    return when {
        target.isResumable -> "From ${formatPosition(target.resumeTicks)}" +
            (remainingLabel(target)?.let { " · $it" } ?: "")
        nextEpisode != null -> nextEpisode.name.takeIf(String::isNotBlank)
        else -> null
    }
}

/** Kicker above the title: what kind of thing this page is about. */
fun detailKicker(item: BaseItem): String = when {
    item.isMovie -> "MOVIE"
    item.isSeries -> "SERIES"
    item.isEpisode -> item.seriesName?.uppercase(Locale.US) ?: "EPISODE"
    else -> item.type.uppercase(Locale.US).ifBlank { "LIBRARY" }
}
