package de.arxsilex.maptools.converter.core.mapsforge

import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.GeoRectangleRegion
import de.arxsilex.maptools.converter.core.GeoSelectionRegion
import de.arxsilex.maptools.converter.core.unionOrNull
import de.arxsilex.maptools.converter.core.MapsforgeMapMetadata
import de.arxsilex.maptools.converter.core.pbf.MtciPbfWriteProgress
import de.arxsilex.maptools.converter.core.pbf.MtciPbfWriteReport
import de.arxsilex.maptools.converter.core.preview.CachedPreviewDataset
import de.arxsilex.maptools.converter.core.preview.PreviewBuildPhase
import de.arxsilex.maptools.converter.core.preview.PreviewBuildProgress
import de.arxsilex.maptools.converter.core.preview.PreviewDataset
import de.arxsilex.maptools.converter.core.preview.PreviewDatasetStore
import de.arxsilex.maptools.converter.core.preview.PreviewFeatureClass
import de.arxsilex.maptools.converter.core.preview.PreviewFeatureClassifier
import de.arxsilex.maptools.converter.core.preview.PreviewLabel
import de.arxsilex.maptools.converter.core.preview.PreviewLabelClass
import de.arxsilex.maptools.converter.core.preview.PreviewLoadResult
import de.arxsilex.maptools.converter.core.preview.PreviewPoint
import de.arxsilex.maptools.converter.core.preview.PreviewPolyline
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.security.MessageDigest
import kotlin.io.path.fileSize
import kotlin.io.path.getLastModifiedTime
import kotlin.io.path.nameWithoutExtension
import org.mapsforge.core.model.BoundingBox
import org.mapsforge.core.model.LatLong
import org.mapsforge.core.model.Tag
import org.mapsforge.core.model.Tile
import org.mapsforge.core.util.MercatorProjection
import org.mapsforge.map.datastore.MapReadResult
import org.mapsforge.map.datastore.Way
import org.mapsforge.map.reader.MapFile

object MapsforgeMapSource {
    private const val MAX_PREVIEW_POLYLINES = 200_000
    private const val MAX_PREVIEW_POINTS = 50_000
    private const val MAX_PREVIEW_LABELS = 30_000
    private const val PREVIEW_CHUNK_ZOOM = 12

    fun readMetadata(source: Path): MapsforgeMapMetadata = withMapFile(source) { mapFile ->
        val info = mapFile.mapFileInfo
        MapsforgeMapMetadata(
            bounds = info.boundingBox.toGeoBounds(),
            fileVersion = info.fileVersion,
            zoomLevelMin = info.zoomLevelMin.toInt(),
            zoomLevelMax = info.zoomLevelMax.toInt(),
            createdBy = info.createdBy,
            languages = info.languagesPreference,
        )
    }

    fun loadOrBuildPreview(
        source: Path,
        bounds: GeoBounds,
        cacheDirectory: Path,
        onProgress: (PreviewBuildProgress) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): PreviewLoadResult {
        val cacheFile = previewCacheFile(source, cacheDirectory)
        if (Files.isRegularFile(cacheFile)) {
            runCatching { PreviewDatasetStore.read(cacheFile) }.getOrNull()?.let { cached ->
                if (PreviewDatasetStore.matchesSource(cached, source)) {
                    return PreviewLoadResult(cached.dataset, loadedFromCache = true)
                }
            }
        }
        onProgress(PreviewBuildProgress(PreviewBuildPhase.SCANNING, 0, 1))
        val dataset = withMapFile(source) { mapFile ->
            checkCancellation(isCancelled)
            val accumulator = PreviewAccumulator(bounds)
            mapFile.forEachPreviewChunk(bounds) { result, completedChunks, totalChunks ->
                checkCancellation(isCancelled)
                accumulator.add(result, totalChunks, isCancelled)
                onProgress(
                    PreviewBuildProgress(
                        PreviewBuildPhase.BUILDING_GEOMETRIES,
                        completedChunks,
                        totalChunks,
                    ),
                )
            }
            accumulator.build()
        }
        val cacheWarning = PreviewDatasetStore.writeBestEffort(
            CachedPreviewDataset(
                sourceSize = source.fileSize(),
                sourceLastModifiedMillis = source.getLastModifiedTime().toMillis(),
                sourceSha256 = sourceIdentityHash(source),
                dataset = dataset,
            ),
            cacheFile,
        )
        return PreviewLoadResult(
            dataset,
            loadedFromCache = false,
            cacheWarning = cacheWarning?.let { "${source.fileName}: $it" },
        )
    }

