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

import java.io.IOException
import java.lang.Math.addExact
import java.lang.Math.multiplyExact

data class OsmPreviewNode(
    val id: Long,
    val latitude: Double,
    val longitude: Double,
    val tags: Map<String, String>,
)

data class OsmPreviewWay(
    val id: Long,
    val nodeIds: LongArray,
    val tags: Map<String, String>,
) {
    override fun equals(other: Any?): Boolean = other is OsmPreviewWay &&
        id == other.id && nodeIds.contentEquals(other.nodeIds) && tags == other.tags

    override fun hashCode(): Int = 31 * (31 * id.hashCode() + nodeIds.contentHashCode()) + tags.hashCode()
}

enum class OsmRelationMemberType {
    NODE,
    WAY,
    RELATION,
}

data class OsmRelationMember(
    val id: Long,
    val type: OsmRelationMemberType,
    val role: String,
)

data class OsmPreviewRelation(
    val id: Long,
    val members: List<OsmRelationMember>,
    val tags: Map<String, String>,
)

data class PrimitiveBlockStatistics(
    val nodeCount: Int,
    val wayCount: Int,
    val relationCount: Int,
)

data class PrimitiveBlockData(
    val nodes: List<OsmPreviewNode>,
    val ways: List<OsmPreviewWay>,
    val relations: List<OsmPreviewRelation>,
) {
    val statistics: PrimitiveBlockStatistics
        get() = PrimitiveBlockStatistics(nodes.size, ways.size, relations.size)
}

object PrimitiveBlockDecoder {
    fun decodeToData(bytes: ByteArray): PrimitiveBlockData {
        val nodes = mutableListOf<OsmPreviewNode>()
        val ways = mutableListOf<OsmPreviewWay>()
        val relations = mutableListOf<OsmPreviewRelation>()
        decode(bytes, nodes::add, ways::add, relations::add)
        return PrimitiveBlockData(nodes, ways, relations)
    }

