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

import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.GeoSelectionRegion
import de.arxsilex.maptools.converter.core.mtci.MtciShardWriter
import de.arxsilex.maptools.converter.core.pbf.MtciPbfWriteProgress
import de.arxsilex.maptools.converter.core.pbf.MtciPbfWriteReport
import de.arxsilex.maptools.converter.core.pbf.MtciPbfWriter
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.OsmRelationMember
import de.arxsilex.maptools.converter.core.pbf.OsmRelationMemberType
import java.nio.file.Files
import java.nio.file.Path
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.util.Comparator
import org.mapsforge.core.model.LatLong
import org.mapsforge.core.model.Tag
import org.mapsforge.map.datastore.MapReadResult
import org.mapsforge.map.datastore.Way

/**
 * Converts Mapsforge features into a temporary sharded MTCI stream while retaining only one map
 * tile in memory. A tiny SQLite table keeps duplicate hashes on disk because Mapsforge ways can be
 * returned by several adjacent tiles or overlapping user selections.
 */
internal class MapsforgeDiskMaterializer private constructor(
    private val workDirectory: Path,
    private val dataDirectory: Path,
    private val connection: Connection,
    private val insertSeen: PreparedStatement,
    private val writer: MtciShardWriter,
    namespace: Int,
) : AutoCloseable {
    private val namespaceBase = -(namespace + 1L) * SYNTHETIC_NAMESPACE_SIZE
    private var nextNodeOrdinal = 0L
    private var nextWayOrdinal = 0L
    private var nextRelationOrdinal = 0L
    private var writerClosed = false
    private var closed = false

    val writtenObjectCount: Long
        get() = nextNodeOrdinal + nextWayOrdinal + nextRelationOrdinal

    val nodeCount: Long
        get() = nextNodeOrdinal

    val wayCount: Long
        get() = nextWayOrdinal

    val relationCount: Long
        get() = nextRelationOrdinal

    fun add(
        result: MapReadResult,
        regions: List<GeoSelectionRegion>,
        isCancelled: () -> Boolean,
    ) {
        check(!writerClosed) { "Die Mapsforge-Zwischendaten wurden bereits abgeschlossen." }
        connection.autoCommit = false
        try {
            result.pois.forEachIndexed { index, poi ->
                if (index % 10_000 == 0) checkCancellation(isCancelled)
                if (regions.none { it.contains(poi.position.latitude, poi.position.longitude) }) {
                    return@forEachIndexed
                }
                val tags = poi.tags.normalized(poi.layer)
                if (!markSeen(POINT_KIND, featureKey(tags) { putCoordinate(poi.position) })) {
                    return@forEachIndexed
                }
                writer.writeNode(
                    OsmPreviewNode(
                        id = nodeId(nextNodeOrdinal++),
                        latitude = poi.position.latitude,
                        longitude = poi.position.longitude,
                        tags = tags,
                    ),
                )
                requireNamespaceCapacity(nextNodeOrdinal)
            }

            result.ways.forEachIndexed { index, way ->
                if (index % 2_000 == 0) checkCancellation(isCancelled)
                if (way.latLongs.none { coordinates ->
                        coordinates.any { point ->
                            regions.any { it.contains(point.latitude, point.longitude) }
                        }
                    }
                ) {
                    return@forEachIndexed
                }
                val tags = way.tags.normalized(way.layer)
                val validParts = way.latLongs.filter { it.size >= 2 }
                if (validParts.isEmpty() || !markSeen(WAY_KIND, way.featureKey(tags))) {
                    return@forEachIndexed
                }
                val multipolygon = validParts.size > 1 && validParts.all { it.isClosed() } && tags.isArea()
                val partIds = ArrayList<Long>(validParts.size)
                validParts.forEach { coordinates ->
                    val nodeIds = LongArray(coordinates.size)
                    coordinates.forEachIndexed { vertex, coordinate ->
                        val id = nodeId(nextNodeOrdinal++)
                        nodeIds[vertex] = id
                        writer.writeNode(OsmPreviewNode(id, coordinate.latitude, coordinate.longitude, emptyMap()))
                    }
                    requireNamespaceCapacity(nextNodeOrdinal)
                    val wayId = wayId(nextWayOrdinal++)
                    partIds += wayId
                    writer.writeWay(
                        OsmPreviewWay(
                            id = wayId,
                            nodeIds = nodeIds,
                            tags = if (multipolygon) emptyMap() else tags,
                        ),
                    )
                    requireNamespaceCapacity(nextWayOrdinal)
                }
                if (multipolygon) {
                    writer.writeRelation(
                        OsmPreviewRelation(
                            id = relationId(nextRelationOrdinal++),
                            members = partIds.mapIndexed { partIndex, partId ->
                                OsmRelationMember(
                                    id = partId,
                                    type = OsmRelationMemberType.WAY,
                                    role = if (partIndex == 0) "outer" else "inner",
                                )
                            },
                            tags = tags + ("type" to "multipolygon"),
                        ),
                    )
                    requireNamespaceCapacity(nextRelationOrdinal)
                }
            }
            connection.commit()
        } catch (exception: Throwable) {
            runCatching(connection::rollback)
            throw exception
        } finally {
            connection.autoCommit = true
        }
    }

    fun writeToPbf(
        output: Path,
        bounds: GeoBounds,
        onProgress: (MtciPbfWriteProgress) -> Unit,
        isCancelled: () -> Boolean,
    ): MtciPbfWriteReport {
        closeWriter()
        return MtciPbfWriter.write(
            sourceDirectory = dataDirectory,
            outputPath = output,
            bounds = bounds,
            onProgress = onProgress,
            isCancelled = isCancelled,
        )
    }

    private fun markSeen(kind: Int, key: FeatureKey): Boolean {
        insertSeen.setInt(1, kind)
        insertSeen.setLong(2, key.first)
        insertSeen.setLong(3, key.second)
        return insertSeen.executeUpdate() == 1
    }

    private fun nodeId(ordinal: Long): Long = namespaceBase + ordinal
    private fun wayId(ordinal: Long): Long = namespaceBase + ordinal
    private fun relationId(ordinal: Long): Long = namespaceBase + ordinal

    private fun requireNamespaceCapacity(nextOrdinal: Long) {
        require(nextOrdinal < SYNTHETIC_NAMESPACE_SIZE) {
            "Die Mapsforge-Quelle enthält zu viele Geometrieobjekte für einen Exportauftrag."
        }
    }

    private fun closeWriter() {
        if (!writerClosed) {
            writerClosed = true
            writer.close()
        }
    }

    override fun close() {
        if (!closed) {
            closed = true
            var firstFailure: Throwable? = null
            runCatching(::closeWriter).onFailure { firstFailure = it }
            runCatching(insertSeen::close).onFailure { if (firstFailure == null) firstFailure = it }
            runCatching(connection::close).onFailure { if (firstFailure == null) firstFailure = it }
            runCatching { deleteTree(workDirectory) }.onFailure { if (firstFailure == null) firstFailure = it }
            firstFailure?.let { throw it }
        }
    }

    companion object {
        private const val POINT_KIND = 1
        private const val WAY_KIND = 2
        private const val SYNTHETIC_NAMESPACE_SIZE = 1_000_000_000_000L
        private const val SHARD_COUNT = 64

        fun create(temporaryDirectory: Path, namespace: Int): MapsforgeDiskMaterializer {
            require(namespace in 0..999) { "Ungültiger Mapsforge-ID-Namensraum: $namespace" }
            Files.createDirectories(temporaryDirectory)
            val workDirectory = Files.createTempDirectory(temporaryDirectory, "mapsforge-materialization-")
            val dataDirectory = Files.createDirectory(workDirectory.resolve("mtci"))
            var connection: Connection? = null
            return try {
                Class.forName("org.sqlite.JDBC")
                connection = DriverManager.getConnection(
                    "jdbc:sqlite:${workDirectory.resolve("seen.sqlite").toAbsolutePath()}",
                )
                connection.createStatement().use { statement ->
                    statement.execute("PRAGMA journal_mode=OFF")
                    statement.execute("PRAGMA synchronous=OFF")
                    statement.execute("PRAGMA temp_store=FILE")
                    statement.execute("PRAGMA cache_size=-16384")
                    statement.execute(
                        "CREATE TABLE seen (" +
                            "kind INTEGER NOT NULL, hash_a INTEGER NOT NULL, hash_b INTEGER NOT NULL, " +
                            "PRIMARY KEY (kind, hash_a, hash_b)) WITHOUT ROWID",
                    )
                }
                val insertSeen = connection.prepareStatement(
                    "INSERT OR IGNORE INTO seen(kind, hash_a, hash_b) VALUES (?, ?, ?)",
                )
                MapsforgeDiskMaterializer(
                    workDirectory,
                    dataDirectory,
                    connection,
                    insertSeen,
                    MtciShardWriter(dataDirectory, SHARD_COUNT),
                    namespace,
                )
            } catch (exception: Throwable) {
                runCatching { connection?.close() }
                runCatching { deleteTree(workDirectory) }
                throw exception
            }
        }

        private fun deleteTree(root: Path) {
            if (!Files.exists(root)) return
            Files.walk(root).use { paths ->
                paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists)
            }
        }
    }
}