    /** Reads full-detail features only for the currently visible geographic window. */
    fun loadDetailedPreview(source: Path, visibleBounds: GeoBounds, datasetBounds: GeoBounds): PreviewDataset =
        withMapFile(source) { mapFile ->
            require(mapFile.boundingBox().toGeoBounds().intersects(visibleBounds)) {
                "Die Mapsforge-Quelle überschneidet die sichtbare Kartenfläche nicht."
            }
            PreviewAccumulator(datasetBounds, detailed = true).apply {
                add(mapFile.read(visibleBounds), totalChunks = 1) { false }
            }.build()
        }

    fun exportToPbf(
        source: Path,
        output: Path,
        bounds: GeoBounds,
        namespace: Int = 0,
        onProgress: (MtciPbfWriteProgress) -> Unit = {},
        isCancelled: () -> Boolean = { false },
        temporaryDirectory: Path = defaultTemporaryDirectory(output),
        onSourceProgress: (completedChunks: Int, totalChunks: Int, selectedObjects: Long) -> Unit = { _, _, _ -> },
    ): MtciPbfWriteReport = exportToPbf(
        source, output, listOf(GeoRectangleRegion(bounds)), namespace, onProgress, isCancelled,
        temporaryDirectory, onSourceProgress,
    )

    fun exportToPbf(
        source: Path,
        output: Path,
        areas: List<GeoSelectionRegion>,
        namespace: Int = 0,
        onProgress: (MtciPbfWriteProgress) -> Unit = {},
        isCancelled: () -> Boolean = { false },
        temporaryDirectory: Path = defaultTemporaryDirectory(output),
        onSourceProgress: (completedChunks: Int, totalChunks: Int, selectedObjects: Long) -> Unit = { _, _, _ -> },
    ): MtciPbfWriteReport = withMapFile(source) { mapFile ->
        require(areas.isNotEmpty()) { "Mindestens ein Auswahlbereich ist erforderlich." }
        val bounds = requireNotNull(areas.map { it.bounds }.unionOrNull())
        require(namespace in 0..999) { "Ungültiger Mapsforge-ID-Namensraum: $namespace" }
        require(areas.any { mapFile.boundingBox().toGeoBounds().intersects(it.bounds) }) {
            "Die Mapsforge-Quelle überschneidet den gewählten Ausschnitt nicht."
        }
        checkCancellation(isCancelled)
        val mapBounds = mapFile.boundingBox().toGeoBounds()
        val relevantAreas = areas.filter { mapBounds.intersects(it.bounds) }
        val totalChunks = relevantAreas.sumOf { mapFile.previewChunkCount(it.bounds) }
        var completedBeforeArea = 0
        MapsforgeDiskMaterializer.create(temporaryDirectory, namespace).use { materializer ->
            relevantAreas.forEach { area ->
                mapFile.forEachPreviewChunk(area.bounds) { result, completedChunks, _ ->
                    checkCancellation(isCancelled)
                    materializer.add(result, areas, isCancelled)
                    onSourceProgress(
                        completedBeforeArea + completedChunks,
                        totalChunks,
                        materializer.writtenObjectCount,
                    )
                }
                completedBeforeArea += mapFile.previewChunkCount(area.bounds)
            }
            checkCancellation(isCancelled)
            val expectedNodes = materializer.nodeCount
            val expectedWays = materializer.wayCount
            val expectedRelations = materializer.relationCount
            materializer.writeToPbf(
                output,
                bounds,
                onProgress = { progress ->
                    val expectedObjects = when (progress.phase) {
                        de.arxsilex.maptools.converter.core.pbf.MtciPbfWritePhase.NODES -> expectedNodes
                        de.arxsilex.maptools.converter.core.pbf.MtciPbfWritePhase.WAYS -> expectedWays
                        de.arxsilex.maptools.converter.core.pbf.MtciPbfWritePhase.RELATIONS -> expectedRelations
                    }
                    onProgress(progress.copy(expectedObjects = expectedObjects))
                },
                isCancelled,
            )
        }
    }

