package de.arxsilex.maptools.converter.core.preview

import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.pbf.OsmPreviewNode
import de.arxsilex.maptools.converter.core.pbf.OsmPreviewRelation
import de.arxsilex.maptools.converter.core.pbf.OsmPreviewWay
import de.arxsilex.maptools.converter.core.pbf.OsmRelationMemberType
import de.arxsilex.maptools.converter.core.pbf.PbfBlockIndex
import de.arxsilex.maptools.converter.core.pbf.PbfBlockScanner
import de.arxsilex.maptools.converter.core.pbf.PbfDataBlockReader
import de.arxsilex.maptools.converter.core.pbf.PrimitiveBlockDecoder
import java.io.IOException
import java.nio.file.Path
import java.util.Arrays

enum class PreviewFeatureClass {
    MAJOR_ROAD,
    MINOR_ROAD,
    PATH,
    RAILWAY,
    WATERWAY,
    LAND,
    BUILDING,
    POI,
    WATER_AREA,
    URBAN_AREA,
    LOCAL_ROAD,
}

enum class PreviewLabelClass {
    CITY,
    TOWN,
    VILLAGE,
    ROAD,
    POI,
    WATER,
    AREA,
}

data class PreviewPoint(
    val latitude: Double,
    val longitude: Double,
    val featureClass: PreviewFeatureClass,
)

data class PreviewPolyline(
    val coordinates: DoubleArray,
    val featureClass: PreviewFeatureClass,
) {
    init {
        require(coordinates.size >= 4 && coordinates.size % 2 == 0)
    }
}

data class PreviewLabel(
    val latitude: Double,
    val longitude: Double,
    val text: String,
    val labelClass: PreviewLabelClass,
    val importance: Int,
) {
    init {
        require(text.isNotBlank() && text.length <= 80)
        require(importance in 0..100)
    }
}

data class PreviewDataset(
    val bounds: GeoBounds,
    val points: List<PreviewPoint>,
    val polylines: List<PreviewPolyline>,
    val sourceNodeCount: Long,
    val sourceWayCount: Long,
    val labels: List<PreviewLabel> = emptyList(),
)

object PreviewDatasetCombiner {
    fun combine(bounds: GeoBounds, datasets: List<PreviewDataset>): PreviewDataset {
        require(datasets.isNotEmpty()) { "Mindestens eine Vorschau wird zum Kombinieren benötigt." }
        if (datasets.size == 1 && datasets.single().bounds == bounds) return datasets.single()
        val points = datasets.asSequence().flatMap { it.points.asSequence() }.distinctBy { point ->
            Triple(
                point.featureClass,
                Math.round(point.latitude * 10_000_000.0),
                Math.round(point.longitude * 10_000_000.0),
            )
        }.toList()
        val polylines = datasets.asSequence().flatMap { it.polylines.asSequence() }.distinctBy { line ->
            Triple(line.featureClass, line.coordinates.size, line.coordinates.contentHashCode())
        }.toList()
        val labels = datasets.asSequence().flatMap { it.labels.asSequence() }.distinctBy { label ->
            listOf(
                label.labelClass,
                label.text,
                Math.round(label.latitude * 1_000_000.0),
                Math.round(label.longitude * 1_000_000.0),
            )
        }.sortedByDescending(PreviewLabel::importance).toList()
        return PreviewDataset(
            bounds = bounds,
            points = points,
            polylines = polylines,
            sourceNodeCount = datasets.sumOf(PreviewDataset::sourceNodeCount),
            sourceWayCount = datasets.sumOf(PreviewDataset::sourceWayCount),
            labels = labels,
        )
    }
}

enum class PreviewBuildPhase { SCANNING, COLLECTING, RESOLVING_RELATIONS, RESOLVING_NODES, BUILDING_GEOMETRIES }

data class PreviewBuildProgress(val phase: PreviewBuildPhase, val completed: Int, val total: Int)

