package de.arxsilex.maptools.converter.core.pbf

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.zip.DeflaterOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull

class PbfHeaderReaderTest {
    @Test
    fun `reads raw OSM header bounds and features`() {
        val pbf = pbfFile(
            headerBlock = headerBlock(
                west = 6.1,
                south = 50.2,
                east = 8.3,
                north = 51.4,
                requiredFeatures = listOf("OsmSchema-V0.6", "DenseNodes"),
                optionalFeatures = listOf("Sort.Type_then_ID"),
                writingProgram = "MapTools test",
                source = "unit test",
            ),
            compressed = false,
        )

        val metadata = PbfHeaderReader.read(ByteArrayInputStream(pbf))
        val bounds = assertNotNull(metadata.bounds)

        assertEquals(6.1, bounds.west, 0.000000001)
        assertEquals(50.2, bounds.south, 0.000000001)
        assertEquals(8.3, bounds.east, 0.000000001)
        assertEquals(51.4, bounds.north, 0.000000001)
        assertEquals(listOf("OsmSchema-V0.6", "DenseNodes"), metadata.requiredFeatures)
        assertEquals(listOf("Sort.Type_then_ID"), metadata.optionalFeatures)
        assertEquals("MapTools test", metadata.writingProgram)
        assertEquals("unit test", metadata.source)
    }

    @Test
    fun `reads zlib compressed OSM header`() {
        val pbf = pbfFile(
            headerBlock = headerBlock(10.0, 40.0, 20.0, 50.0),
            compressed = true,
        )

        val metadata = PbfHeaderReader.read(ByteArrayInputStream(pbf))
        val bounds = assertNotNull(metadata.bounds)

        assertEquals(10.0, bounds.west)
        assertEquals(50.0, bounds.north)
    }

    @Test
    fun `rejects file whose first blob is not OSM header`() {
        val headerBlock = headerBlock(10.0, 40.0, 20.0, 50.0)
        val blob = fieldBytes(1, headerBlock)
        val blobHeader = fieldString(1, "OSMData") + fieldVarint(3, blob.size.toLong())
        val file = bigEndianInt(blobHeader.size) + blobHeader + blob

        val error = assertFailsWith<IOException> {
            PbfHeaderReader.read(ByteArrayInputStream(file))
        }

        assertEquals("Die PBF-Datei beginnt nicht mit einem OSMHeader.", error.message)
    }

    @Test
    fun `rejects oversized blob header before allocating it`() {
        val file = bigEndianInt(64 * 1024 + 1)

        assertFailsWith<IOException> {
            PbfHeaderReader.read(ByteArrayInputStream(file))
        }
    }
}

private fun pbfFile(headerBlock: ByteArray, compressed: Boolean): ByteArray {
    val blob = if (compressed) {
        fieldVarint(2, headerBlock.size.toLong()) + fieldBytes(3, headerBlock.zlib())
    } else {
        fieldBytes(1, headerBlock)
    }
    val blobHeader = fieldString(1, "OSMHeader") + fieldVarint(3, blob.size.toLong())
    return bigEndianInt(blobHeader.size) + blobHeader + blob
}

private fun headerBlock(
    west: Double,
    south: Double,
    east: Double,
    north: Double,
    requiredFeatures: List<String> = emptyList(),
    optionalFeatures: List<String> = emptyList(),
    writingProgram: String? = null,
    source: String? = null,
): ByteArray {
    val bounds = fieldSInt64(1, west.nanoDegrees()) +
        fieldSInt64(2, east.nanoDegrees()) +
        fieldSInt64(3, north.nanoDegrees()) +
        fieldSInt64(4, south.nanoDegrees())
    val output = ByteArrayOutputStream()
    output.write(fieldBytes(1, bounds))
    requiredFeatures.forEach { output.write(fieldString(4, it)) }
    optionalFeatures.forEach { output.write(fieldString(5, it)) }
    writingProgram?.let { output.write(fieldString(16, it)) }
    source?.let { output.write(fieldString(17, it)) }
    return output.toByteArray()
}

private fun fieldString(fieldNumber: Int, value: String): ByteArray =
    fieldBytes(fieldNumber, value.toByteArray(Charsets.UTF_8))

private fun fieldBytes(fieldNumber: Int, value: ByteArray): ByteArray =
    varint((fieldNumber shl 3 or 2).toLong()) + varint(value.size.toLong()) + value

private fun fieldVarint(fieldNumber: Int, value: Long): ByteArray =
    varint((fieldNumber shl 3).toLong()) + varint(value)

private fun fieldSInt64(fieldNumber: Int, value: Long): ByteArray =
    fieldVarint(fieldNumber, (value shl 1) xor (value shr 63))

private fun varint(value: Long): ByteArray {
    var remaining = value
    val output = ByteArrayOutputStream()
    while (remaining and -128L != 0L) {
        output.write(((remaining and 127L) or 128L).toInt())
        remaining = remaining ushr 7
    }
    output.write(remaining.toInt())
    return output.toByteArray()
}

private fun bigEndianInt(value: Int): ByteArray = byteArrayOf(
    (value ushr 24).toByte(),
    (value ushr 16).toByte(),
    (value ushr 8).toByte(),
    value.toByte(),
)

private fun ByteArray.zlib(): ByteArray {
    val output = ByteArrayOutputStream()
    DeflaterOutputStream(output).use { it.write(this) }
    return output.toByteArray()
}

private fun Double.nanoDegrees(): Long = (this * 1_000_000_000.0).toLong()