    private fun MapFile.read(bounds: GeoBounds): MapReadResult {
        val info = mapFileInfo
        val sourceBounds = info.boundingBox.toGeoBounds()
        val clipped = GeoBounds(
            west = maxOf(bounds.west, sourceBounds.west),
            south = maxOf(bounds.south, sourceBounds.south),
            east = minOf(bounds.east, sourceBounds.east),
            north = minOf(bounds.north, sourceBounds.north),
        )
        val zoom = info.zoomLevelMax
        val topLeft = Tile(
            MercatorProjection.longitudeToTileX(clipped.west, zoom),
            MercatorProjection.latitudeToTileY(clipped.north, zoom),
            zoom,
            info.tilePixelSize,
        )
        val bottomRight = Tile(
            MercatorProjection.longitudeToTileX(Math.nextDown(clipped.east), zoom),
            MercatorProjection.latitudeToTileY(Math.nextUp(clipped.south), zoom),
            zoom,
            info.tilePixelSize,
        )
        return readMapData(topLeft, bottomRight).deduplicate()
    }

    private fun MapFile.forEachPreviewChunk(
        bounds: GeoBounds,
        action: (result: MapReadResult, completedChunks: Int, totalChunks: Int) -> Unit,
    ) {
        val info = mapFileInfo
        val sourceBounds = info.boundingBox.toGeoBounds()
        val clipped = GeoBounds(
            west = maxOf(bounds.west, sourceBounds.west),
            south = maxOf(bounds.south, sourceBounds.south),
            east = minOf(bounds.east, sourceBounds.east),
            north = minOf(bounds.north, sourceBounds.north),
        )
        val chunkZoom = minOf(PREVIEW_CHUNK_ZOOM, info.zoomLevelMax.toInt()).toByte()
        val left = MercatorProjection.longitudeToTileX(clipped.west, chunkZoom)
        val right = MercatorProjection.longitudeToTileX(Math.nextDown(clipped.east), chunkZoom)
        val top = MercatorProjection.latitudeToTileY(clipped.north, chunkZoom)
        val bottom = MercatorProjection.latitudeToTileY(Math.nextUp(clipped.south), chunkZoom)
        val total = (right - left + 1) * (bottom - top + 1)
        var completed = 0
        for (tileY in top..bottom) {
            for (tileX in left..right) {
                val tileBounds = Tile(tileX, tileY, chunkZoom, info.tilePixelSize).boundingBox.toGeoBounds()
                val chunkBounds = GeoBounds(
                    west = maxOf(clipped.west, tileBounds.west),
                    south = maxOf(clipped.south, tileBounds.south),
                    east = minOf(clipped.east, tileBounds.east),
                    north = minOf(clipped.north, tileBounds.north),
                )
                action(read(chunkBounds), ++completed, total)
            }
        }
    }

    private fun MapFile.previewChunkCount(bounds: GeoBounds): Int {
        val info = mapFileInfo
        val sourceBounds = info.boundingBox.toGeoBounds()
        val clipped = GeoBounds(
            west = maxOf(bounds.west, sourceBounds.west),
            south = maxOf(bounds.south, sourceBounds.south),
            east = minOf(bounds.east, sourceBounds.east),
            north = minOf(bounds.north, sourceBounds.north),
        )
        val chunkZoom = minOf(PREVIEW_CHUNK_ZOOM, info.zoomLevelMax.toInt()).toByte()
        val left = MercatorProjection.longitudeToTileX(clipped.west, chunkZoom)
        val right = MercatorProjection.longitudeToTileX(Math.nextDown(clipped.east), chunkZoom)
        val top = MercatorProjection.latitudeToTileY(clipped.north, chunkZoom)
        val bottom = MercatorProjection.latitudeToTileY(Math.nextUp(clipped.south), chunkZoom)
        return (right - left + 1) * (bottom - top + 1)
    }

