package com.ponzischeme89.memby.data

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * The direct path's copy of the gateway's BIF reader. It exists so the two agree — the
 * matching Go tests are in `server/internal/trickplay/bif_test.go`, and the cases here are
 * deliberately the same ones. With no gateway there is nobody to ask, and a viewer must
 * not get different seek previews depending on whether the container is up.
 */
class TrickplayTest {

    /**
     * Assembles a file in the shape Emby writes one, so the tests exercise the arithmetic
     * the real parser will meet rather than a convenient fiction.
     */
    private fun buildBif(
        count: Int,
        multiplier: Long = 10_000L,
        frameSizes: List<Int> = emptyList(),
    ): ByteArray {
        val length = bifIndexLength(count)
        val file = ByteArray(length)
        byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a).copyInto(file)
        writeLittleEndian(file, 12, count.toLong())
        writeLittleEndian(file, 16, multiplier)

        var offset = length
        val body = ArrayList<Byte>()
        for (entry in 0 until count) {
            val at = BIF_HEADER_SIZE + entry * 8
            writeLittleEndian(file, at, entry.toLong())
            writeLittleEndian(file, at + 4, offset.toLong())
            val size = frameSizes.getOrElse(entry) { 100 }
            offset += size
            repeat(size) { body.add(0) }
        }
        val terminator = BIF_HEADER_SIZE + count * 8
        writeLittleEndian(file, terminator, 0xFFFFFFFFL)
        writeLittleEndian(file, terminator + 4, offset.toLong())
        return file + body.toByteArray()
    }

    private fun writeLittleEndian(bytes: ByteArray, at: Int, value: Long) {
        for (i in 0..3) bytes[at + i] = ((value shr (8 * i)) and 0xFF).toByte()
    }

    private fun parsed(bytes: ByteArray): BifIndex =
        (parseBifIndex(bytes) as BifParse.Parsed).index

    @Test
    fun `reads the layout Emby writes`() {
        // Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
        // interval is ten seconds, and reading the multiplier as the interval would be
        // right only by accident. The multiplication is the part worth pinning.
        val index = parsed(buildBif(count = 3, frameSizes = listOf(500, 600, 700)))

        assertEquals(3, index.count)
        assertEquals(10_000L, index.intervalMs)
        val frame = index.frame(1)!!
        assertEquals((bifIndexLength(3) + 500).toLong(), frame.first)
        assertEquals(600L, frame.last - frame.first + 1)
    }

    @Test
    fun `a title with no thumbnails is an answer, not a failure`() {
        // This is what Emby serves for a title it has not generated previews for: a
        // well-formed 72-byte header with a count of zero. It must read as "this title has
        // none", never as a broken file, or every such title looks like a fault.
        val index = parsed(buildBif(count = 0))

        assertEquals(0, index.count)
        assertEquals(null, index.frame(0))
    }

    @Test
    fun `refuses what is not a BIF`() {
        // Emby's error pages come back on this route with a 200, so "not a BIF" is a real
        // answer the reader has to give rather than a theoretical one.
        assertEquals(
            BifParse.NotBif,
            parseBifIndex(("<html><body>Item not found</body></html>" + " ".repeat(64)).toByteArray()),
        )

        val mangled = buildBif(count = 2)
        mangled[3] = 'X'.code.toByte()
        assertEquals(BifParse.NotBif, parseBifIndex(mangled))
    }

    @Test
    fun `refuses frames that point back into the index`() {
        // An offset inside the index would have the player decode a slice of the index
        // itself as a JPEG. Refuse the file rather than draw nonsense over somebody's film.
        val file = buildBif(count = 2)
        writeLittleEndian(file, BIF_HEADER_SIZE + 4, 8L)

        assertEquals(BifParse.NotBif, parseBifIndex(file))
    }

    @Test
    fun `asks for more rather than failing on a short read`() {
        // The index is read as a fixed window off the front of the file, so a long title
        // legitimately arrives cut short. That must be answerable — read this much and try
        // again — rather than looking like a bad file.
        val file = buildBif(count = 40)

        val short = parseBifIndex(file.copyOfRange(0, BIF_HEADER_SIZE + 16))
        assertTrue(short is BifParse.NeedMore)
        assertEquals(bifIndexLength(40), (short as BifParse.NeedMore).bytes)

        val header = parseBifIndex(file.copyOfRange(0, 20))
        assertEquals(BifParse.NeedMore(BIF_HEADER_SIZE), header)
    }

    @Test
    fun `frameAt clamps rather than refusing`() {
        // The preview is drawn while somebody is still moving a seek target about, so a
        // position past the last frame must show the last frame. Nothing is worse here than
        // the thumbnail blanking at exactly the end of a film.
        val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)

        assertEquals(0, track.frameAt(-5_000L))
        assertEquals(0, track.frameAt(0L))
        assertEquals(0, track.frameAt(9_999L))
        assertEquals(1, track.frameAt(10_000L))
        assertEquals(2, track.frameAt(25_000L))
        assertEquals(2, track.frameAt(9_000_000L))
    }

    @Test
    fun `refuses a frame index out of range`() {
        val index = parsed(buildBif(count = 2))

        for (n in listOf(-1, 2, 99)) {
            assertEquals("frame $n was served", null, index.frame(n))
        }
    }

    @Test
    fun `falls back to a sensible shape until the frames are measured`() {
        // The gateway measures the frames and says so; the direct path does not, and the
        // chip still has to be laid out at about the right size on the very first press.
        val unmeasured = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
        assertEquals(213, unmeasured.widthFor(120))

        val measured = unmeasured.copy(width = 320, height = 172)
        assertEquals(120 * 320 / 172, measured.widthFor(120))
    }
}