object PreviewFeatureClassifier {
    fun classifyWay(tags: Map<String, String>, id: Long, detailed: Boolean = false): PreviewFeatureClass? {
        val highway = tags["highway"]
        if (highway != null) {
            return when (highway) {
                "motorway", "motorway_link", "trunk", "trunk_link", "primary", "primary_link", "secondary" ->
                    PreviewFeatureClass.MAJOR_ROAD
                "tertiary", "tertiary_link" -> PreviewFeatureClass.MINOR_ROAD
                "residential", "unclassified", "service", "living_street" ->
                    PreviewFeatureClass.LOCAL_ROAD
                "track", "path", "footway", "cycleway", "bridleway", "steps", "pedestrian" ->
                    PreviewFeatureClass.PATH
                else -> null
            }
        }
        if (tags["railway"] in setOf("rail", "light_rail", "narrow_gauge")) return PreviewFeatureClass.RAILWAY
        when (tags["waterway"]) {
            "river", "canal" -> PreviewFeatureClass.WATERWAY
            "stream" -> PreviewFeatureClass.WATERWAY
            else -> null
        }?.let { return it }
        if ("building" in tags) {
            return PreviewFeatureClass.BUILDING.takeIf { detailed || id.floorMod(12) == 0 }
        }
        return classifyArea(tags)
    }

    fun classifyRelation(relation: OsmPreviewRelation): PreviewFeatureClass? =
        classifyArea(relation.tags).takeIf {
            relation.tags["type"] in setOf("multipolygon", "boundary")
        }

    private fun classifyArea(tags: Map<String, String>): PreviewFeatureClass? {
        if (tags["natural"] in setOf("water", "bay") ||
            tags["waterway"] == "riverbank" ||
            tags["landuse"] in setOf("reservoir", "basin")
        ) {
            return PreviewFeatureClass.WATER_AREA
        }
        if (tags["landuse"] in setOf("residential", "commercial", "industrial", "retail", "construction")) {
            return PreviewFeatureClass.URBAN_AREA
        }
        if (tags.keys.any { it in setOf("landuse", "natural", "leisure") }) {
            return PreviewFeatureClass.LAND
        }
        return null
    }

    fun isPoi(tags: Map<String, String>, id: Long, detailed: Boolean = false): Boolean =
        tags.keys.any { it in setOf("amenity", "tourism", "shop", "historic", "leisure", "office", "craft") } &&
            (detailed || id.floorMod(4) == 0)

    fun nodeLabel(tags: Map<String, String>): Pair<PreviewLabelClass, Int>? = when (tags["place"]) {
        "city" -> PreviewLabelClass.CITY to 100
        "town" -> PreviewLabelClass.TOWN to 90
        "village" -> PreviewLabelClass.VILLAGE to 78
        "hamlet" -> PreviewLabelClass.VILLAGE to 68
        "suburb", "quarter" -> PreviewLabelClass.VILLAGE to 58
        else -> null
    }

    fun wayLabel(featureClass: PreviewFeatureClass): Pair<PreviewLabelClass, Int>? = when (featureClass) {
        PreviewFeatureClass.MAJOR_ROAD -> PreviewLabelClass.ROAD to 54
        PreviewFeatureClass.MINOR_ROAD -> PreviewLabelClass.ROAD to 38
        PreviewFeatureClass.LOCAL_ROAD -> PreviewLabelClass.ROAD to 28
        PreviewFeatureClass.PATH -> PreviewLabelClass.ROAD to 22
        PreviewFeatureClass.WATERWAY, PreviewFeatureClass.WATER_AREA -> PreviewLabelClass.WATER to 45
        PreviewFeatureClass.LAND, PreviewFeatureClass.URBAN_AREA -> PreviewLabelClass.AREA to 30
        else -> null
    }

    private fun Long.floorMod(divisor: Int): Int = ((this % divisor) + divisor).toInt() % divisor
}

object PreviewDatasetBuilder {
    // The preview is an overview index, not a second copy of the complete source. Keeping this
    // bounded is important because pending ways and their node references coexist while resolving
    // coordinates. The renderer never consumes anywhere near the former 1.5 million line limit.
    private const val MAX_WAYS = 400_000
    private const val MAX_POINTS = 150_000
    private const val MAX_LABELS = 60_000
    private const val MAX_MULTIPOLYGON_RELATIONS = 40_000