private data class FeatureKey(val first: Long, val second: Long)

private class FeatureKeyBuilder {
    private var first = -0x340d631b7bdddcdbL
    private var second = 0x6a09e667f3bcc909L

    fun putLong(value: Long) {
        var remaining = value
        repeat(Long.SIZE_BYTES) {
            val byte = remaining and 0xffL
            first = (first xor byte) * 0x100000001b3L
            second = (second xor (byte + 0x9dL)) * -0x61c8864680b583ebL
            remaining = remaining ushr 8
        }
    }

    fun putString(value: String) {
        value.forEach { putLong(it.code.toLong()) }
        putLong(-1L)
    }

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

    fun putTags(tags: Map<String, String>) {
        tags.entries.sortedWith(compareBy({ it.key }, { it.value })).forEach { (key, value) ->
            putString(key)
            putString(value)
        }
    }

    fun build(): FeatureKey = FeatureKey(first, second)
}

private inline fun featureKey(
    tags: Map<String, String>,
    populate: FeatureKeyBuilder.() -> Unit,
): FeatureKey = FeatureKeyBuilder().apply {
    populate()
    putTags(tags)
}.build()

private fun Way.featureKey(tags: Map<String, String>): FeatureKey = featureKey(tags) {
    latLongs.forEach { coordinates ->
        putLong(coordinates.size.toLong())
        coordinates.forEach(::putCoordinate)
    }
}

private fun List<Tag>.normalized(layer: Byte): Map<String, String> {
    val result = linkedMapOf<String, String>()
    forEach { tag ->
        if (tag.key.isNotBlank() && tag.value.isNotBlank()) result[tag.key] = tag.value
    }
    if (layer.toInt() != 0 && "layer" !in result) result["layer"] = layer.toString()
    return result
}

private fun Array<LatLong>.isClosed(): Boolean = size >= 4 && first() == last()

private fun Map<String, String>.isArea(): Boolean =
    this["area"] == "yes" || keys.any { it in DISK_AREA_KEYS }

private val DISK_AREA_KEYS = setOf(
    "aeroway", "amenity", "boundary", "building", "historic", "landuse", "leisure",
    "man_made", "military", "natural", "place", "shop", "tourism", "water", "waterway",
)

private fun checkCancellation(isCancelled: () -> Boolean) {
    if (isCancelled()) throw java.io.IOException("Mapsforge-Export wurde abgebrochen.")
}