    private class PreviewAccumulator(
        private val bounds: GeoBounds,
        private val detailed: Boolean = false,
    ) {
        val points = ArrayList<PreviewPoint>()
        val polylines = ArrayList<PreviewPolyline>()
        val labels = ArrayList<PreviewLabel>()
        private val seenPoints = HashSet<Long>()
        private val seenLines = HashSet<Long>()
        private val seenLabels = HashSet<Long>()
        private var sourceNodeCount = 0L
        private var sourceWayCount = 0L

        fun add(result: MapReadResult, totalChunks: Int, isCancelled: () -> Boolean) {
            sourceNodeCount += result.pois.size
            sourceWayCount += result.ways.size
            val pointBudget = (MAX_PREVIEW_POINTS / totalChunks).coerceAtLeast(8)
            val labelBudget = (MAX_PREVIEW_LABELS / totalChunks).coerceAtLeast(8)
            val lineBudget = (MAX_PREVIEW_POLYLINES / totalChunks).coerceAtLeast(40)
            var addedPoints = 0
            var addedLabels = 0
            val addedClasses = IntArray(PreviewFeatureClass.entries.size)
            result.pois.forEachIndexed { index, poi ->
                if (index % 10_000 == 0) checkCancellation(isCancelled)
                val tags = poi.tags.normalized(poi.layer)
                val stableId = stablePointHash(poi.position, tags)
                val isPoi = PreviewFeatureClassifier.isPoi(tags, stableId, detailed)
                if (addedPoints < pointBudget && points.size < MAX_PREVIEW_POINTS && isPoi && seenPoints.add(stableId)) {
                    points += PreviewPoint(poi.position.latitude, poi.position.longitude, PreviewFeatureClass.POI)
                    addedPoints++
                }
                if (addedLabels < labelBudget && labels.size < MAX_PREVIEW_LABELS) {
                    val text = tags.previewName()
                    val label = PreviewFeatureClassifier.nodeLabel(tags)
                        ?: (PreviewLabelClass.POI to 36).takeIf { text != null && isPoi }
                    val labelHash = text?.let { stableLabelHash(poi.position, it) }
                    if (text != null && label != null && labelHash != null && seenLabels.add(labelHash)) {
                        labels += PreviewLabel(
                            poi.position.latitude,
                            poi.position.longitude,
                            text,
                            label.first,
                            label.second,
                        )
                        addedLabels++
                    }
                }
            }
            result.ways.forEachIndexed { index, way ->
                if (index % 5_000 == 0) checkCancellation(isCancelled)
                val tags = way.tags.normalized(way.layer)
                val stableId = stableWayHash(way, tags)
                val featureClass = PreviewFeatureClassifier.classifyWay(tags, stableId, detailed) ?: return@forEachIndexed
                val classBudget = (lineBudget * previewClassWeight(featureClass) / 100).coerceAtLeast(4)
                way.latLongs.forEach { coordinates ->
                    if (coordinates.size < 2 || polylines.size >= MAX_PREVIEW_POLYLINES ||
                        addedClasses[featureClass.ordinal] >= classBudget
                    ) {
                        return@forEach
                    }
                    val lineHash = stableLineHash(coordinates, featureClass)
                    if (seenLines.add(lineHash)) {
                        polylines += PreviewPolyline(coordinates.toCoordinateArray(), featureClass)
                        addedClasses[featureClass.ordinal]++
                    }
                }
                val text = tags.previewName()
                val labelClass = PreviewFeatureClassifier.wayLabel(featureClass)
                val labelPosition = way.labelPosition ?: way.latLongs.firstOrNull()?.firstOrNull()
                val labelHash = if (text != null && labelPosition != null) stableLabelHash(labelPosition, text) else null
                if (text != null && labelClass != null && labelPosition != null && labelHash != null &&
                    addedLabels < labelBudget && labels.size < MAX_PREVIEW_LABELS && seenLabels.add(labelHash)
                ) {
                    labels += PreviewLabel(
                        labelPosition.latitude,
                        labelPosition.longitude,
                        text,
                        labelClass.first,
                        labelClass.second,
                    )
                    addedLabels++
                }
            }
        }

        fun build(): PreviewDataset = PreviewDataset(
            bounds = bounds,
            points = points,
            polylines = polylines,
            sourceNodeCount = sourceNodeCount,
            sourceWayCount = sourceWayCount,
            labels = labels.sortedByDescending(PreviewLabel::importance),
        )
    }

    private fun previewClassWeight(featureClass: PreviewFeatureClass): Int = when (featureClass) {
        PreviewFeatureClass.MAJOR_ROAD -> 10
        PreviewFeatureClass.MINOR_ROAD -> 12
        PreviewFeatureClass.LOCAL_ROAD -> 20
        PreviewFeatureClass.PATH -> 25
        PreviewFeatureClass.RAILWAY -> 5
        PreviewFeatureClass.WATERWAY -> 5
        PreviewFeatureClass.LAND -> 10
        PreviewFeatureClass.BUILDING -> 12
        PreviewFeatureClass.WATER_AREA -> 5
        PreviewFeatureClass.URBAN_AREA -> 8
        PreviewFeatureClass.POI -> 0
    }

