package de.arxsilex.maptools.converter.core.elevation

import de.arxsilex.maptools.converter.core.ElevationMetadata
import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.InputFormat
import org.apache.sis.geometry.Envelopes
import org.apache.sis.referencing.CommonCRS
import org.apache.sis.storage.Aggregate
import org.apache.sis.storage.DataStores
import org.apache.sis.storage.GridCoverageResource
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.io.RandomAccessFile
import kotlin.io.path.fileSize
import kotlin.io.path.name
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sqrt

object ElevationMetadataReader {
    fun read(source: Path, format: InputFormat): ElevationMetadata = when (format) {
        InputFormat.ELEVATION_HGT -> readHgt(source)
        InputFormat.ELEVATION_ASC -> readAsciiGrid(source)
        InputFormat.ELEVATION_DTED -> readDted(source)
        InputFormat.ELEVATION_GEOTIFF -> readGeoTiff(source)
        else -> error("${format.displayName} ist kein Höhendatenformat.")
    }

    private fun readHgt(source: Path): ElevationMetadata {
        val match = HGT_NAME.matchEntire(source.name)
            ?: error("HGT-Dateinamen müssen dem Schema N50E008.hgt entsprechen.")
        val south = match.groupValues[2].toDouble() *
            if (match.groupValues[1].equals("S", ignoreCase = true)) -1.0 else 1.0
        val west = match.groupValues[4].toDouble() *
            if (match.groupValues[3].equals("W", ignoreCase = true)) -1.0 else 1.0
        val samples = source.fileSize().also {
            require(it > 0L && it % 2L == 0L) { "Die HGT-Dateigröße ist ungültig." }
        } / 2L
        val dimension = sqrt(samples.toDouble()).roundToInt()
        require(dimension.toLong() * dimension.toLong() == samples && dimension in HGT_DIMENSIONS) {
            "Die HGT-Datei muss ein Raster mit 1201 × 1201 oder 3601 × 3601 Werten enthalten."
        }
        return ElevationMetadata(
            bounds = GeoBounds(west, south, west + 1.0, south + 1.0),
            width = dimension,
            height = dimension,
            resolutionMeters = latitudeDegreesToMeters(1.0 / (dimension - 1)),
            noDataValue = -32768.0,
            coordinateReferenceSystem = "EPSG:4326",
        )
    }

    private fun readAsciiGrid(source: Path): ElevationMetadata {
        val values = linkedMapOf<String, String>()
        Files.newBufferedReader(source, StandardCharsets.US_ASCII).use { reader ->
            repeat(16) {
                val line = reader.readLine() ?: return@repeat
                val parts = line.trim().split(Regex("\\s+"), limit = 2)
                if (parts.size == 2) values[parts[0].lowercase()] = parts[1].trim()
            }
        }
        val width = values.requireInt("ncols")
        val height = values.requireInt("nrows")
        val cellSize = values.requireDouble("cellsize")
        require(width > 1 && height > 1 && cellSize > 0.0) { "Der ASCII-Grid-Header ist ungültig." }
        val west = values["xllcorner"]?.toDoubleOrNull()
            ?: values.requireDouble("xllcenter") - cellSize / 2.0
        val south = values["yllcorner"]?.toDoubleOrNull()
            ?: values.requireDouble("yllcenter") - cellSize / 2.0
        val east = west + width * cellSize
        val north = south + height * cellSize
        require(west in -180.0..<east && east <= 180.0 && south in -90.0..<north && north <= 90.0) {
            "Das ASCII Grid benötigt geografische WGS84-Koordinaten; projizierte Raster folgen in einem späteren Block."
        }
        return ElevationMetadata(
            bounds = GeoBounds(west, south, east, north),
            width = width,
            height = height,
            resolutionMeters = latitudeDegreesToMeters(cellSize),
            noDataValue = values["nodata_value"]?.toDoubleOrNull(),
            coordinateReferenceSystem = "EPSG:4326",
        )
    }

    private fun readDted(source: Path): ElevationMetadata {
        val header = ByteArray(DTED_UHL_SIZE)
        Files.newInputStream(source).use { input ->
            require(input.readNBytes(header, 0, header.size) == header.size) { "Der DTED-UHL-Header fehlt." }
        }
        require(String(header, 0, 3, StandardCharsets.US_ASCII) == "UHL") { "Ungültiger DTED-UHL-Header." }
        val west = parseDtedAngle(header, 4)
        val south = parseDtedAngle(header, 12)
        val lonInterval = asciiNumber(header, 20, 4) / 36_000.0
        val latInterval = asciiNumber(header, 24, 4) / 36_000.0
        val width = asciiNumber(header, 47, 4).roundToInt()
        val height = asciiNumber(header, 51, 4).roundToInt()
        require(width > 1 && height > 1 && lonInterval > 0.0 && latInterval > 0.0) {
            "Die DTED-Rasterangaben sind ungültig."
        }
        val recordSize = 8L + height.toLong() * 2L + 4L
        val expectedSize = DTED_DATA_OFFSET + width.toLong() * recordSize
        require(source.fileSize() >= expectedSize) {
            "Die DTED-Datei ist unvollständig: erwartet werden mindestens $expectedSize Byte."
        }
        val bounds = GeoBounds(
            west = west,
            south = south,
            east = west + lonInterval * (width - 1),
            north = south + latInterval * (height - 1),
        )
        return ElevationMetadata(
            bounds = bounds,
            width = width,
            height = height,
            resolutionMeters = latitudeDegreesToMeters(maxOf(lonInterval, latInterval)),
            noDataValue = -32767.0,
            coordinateReferenceSystem = "EPSG:4326",
        )
    }