    fun build(
        source: Path,
        bounds: GeoBounds,
        existingIndex: PbfBlockIndex? = null,
        onProgress: (PreviewBuildProgress) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): PreviewDataset {
        val index = existingIndex ?: PbfBlockScanner.scan(
            source,
            onProgress = { onProgress(PreviewBuildProgress(PreviewBuildPhase.SCANNING, it.blocksRead, 0)) },
            isCancelled = isCancelled,
        )
        val dataEntries = index.entries.filter { it.type == "OSMData" }
        val candidates = mutableListOf<PendingWay>()
        val pendingRelations = mutableListOf<PendingRelation>()
        val points = mutableListOf<PreviewPoint>()
        val labels = mutableListOf<PreviewLabel>()
        val references = LongCollector()
        val candidateCounts = IntArray(PreviewFeatureClass.entries.size)
        var sourceNodeCount = 0L
        var sourceWayCount = 0L
        var lastNodeEntry = -1

        fun addCandidate(way: OsmPreviewWay, featureClass: PreviewFeatureClass) {
            if (way.nodeIds.size < 2 || candidates.size >= MAX_WAYS) return
            val classLimit = when (featureClass) {
                PreviewFeatureClass.MAJOR_ROAD -> 80_000
                PreviewFeatureClass.MINOR_ROAD -> 50_000
                PreviewFeatureClass.LOCAL_ROAD -> 100_000
                PreviewFeatureClass.PATH -> 90_000
                PreviewFeatureClass.BUILDING -> 30_000
                PreviewFeatureClass.LAND, PreviewFeatureClass.URBAN_AREA, PreviewFeatureClass.WATER_AREA -> 20_000
                else -> 20_000
            }
            if (candidateCounts[featureClass.ordinal] >= classLimit) return
            candidateCounts[featureClass.ordinal]++
            candidates += PendingWay(way.id, way.nodeIds, featureClass, way.tags.previewName())
            references.addAll(way.nodeIds)
        }

        PbfDataBlockReader.open(source).use { blocks ->
            dataEntries.forEachIndexed { blockIndex, entry ->
                if (isCancelled()) throw IOException("Der Aufbau der Vorschau wurde abgebrochen.")
                val statistics = PrimitiveBlockDecoder.decode(
                    blocks.read(entry),
                    onNode = { node ->
                        val name = node.tags.previewName()
                        val placeLabel = name?.let { PreviewFeatureClassifier.nodeLabel(node.tags) }
                        if (placeLabel != null && labels.size < MAX_LABELS) {
                            labels += PreviewLabel(
                                node.latitude,
                                node.longitude,
                                name,
                                placeLabel.first,
                                placeLabel.second,
                            )
                        }
                        if (points.size < MAX_POINTS && PreviewFeatureClassifier.isPoi(node.tags, node.id)) {
                            points += PreviewPoint(node.latitude, node.longitude, PreviewFeatureClass.POI)
                            if (name != null && placeLabel == null && labels.size < MAX_LABELS) {
                                labels += PreviewLabel(
                                    node.latitude,
                                    node.longitude,
                                    name,
                                    PreviewLabelClass.POI,
                                    24,
                                )
                            }
                        }
                    },
                    onWay = { way ->
                        val featureClass = PreviewFeatureClassifier.classifyWay(way.tags, way.id)
                        if (featureClass != null) addCandidate(way, featureClass)
                    },
                    onRelation = { relation ->
                        val featureClass = PreviewFeatureClassifier.classifyRelation(relation)
                        if (featureClass != null && pendingRelations.size < MAX_MULTIPOLYGON_RELATIONS) {
                            val outerWays = relation.members
                                .asSequence()
                                .filter { it.type == OsmRelationMemberType.WAY && it.role != "inner" }
                                .map { it.id }
                                .toList()
                            if (outerWays.isNotEmpty()) {
                                pendingRelations += PendingRelation(
                                    relation.id,
                                    outerWays,
                                    featureClass,
                                    relation.tags.previewName(),
                                )
                            }
                        }
                    },
                )
                sourceNodeCount += statistics.nodeCount
                sourceWayCount += statistics.wayCount
                if (statistics.nodeCount > 0) lastNodeEntry = blockIndex
                onProgress(PreviewBuildProgress(PreviewBuildPhase.COLLECTING, blockIndex + 1, dataEntries.size))
            }
        }

        val relationWayIds = pendingRelations.flatMapTo(HashSet()) { it.outerWayIds }
        val relationWays = HashMap<Long, LongArray>(relationWayIds.size)
        if (relationWayIds.isNotEmpty()) {
            PbfDataBlockReader.open(source).use { blocks ->
                dataEntries.forEachIndexed { blockIndex, entry ->
                    if (isCancelled()) throw IOException("Der Aufbau der Vorschau wurde abgebrochen.")
                    PrimitiveBlockDecoder.decode(
                        blocks.read(entry),
                        onWay = { way ->
                            if (way.id in relationWayIds) {
                                relationWays[way.id] = way.nodeIds
                                references.addAll(way.nodeIds)
                            }
                        },
                    )
                    onProgress(
                        PreviewBuildProgress(PreviewBuildPhase.RESOLVING_RELATIONS, blockIndex + 1, dataEntries.size),
                    )
                }
            }
        }

        val nodeIds = references.sortedUnique()
        val latitudes = DoubleArray(nodeIds.size) { Double.NaN }
        val longitudes = DoubleArray(nodeIds.size) { Double.NaN }
        var wantedIndex = 0
        var previousNodeId = Long.MIN_VALUE
        PbfDataBlockReader.open(source).use { blocks ->
            dataEntries.take(lastNodeEntry + 1).forEachIndexed { blockIndex, entry ->
                if (isCancelled()) throw IOException("Der Aufbau der Vorschau wurde abgebrochen.")
                PrimitiveBlockDecoder.decode(
                    blocks.read(entry),
                    onNode = { node ->
                        if (node.id < previousNodeId) throw IOException("Die PBF-Nodes sind nicht nach ID sortiert.")
                        previousNodeId = node.id
                        while (wantedIndex < nodeIds.size && nodeIds[wantedIndex] < node.id) wantedIndex++
                        if (wantedIndex < nodeIds.size && nodeIds[wantedIndex] == node.id) {
                            latitudes[wantedIndex] = node.latitude
                            longitudes[wantedIndex] = node.longitude
                        }
                    },
                )
                onProgress(
                    PreviewBuildProgress(PreviewBuildPhase.RESOLVING_NODES, blockIndex + 1, lastNodeEntry + 1),
                )
            }
        }

        val polylines = ArrayList<PreviewPolyline>(candidates.size + pendingRelations.size)
        val wayLabelCells = HashSet<String>()
        candidates.forEachIndexed { candidateIndex, candidate ->
            val coordinates = resolveCoordinates(candidate.nodeIds, nodeIds, latitudes, longitudes)
            if (coordinates != null) {
                polylines += PreviewPolyline(coordinates, candidate.featureClass)
                val labelType = candidate.name?.let { PreviewFeatureClassifier.wayLabel(candidate.featureClass) }
                if (labelType != null && labels.size < MAX_LABELS) {
                    val middle = (candidate.nodeIds.size / 2) * 2
                    val longitude = coordinates[middle]
                    val latitude = coordinates[middle + 1]
                    val cellX = (((longitude - bounds.west) / (bounds.east - bounds.west)) * 30).toInt()
                    val cellY = (((latitude - bounds.south) / (bounds.north - bounds.south)) * 30).toInt()
                    val key = "${labelType.first}:$cellX:$cellY:${candidate.name}"
                    if (wayLabelCells.add(key)) {
                        labels += PreviewLabel(latitude, longitude, candidate.name, labelType.first, labelType.second)
                    }
                }
            }
            if (candidateIndex % 1000 == 0 || candidateIndex == candidates.lastIndex) {
                onProgress(
                    PreviewBuildProgress(PreviewBuildPhase.BUILDING_GEOMETRIES, candidateIndex + 1, candidates.size),
                )
            }
        }
        pendingRelations.forEachIndexed { relationIndex, relation ->
            val memberWays = relation.outerWayIds.mapNotNull(relationWays::get)
            val rings = stitchClosedRings(memberWays)
            rings.forEachIndexed { ringIndex, ring ->
                val coordinates = resolveCoordinates(ring, nodeIds, latitudes, longitudes) ?: return@forEachIndexed
                polylines += PreviewPolyline(coordinates, relation.featureClass)
                if (ringIndex == 0 && relation.name != null && labels.size < MAX_LABELS) {
                    val middle = (ring.size / 2) * 2
                    val labelType = PreviewFeatureClassifier.wayLabel(relation.featureClass)
                    if (labelType != null) {
                        labels += PreviewLabel(
                            latitude = coordinates[middle + 1],
                            longitude = coordinates[middle],
                            text = relation.name,
                            labelClass = labelType.first,
                            importance = labelType.second + 4,
                        )
                    }
                }
            }
            if (relationIndex % 250 == 0 || relationIndex == pendingRelations.lastIndex) {
                onProgress(
                    PreviewBuildProgress(
                        PreviewBuildPhase.BUILDING_GEOMETRIES,
                        candidates.size + relationIndex + 1,
                        candidates.size + pendingRelations.size,
                    ),
                )
            }
        }
        labels.sortByDescending(PreviewLabel::importance)
        return PreviewDataset(bounds, points, polylines, sourceNodeCount, sourceWayCount, labels)
    }