    private fun stablePointHash(position: LatLong, tags: Map<String, String>): Long = StableHasher()
        .putCoordinate(position)
        .putTags(tags)
        .value

    private fun stableWayHash(way: Way, tags: Map<String, String>): Long = StableHasher()
        .putTags(tags)
        .apply {
            way.latLongs.forEach { coordinates ->
                putLong(coordinates.size.toLong())
                coordinates.forEach(::putCoordinate)
            }
        }
        .value

    private fun stableLineHash(coordinates: Array<LatLong>, featureClass: PreviewFeatureClass): Long = StableHasher()
        .putLong(featureClass.ordinal.toLong())
        .putLong(coordinates.size.toLong())
        .apply { coordinates.forEach(::putCoordinate) }
        .value

    private fun stableLabelHash(position: LatLong, text: String): Long = StableHasher()
        .putCoordinate(position)
        .putString(text)
        .value

    private class StableHasher {
        var value: Long = -0x340d631b7bdddcdbL
            private set

        fun putLong(item: Long): StableHasher = apply {
            var remaining = item
            repeat(Long.SIZE_BYTES) {
                value = (value xor (remaining and 0xffL)) * 0x100000001b3L
                remaining = remaining ushr 8
            }
        }

        fun putString(item: String): StableHasher = apply {
            item.forEach { character -> putLong(character.code.toLong()) }
            putLong(-1L)
        }

        fun putCoordinate(coordinate: LatLong): StableHasher = apply {
            putLong(Math.round(coordinate.latitude * 10_000_000.0))
            putLong(Math.round(coordinate.longitude * 10_000_000.0))
        }

        fun putTags(tags: Map<String, String>): StableHasher = apply {
            tags.toSortedMap().forEach { (key, value) ->
                putString(key)
                putString(value)
            }
        }
    }

    private fun Array<LatLong>.toCoordinateArray(): DoubleArray = DoubleArray(size * 2).also { output ->
        forEachIndexed { index, coordinate ->
            output[index * 2] = coordinate.longitude
            output[index * 2 + 1] = coordinate.latitude
        }
    }

    private fun List<Tag>.normalized(layer: Byte): Map<String, String> = buildMap {
        this@normalized.forEach { tag ->
            if (tag.key.isNotBlank() && tag.value.isNotBlank()) put(tag.key, tag.value)
        }
        if (layer.toInt() != 0 && "layer" !in this) put("layer", layer.toString())
    }

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

    private fun BoundingBox.toGeoBounds(): GeoBounds = GeoBounds(
        west = minLongitude,
        south = minLatitude,
        east = maxLongitude,
        north = maxLatitude,
    )

    private fun previewCacheFile(source: Path, cacheDirectory: Path): Path {
        val safeName = source.nameWithoutExtension.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80)
        return cacheDirectory.resolve("$safeName-${sourceIdentityHash(source).take(16)}-map.mtpv")
    }

    private fun sourceIdentityHash(source: Path): String {
        val identity = "${source.toAbsolutePath().normalize()}|${source.fileSize()}|${source.getLastModifiedTime().toMillis()}"
        return MessageDigest.getInstance("SHA-256")
            .digest(identity.toByteArray(StandardCharsets.UTF_8))
            .joinToString("") { "%02x".format(it) }
    }

    private inline fun <T> withMapFile(source: Path, block: (MapFile) -> T): T {
        require(Files.isRegularFile(source)) { "Mapsforge-Quelle nicht gefunden: $source" }
        val mapFile = MapFile(source.toFile())
        return try {
            block(mapFile)
        } finally {
            mapFile.close()
        }
    }

    private fun checkCancellation(isCancelled: () -> Boolean) {
        if (isCancelled()) throw java.util.concurrent.CancellationException("Mapsforge-Verarbeitung abgebrochen.")
    }

    private fun defaultTemporaryDirectory(output: Path): Path =
        output.toAbsolutePath().normalize().parent.resolve("maptools-temp")
}