    private fun readGeoTiff(source: Path): ElevationMetadata = DataStores.open(source.toFile()).use { store ->
        val resource = when (store) {
            is GridCoverageResource -> store
            is Aggregate -> store.components().filterIsInstance<GridCoverageResource>().firstOrNull()
            else -> null
        }
            ?: error("Die GeoTIFF-Datei enthält kein Höhenraster.")
        val geometry = resource.gridGeometry
        val extent = geometry.extent
        val width = extent.getSize(0).toInt()
        val height = extent.getSize(1).toInt()
        require(width > 1 && height > 1) { "Das GeoTIFF-Höhenraster ist leer." }
        val geographic = Envelopes.transform(geometry.envelope, CommonCRS.WGS84.normalizedGeographic())
        val bounds = GeoBounds(
            west = geographic.getMinimum(0),
            south = geographic.getMinimum(1),
            east = geographic.getMaximum(0),
            north = geographic.getMaximum(1),
        )
        val horizontalResolution = longitudeDegreesToMeters((bounds.east - bounds.west) / width, (bounds.north + bounds.south) / 2.0)
        val verticalResolution = latitudeDegreesToMeters((bounds.north - bounds.south) / height)
        val noData = resource.sampleDimensions.firstOrNull()?.background?.orElse(null)?.toDouble()
            ?: readGeoTiffGdalNoData(source)
        ElevationMetadata(
            bounds = bounds,
            width = width,
            height = height,
            resolutionMeters = maxOf(horizontalResolution, verticalResolution),
            noDataValue = noData,
            coordinateReferenceSystem = geometry.coordinateReferenceSystem?.name?.toString(),
        )
    }

    private fun Map<String, String>.requireInt(key: String): Int =
        get(key)?.toIntOrNull() ?: error("Im ASCII-Grid-Header fehlt '$key'.")

    private fun Map<String, String>.requireDouble(key: String): Double =
        get(key)?.toDoubleOrNull() ?: error("Im ASCII-Grid-Header fehlt '$key'.")

    private fun parseDtedAngle(bytes: ByteArray, offset: Int): Double {
        val value = String(bytes, offset, 8, StandardCharsets.US_ASCII)
        val degrees = value.substring(0, 3).trim().toDoubleOrNull() ?: error("Ungültige DTED-Koordinate.")
        val minutes = value.substring(3, 5).trim().toDoubleOrNull() ?: error("Ungültige DTED-Koordinate.")
        val seconds = value.substring(5, 7).trim().toDoubleOrNull() ?: error("Ungültige DTED-Koordinate.")
        val unsigned = degrees + minutes / 60.0 + seconds / 3600.0
        return if (value[7] == 'S' || value[7] == 'W') -unsigned else unsigned
    }

    private fun asciiNumber(bytes: ByteArray, offset: Int, length: Int): Double =
        String(bytes, offset, length, StandardCharsets.US_ASCII).trim().toDoubleOrNull()
            ?: error("Ungültiger Zahlenwert im DTED-UHL-Header.")

    private fun latitudeDegreesToMeters(degrees: Double): Double = degrees * 111_320.0

    private fun longitudeDegreesToMeters(degrees: Double, latitude: Double): Double =
        degrees * 111_320.0 * cos(latitude * PI / 180.0)

    private val HGT_NAME = Regex("(?i)^([NS])(\\d{2})([EW])(\\d{3})\\.hgt$")
    private val HGT_DIMENSIONS = setOf(1201, 3601)
    private const val DTED_UHL_SIZE = 80
    private const val DTED_DATA_OFFSET = 80L + 648L + 2700L
}

internal fun readGeoTiffGdalNoData(source: Path): Double? = runCatching {
    RandomAccessFile(source.toFile(), "r").use { file ->
        val byteOrder = ByteArray(2).also(file::readFully)
        val littleEndian = when (String(byteOrder, StandardCharsets.US_ASCII)) {
            "II" -> true
            "MM" -> false
            else -> return@use null
        }
        fun readUnsignedShort(): Int {
            val first = file.readUnsignedByte()
            val second = file.readUnsignedByte()
            return if (littleEndian) first or (second shl 8) else (first shl 8) or second
        }
        fun readUnsignedInt(): Long {
            val bytes = LongArray(4) { file.readUnsignedByte().toLong() }
            return if (littleEndian) {
                bytes[0] or (bytes[1] shl 8) or (bytes[2] shl 16) or (bytes[3] shl 24)
            } else {
                (bytes[0] shl 24) or (bytes[1] shl 16) or (bytes[2] shl 8) or bytes[3]
            }
        }
        if (readUnsignedShort() != 42) return@use null
        val ifdOffset = readUnsignedInt()
        if (ifdOffset !in 8 until file.length()) return@use null
        file.seek(ifdOffset)
        val entryCount = readUnsignedShort()
        if (entryCount !in 1..4096) return@use null
        repeat(entryCount) {
            val tag = readUnsignedShort()
            val type = readUnsignedShort()
            val count = readUnsignedInt()
            val valueFieldPosition = file.filePointer
            val valueOrOffset = readUnsignedInt()
            if (tag == 42113 && type == 2 && count in 1..64) {
                val returnPosition = file.filePointer
                file.seek(if (count <= 4) valueFieldPosition else valueOrOffset)
                val text = ByteArray(count.toInt()).also(file::readFully)
                    .toString(StandardCharsets.US_ASCII)
                    .trim('\u0000', ' ')
                    .lowercase()
                file.seek(returnPosition)
                return@use when (text) {
                    "nan", "+nan", "-nan" -> Double.NaN
                    "inf", "+inf", "infinity", "+infinity" -> Double.POSITIVE_INFINITY
                    "-inf", "-infinity" -> Double.NEGATIVE_INFINITY
                    else -> text.toDoubleOrNull()
                }
            }
        }
        null
    }
}.getOrNull()