    private data class PendingWay(
        val id: Long,
        val nodeIds: LongArray,
        val featureClass: PreviewFeatureClass,
        val name: String?,
    )

    private data class PendingRelation(
        val id: Long,
        val outerWayIds: List<Long>,
        val featureClass: PreviewFeatureClass,
        val name: String?,
    )

    private fun resolveCoordinates(
        references: LongArray,
        sortedNodeIds: LongArray,
        latitudes: DoubleArray,
        longitudes: DoubleArray,
    ): DoubleArray? {
        val coordinates = DoubleArray(references.size * 2)
        references.forEachIndexed { indexInWay, nodeId ->
            val coordinateIndex = Arrays.binarySearch(sortedNodeIds, nodeId)
            if (coordinateIndex < 0 || latitudes[coordinateIndex].isNaN()) return null
            coordinates[indexInWay * 2] = longitudes[coordinateIndex]
            coordinates[indexInWay * 2 + 1] = latitudes[coordinateIndex]
        }
        return coordinates
    }

    internal fun stitchClosedRings(memberWays: List<LongArray>): List<LongArray> {
        val remaining = memberWays.filter { it.size >= 2 }.mapTo(mutableListOf()) { it.toMutableList() }
        val rings = mutableListOf<LongArray>()
        while (remaining.isNotEmpty()) {
            val chain = remaining.removeAt(0)
            var joined = true
            while (chain.first() != chain.last() && joined) {
                joined = false
                val start = chain.first()
                val end = chain.last()
                val matchIndex = remaining.indexOfFirst { part ->
                    part.first() == end || part.last() == end || part.last() == start || part.first() == start
                }
                if (matchIndex >= 0) {
                    val part = remaining.removeAt(matchIndex)
                    when {
                        part.first() == end -> chain.addAll(part.drop(1))
                        part.last() == end -> chain.addAll(part.asReversed().drop(1))
                        part.last() == start -> chain.addAll(0, part.dropLast(1))
                        part.first() == start -> chain.addAll(0, part.asReversed().dropLast(1))
                    }
                    joined = true
                }
            }
            if (chain.size >= 4 && chain.first() == chain.last()) rings += chain.toLongArray()
        }
        return rings
    }

    private fun Map<String, String>.previewName(): String? =
        (this["name:de"] ?: this["name"] ?: this["ref"])
            ?.trim()
            ?.replace(Regex("\\s+"), " ")
            ?.take(80)
            ?.takeIf(String::isNotBlank)
}

private class LongCollector(initialCapacity: Int = 1_048_576) {
    private var values = LongArray(initialCapacity)
    private var size = 0

    fun addAll(source: LongArray) {
        if (size + source.size > values.size) {
            var capacity = values.size
            while (capacity < size + source.size) capacity = (capacity * 2).coerceAtLeast(size + source.size)
            values = values.copyOf(capacity)
        }
        source.copyInto(values, destinationOffset = size)
        size += source.size
    }

    fun sortedUnique(): LongArray {
        Arrays.sort(values, 0, size)
        if (size == 0) return LongArray(0)
        var uniqueSize = 1
        for (index in 1 until size) {
            if (values[index] != values[uniqueSize - 1]) values[uniqueSize++] = values[index]
        }
        return values.copyOf(uniqueSize).also {
            // Do not retain the much larger duplicate-filled backing array during coordinate lookup.
            values = LongArray(0)
            size = 0
        }
    }
}
