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

import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.unionOrNull
import java.nio.file.Path
import kotlin.math.PI
import kotlin.math.atan
import kotlin.math.ceil
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.roundToInt
import kotlin.math.tan

/** A bounded, Web-Mercator-aligned DEM raster used only by the interactive preview. */
data class ElevationPreviewRaster(
    val bounds: GeoBounds,
    val width: Int,
    val height: Int,
    val elevations: FloatArray,
    val minimumElevationMeters: Float,
    val maximumElevationMeters: Float,
    val populatedCells: Int,
) {
    init {
        require(width > 1 && height > 1)
        require(elevations.size == width * height)
        require(populatedCells in 1..elevations.size)
        require(minimumElevationMeters.isFinite() && maximumElevationMeters.isFinite())
        require(minimumElevationMeters <= maximumElevationMeters)
    }

    fun elevationAt(column: Int, row: Int): Float = elevations[row * width + column]
}

/**
 * Samples arbitrary elevation inputs into a small raster. Original source resolution therefore
 * cannot make the UI preview grow without bounds, while export continues to use the full data.
 */
object ElevationPreviewBuilder {
    const val DEFAULT_MAX_WIDTH = 720
    const val DEFAULT_MAX_HEIGHT = 720

    fun build(
        inputs: List<Path>,
        requestedBounds: GeoBounds,
        maxWidth: Int = DEFAULT_MAX_WIDTH,
        maxHeight: Int = DEFAULT_MAX_HEIGHT,
        onProgress: (completedRows: Int, totalRows: Int) -> Unit = { _, _ -> },
        isCancelled: () -> Boolean = { false },
    ): ElevationPreviewRaster {
        require(inputs.isNotEmpty()) { "Mindestens eine Höhendatenquelle ist erforderlich." }
        require(maxWidth >= 2 && maxHeight >= 2) { "Die Höhenvorschau ist zu klein." }
        val sources = inputs.map(ElevationConverter::openSource).sortedBy(ElevationSource::cellSizeDegrees)
        try {
            val sourceBounds = sources.map(ElevationSource::bounds).unionOrNull()
                ?: error("Die Höhendaten besitzen keine geografischen Grenzen.")
            val previewBounds = intersect(sourceBounds, requestedBounds)
                ?: error("Die Höhendaten überschneiden den Vorschaubereich nicht.")
            val dimensions = previewDimensions(previewBounds, maxWidth, maxHeight)
            val elevations = FloatArray(dimensions.first * dimensions.second) { Float.NaN }
            val northMercator = mercatorY(previewBounds.north)
            val southMercator = mercatorY(previewBounds.south)
            var minimum = Float.POSITIVE_INFINITY
            var maximum = Float.NEGATIVE_INFINITY
            var populated = 0

            for (row in 0 until dimensions.second) {
                if (isCancelled()) throw InterruptedException("Der Aufbau der Höhenvorschau wurde abgebrochen.")
                val verticalFraction = (row + 0.5) / dimensions.second
                val latitude = inverseMercatorY(
                    northMercator - verticalFraction * (northMercator - southMercator),
                )
                for (column in 0 until dimensions.first) {
                    val longitude = previewBounds.west +
                        (column + 0.5) / dimensions.first * (previewBounds.east - previewBounds.west)
                    val value = sources.firstNotNullOfOrNull { source ->
                        source.takeIf { it.bounds.contains(longitude, latitude) }?.sample(longitude, latitude)
                    }?.takeIf(Double::isFinite)?.toFloat() ?: continue
                    elevations[row * dimensions.first + column] = value
                    minimum = minOf(minimum, value)
                    maximum = maxOf(maximum, value)
                    populated++
                }
                onProgress(row + 1, dimensions.second)
            }
            require(populated > 0) { "Die Höhendaten enthalten im Vorschaubereich keine gültigen Werte." }
            return ElevationPreviewRaster(
                bounds = previewBounds,
                width = dimensions.first,
                height = dimensions.second,
                elevations = elevations,
                minimumElevationMeters = minimum,
                maximumElevationMeters = maximum,
                populatedCells = populated,
            )
        } finally {
            sources.forEach { runCatching { it.close() } }
        }
    }

    internal fun previewDimensions(bounds: GeoBounds, maxWidth: Int, maxHeight: Int): Pair<Int, Int> {
        val longitudeSpanRadians = Math.toRadians(bounds.east - bounds.west)
        val projectedHeight = mercatorY(bounds.north) - mercatorY(bounds.south)
        val aspect = (longitudeSpanRadians / projectedHeight).takeIf { it.isFinite() && it > 0.0 } ?: 1.0
        return if (aspect >= maxWidth.toDouble() / maxHeight) {
            maxWidth to ceil(maxWidth / aspect).roundToInt().coerceIn(2, maxHeight)
        } else {
            ceil(maxHeight * aspect).roundToInt().coerceIn(2, maxWidth) to maxHeight
        }
    }

    private fun intersect(first: GeoBounds, second: GeoBounds): GeoBounds? {
        val west = maxOf(first.west, second.west)
        val south = maxOf(first.south, second.south)
        val east = minOf(first.east, second.east)
        val north = minOf(first.north, second.north)
        return if (west < east && south < north) GeoBounds(west, south, east, north) else null
    }

    private fun GeoBounds.contains(longitude: Double, latitude: Double): Boolean =
        longitude in west..east && latitude in south..north

    private fun mercatorY(latitude: Double): Double {
        val clipped = Math.toRadians(latitude.coerceIn(-MAX_MERCATOR_LATITUDE, MAX_MERCATOR_LATITUDE))
        return ln(tan(PI / 4.0 + clipped / 2.0))
    }

    private fun inverseMercatorY(value: Double): Double =
        Math.toDegrees(2.0 * atan(exp(value)) - PI / 2.0)

    private const val MAX_MERCATOR_LATITUDE = 85.05112878
}
