package com.ponzischeme89.memby.data

/**
 * The seek preview thumbnails for one title, and — on the direct path — where each one's
 * bytes live inside Emby's file.
 *
 * Emby stores them as a BIF: a header, an index of (timestamp, offset) pairs, then one
 * JPEG per entry laid end to end, at one frame every ten seconds. The index sitting at the
 * *front* of the file is what makes previews affordable on a television. A two-hour film's
 * BIF is five megabytes and nobody can download that to show one thumbnail, but reading
 * the first few kilobytes gives every frame's byte range, so a preview costs one ranged
 * request of about seven kilobytes.
 *
 * The parsing exists twice on purpose — here and in the gateway's `internal/trickplay` —
 * and is pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`). With no
 * gateway there is nobody to ask, and a preview must not depend on whether the container
 * is up. In gateway mode the television has no Emby credential to range-read a file with,
 * so the server does the reading and serves a frame at a time; [bif] is null there.
 */
data class Trickplay(
    val itemId: String,
    val intervalMs: Long,
    val count: Int,
    val width: Int = 0,
    val height: Int = 0,
    internal val bif: BifIndex? = null,
    internal val bifUrl: String? = null,
) {
    /**
     * Which thumbnail covers a moment in the title.
     *
     * It clamps rather than refusing. This is read while somebody is still moving a seek
     * target about, and a position a second past the last frame should show the last
     * frame — a preview that blanks at the end of a film reads as broken.
     */
    fun frameAt(positionMs: Long): Int {
        if (count <= 0 || intervalMs <= 0L) return 0
        if (positionMs <= 0L) return 0
        val frame = positionMs / intervalMs
        return if (frame >= count) count - 1 else frame.toInt()
    }

    /**
     * How wide to draw the preview for a given height. Falls back to 16:9 until the frames'
     * real shape is known, so the plate is laid out at about the right size on the first
     * press rather than growing a thumbnail-shaped hole when one arrives.
     */
    fun widthFor(heightPx: Int): Int =
        if (width > 0 && height > 0) heightPx * width / height else heightPx * 16 / 9
}

/** Where each frame's bytes are, read off the front of a BIF. */
data class BifIndex(
    val count: Int,
    val intervalMs: Long,
    /** [count] + 1 values, the last being the end of the final frame. */
    val offsets: List<Long>,
) {
    /** The half-open byte range of one thumbnail, ready for a Range header. */
    fun frame(index: Int): LongRange? {
        if (index < 0 || index >= count || offsets.size <= count) return null
        val start = offsets[index]
        val end = offsets[index + 1]
        return if (end > start) start until end else null
    }
}

/**
 * What reading the front of a BIF came to.
 *
 * [NeedMore] is the case that earns this its own type: a caller reads a fixed window off
 * the front of the file, and a long title legitimately has an index that runs past it. That
 * must be answerable — read this much and try again — rather than looking like a bad file.
 */
sealed interface BifParse {
    data class Parsed(val index: BifIndex) : BifParse
    data class NeedMore(val bytes: Int) : BifParse
    data object NotBif : BifParse
}

/** The fixed preamble: magic, version, frame count, timestamp multiplier. */
const val BIF_HEADER_SIZE = 64
private const val BIF_ENTRY_SIZE = 8
private const val BIF_DEFAULT_MULTIPLIER = 1_000L

/**
 * The file's first eight bytes. The leading 0x89 and the CR/LF pair are the trick PNG uses:
 * a file mangled by a text-mode transfer stops matching.
 */
private val BIF_MAGIC = byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a)

/** How many bytes of the file hold the header and the whole index. */
fun bifIndexLength(count: Int): Int = BIF_HEADER_SIZE + (count + 1) * BIF_ENTRY_SIZE

/**
 * Reads the header and index out of the front of a BIF. [bytes] may be longer than the
 * index; the rest is ignored.
 *
 * A count of zero is not a failure. It is what Emby serves for a title whose thumbnails
 * have not been generated — a perfectly well-formed 72-byte file — and it must read as
 * "this title has no previews" or every such title looks like a broken one.
 */
fun parseBifIndex(bytes: ByteArray): BifParse {
    if (bytes.size < BIF_HEADER_SIZE) return BifParse.NeedMore(BIF_HEADER_SIZE)
    for (i in BIF_MAGIC.indices) {
        if (bytes[i] != BIF_MAGIC[i]) return BifParse.NotBif
    }

    val count = readLittleEndian(bytes, 12).toInt()
    if (count < 0) return BifParse.NotBif
    val multiplier = readLittleEndian(bytes, 16).takeIf { it > 0L } ?: BIF_DEFAULT_MULTIPLIER
    if (count == 0) {
        return BifParse.Parsed(BifIndex(count = 0, intervalMs = multiplier, offsets = emptyList()))
    }

    val length = bifIndexLength(count)
    if (bytes.size < length) return BifParse.NeedMore(length)

    val offsets = ArrayList<Long>(count + 1)
    var firstTimestamp = 0L
    var secondTimestamp = 0L
    for (entry in 0..count) {
        val at = BIF_HEADER_SIZE + entry * BIF_ENTRY_SIZE
        val timestamp = readLittleEndian(bytes, at)
        offsets.add(readLittleEndian(bytes, at + 4))
        when (entry) {
            0 -> firstTimestamp = timestamp
            1 -> secondTimestamp = timestamp
        }
    }

    // A frame that starts inside the index, or before the one ahead of it, means the file
    // is not laid out the way the format says. Reading a byte range from it would decode
    // whatever happened to be there.
    if (offsets[0] < length.toLong()) return BifParse.NotBif
    for (entry in 1..count) {
        if (offsets[entry] < offsets[entry - 1]) return BifParse.NotBif
    }

    // Emby writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the interval
    // is ten seconds, and taking the multiplier for it would be right only by accident.
    val interval = if (count >= 2 && secondTimestamp > firstTimestamp) {
        (secondTimestamp - firstTimestamp) * multiplier
    } else {
        multiplier
    }
    return BifParse.Parsed(
        BifIndex(
            count = count,
            intervalMs = if (interval > 0L) interval else BIF_DEFAULT_MULTIPLIER,
            offsets = offsets,
        ),
    )
}

private fun readLittleEndian(bytes: ByteArray, at: Int): Long =
    (bytes[at].toLong() and 0xFF) or
        ((bytes[at + 1].toLong() and 0xFF) shl 8) or
        ((bytes[at + 2].toLong() and 0xFF) shl 16) or
        ((bytes[at + 3].toLong() and 0xFF) shl 24)