    fun decode(
        bytes: ByteArray,
        onNode: (OsmPreviewNode) -> Unit = {},
        onWay: (OsmPreviewWay) -> Unit = {},
        onRelation: (OsmPreviewRelation) -> Unit = {},
    ): PrimitiveBlockStatistics {
        val reader = ProtoReader(bytes)
        var stringTableBytes: ByteArray? = null
        val groups = mutableListOf<ByteArray>()
        var granularity = 100
        var latitudeOffset = 0L
        var longitudeOffset = 0L
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> stringTableBytes = reader.readBytes(tag)
                2 -> groups += reader.readBytes(tag)
                17 -> granularity = reader.readInt32(tag)
                18 -> reader.readInt32(tag) // Zeitgranularität wird für die Vorschau nicht benötigt.
                19 -> latitudeOffset = reader.readSInt64(tag)
                20 -> longitudeOffset = reader.readSInt64(tag)
                else -> reader.skip(tag)
            }
        }
        if (granularity <= 0) throw IOException("Ungültige Koordinatengranularität im PrimitiveBlock.")
        val strings = parseStringTable(
            stringTableBytes ?: throw IOException("Im PrimitiveBlock fehlt die StringTable."),
        )
        var nodeCount = 0
        var wayCount = 0
        var relationCount = 0
        groups.forEach { group ->
            val counts = parsePrimitiveGroup(
                bytes = group,
                strings = strings,
                granularity = granularity,
                latitudeOffset = latitudeOffset,
                longitudeOffset = longitudeOffset,
                onNode = onNode,
                onWay = onWay,
                onRelation = onRelation,
            )
            nodeCount += counts.nodeCount
            wayCount += counts.wayCount
            relationCount += counts.relationCount
        }
        return PrimitiveBlockStatistics(nodeCount, wayCount, relationCount)
    }

    private fun parseStringTable(bytes: ByteArray): List<String> {
        val reader = ProtoReader(bytes)
        val strings = mutableListOf<String>()
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            if (tag.fieldNumber == 1) strings += reader.readString(tag) else reader.skip(tag)
        }
        if (strings.isEmpty() || strings.first().isNotEmpty()) {
            throw IOException("Die PBF-StringTable beginnt nicht mit einem leeren Eintrag.")
        }
        return strings
    }

    private fun parsePrimitiveGroup(
        bytes: ByteArray,
        strings: List<String>,
        granularity: Int,
        latitudeOffset: Long,
        longitudeOffset: Long,
        onNode: (OsmPreviewNode) -> Unit,
        onWay: (OsmPreviewWay) -> Unit,
        onRelation: (OsmPreviewRelation) -> Unit,
    ): PrimitiveBlockStatistics {
        val reader = ProtoReader(bytes)
        var nodeCount = 0
        var wayCount = 0
        var relationCount = 0
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> {
                    onNode(parseNode(reader.readBytes(tag), strings, granularity, latitudeOffset, longitudeOffset))
                    nodeCount++
                }
                2 -> nodeCount += parseDenseNodes(
                    reader.readBytes(tag), strings, granularity, latitudeOffset, longitudeOffset, onNode,
                )
                3 -> {
                    onWay(parseWay(reader.readBytes(tag), strings))
                    wayCount++
                }
                4 -> {
                    onRelation(parseRelation(reader.readBytes(tag), strings))
                    relationCount++
                }
                else -> reader.skip(tag)
            }
        }
        return PrimitiveBlockStatistics(nodeCount, wayCount, relationCount)
    }

    private fun parseNode(
        bytes: ByteArray,
        strings: List<String>,
        granularity: Int,
        latitudeOffset: Long,
        longitudeOffset: Long,
    ): OsmPreviewNode {
        val reader = ProtoReader(bytes)
        var id: Long? = null
        var latitude: Long? = null
        var longitude: Long? = null
        val keys = mutableListOf<Int>()
        val values = mutableListOf<Int>()
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> id = reader.readSInt64(tag)
                2 -> keys += reader.readPackedUInt32(tag).toList()
                3 -> values += reader.readPackedUInt32(tag).toList()
                8 -> latitude = reader.readSInt64(tag)
                9 -> longitude = reader.readSInt64(tag)
                else -> reader.skip(tag)
            }
        }
        return OsmPreviewNode(
            id = id ?: throw IOException("OSM-Node ohne ID."),
            latitude = coordinate(latitudeOffset, granularity, latitude, "Breitengrad"),
            longitude = coordinate(longitudeOffset, granularity, longitude, "Längengrad"),
            tags = tags(keys, values, strings),
        )
    }

    private fun parseDenseNodes(
        bytes: ByteArray,
        strings: List<String>,
        granularity: Int,
        latitudeOffset: Long,
        longitudeOffset: Long,
        onNode: (OsmPreviewNode) -> Unit,
    ): Int {
        val reader = ProtoReader(bytes)
        val idDeltas = mutableListOf<Long>()
        val latitudeDeltas = mutableListOf<Long>()
        val longitudeDeltas = mutableListOf<Long>()
        val keysValues = mutableListOf<Int>()
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> idDeltas += reader.readPackedSInt64(tag).toList()
                8 -> latitudeDeltas += reader.readPackedSInt64(tag).toList()
                9 -> longitudeDeltas += reader.readPackedSInt64(tag).toList()
                10 -> keysValues += reader.readPackedInt32(tag).toList()
                else -> reader.skip(tag)
            }
        }
        if (idDeltas.size != latitudeDeltas.size || idDeltas.size != longitudeDeltas.size) {
            throw IOException("DenseNodes enthält unterschiedlich viele IDs und Koordinaten.")
        }
        var id = 0L
        var latitude = 0L
        var longitude = 0L
        var tagPosition = 0
        idDeltas.indices.forEach { index ->
            id = safeAdd(id, idDeltas[index], "Node-ID")
            latitude = safeAdd(latitude, latitudeDeltas[index], "Breitengrad")
            longitude = safeAdd(longitude, longitudeDeltas[index], "Längengrad")
            val nodeTags = linkedMapOf<String, String>()
            if (keysValues.isNotEmpty()) {
                while (true) {
                    if (tagPosition >= keysValues.size) throw IOException("DenseNodes-Tags enden unerwartet.")
                    val key = keysValues[tagPosition++]
                    if (key == 0) break
                    if (tagPosition >= keysValues.size) throw IOException("DenseNodes-Tag ohne Wert.")
                    val value = keysValues[tagPosition++]
                    nodeTags[stringAt(strings, key)] = stringAt(strings, value)
                }
            }
            onNode(
                OsmPreviewNode(
                    id,
                    coordinate(latitudeOffset, granularity, latitude, "Breitengrad"),
                    coordinate(longitudeOffset, granularity, longitude, "Längengrad"),
                    nodeTags,
                ),
            )
        }
        if (tagPosition != keysValues.size) throw IOException("DenseNodes enthält überzählige Tagwerte.")
        return idDeltas.size
    }

    private fun parseWay(bytes: ByteArray, strings: List<String>): OsmPreviewWay {
        val reader = ProtoReader(bytes)
        var id: Long? = null
        val keys = mutableListOf<Int>()
        val values = mutableListOf<Int>()
        val referenceDeltas = mutableListOf<Long>()
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> id = reader.readInt64(tag)
                2 -> keys += reader.readPackedUInt32(tag).toList()
                3 -> values += reader.readPackedUInt32(tag).toList()
                8 -> referenceDeltas += reader.readPackedSInt64(tag).toList()
                else -> reader.skip(tag)
            }
        }
        var reference = 0L
        val nodeIds = LongArray(referenceDeltas.size) { index ->
            reference = safeAdd(reference, referenceDeltas[index], "Way-Node-Referenz")
            reference
        }
        return OsmPreviewWay(
            id = id ?: throw IOException("OSM-Way ohne ID."),
            nodeIds = nodeIds,
            tags = tags(keys, values, strings),
        )
    }

    private fun parseRelation(bytes: ByteArray, strings: List<String>): OsmPreviewRelation {
        val reader = ProtoReader(bytes)
        var id: Long? = null
        val keys = mutableListOf<Int>()
        val values = mutableListOf<Int>()
        val roles = mutableListOf<Int>()
        val memberIdDeltas = mutableListOf<Long>()
        val memberTypes = mutableListOf<Int>()
        while (reader.hasRemaining()) {
            val tag = reader.readTag()
            when (tag.fieldNumber) {
                1 -> id = reader.readInt64(tag)
                2 -> keys += reader.readPackedUInt32(tag).toList()
                3 -> values += reader.readPackedUInt32(tag).toList()
                8 -> roles += reader.readPackedInt32(tag).toList()
                9 -> memberIdDeltas += reader.readPackedSInt64(tag).toList()
                10 -> memberTypes += reader.readPackedInt32(tag).toList()
                else -> reader.skip(tag)
            }
        }
        if (roles.size != memberIdDeltas.size || roles.size != memberTypes.size) {
            throw IOException("OSM-Relation enthält unterschiedlich viele Rollen, IDs und Typen.")
        }
        var memberId = 0L
        val members = roles.indices.map { index ->
            memberId = safeAdd(memberId, memberIdDeltas[index], "Relation-Mitglieds-ID")
            OsmRelationMember(
                id = memberId,
                type = when (memberTypes[index]) {
                    0 -> OsmRelationMemberType.NODE
                    1 -> OsmRelationMemberType.WAY
                    2 -> OsmRelationMemberType.RELATION
                    else -> throw IOException("Ungültiger Relation-Mitgliedstyp ${memberTypes[index]}.")
                },
                role = stringAt(strings, roles[index]),
            )
        }
        return OsmPreviewRelation(
            id = id ?: throw IOException("OSM-Relation ohne ID."),
            members = members,
            tags = tags(keys, values, strings),
        )
    }

    private fun tags(keys: List<Int>, values: List<Int>, strings: List<String>): Map<String, String> {
        if (keys.size != values.size) throw IOException("OSM-Objekt enthält unterschiedlich viele Tag-Schlüssel und Werte.")
        return keys.indices.associate { index ->
            stringAt(strings, keys[index]) to stringAt(strings, values[index])
        }
    }

    private fun stringAt(strings: List<String>, index: Int): String =
        strings.getOrNull(index) ?: throw IOException("Ungültiger StringTable-Index $index.")

    private fun coordinate(offset: Long, granularity: Int, value: Long?, name: String): Double {
        val encoded = value ?: throw IOException("OSM-Node ohne $name.")
        val nanoDegrees = try {
            addExact(offset, multiplyExact(granularity.toLong(), encoded))
        } catch (exception: ArithmeticException) {
            throw IOException("Koordinatenüberlauf beim $name.", exception)
        }
        return nanoDegrees / 1_000_000_000.0
    }

    private fun safeAdd(left: Long, right: Long, name: String): Long = try {
        addExact(left, right)
    } catch (exception: ArithmeticException) {
        throw IOException("Überlauf bei $name.", exception)
    }
}
