package de.arxsilex.maptools.converter.desktop

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.NormalizedMapArea
import kotlin.math.PI
import kotlin.math.atan
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.tan

/**
 * Web-Mercator projection used only by the interactive preview.
 *
 * Selection shapes intentionally remain normalized WGS-84 coordinates so export and persistence
 * stay unchanged. This projection supplies a shared, conformal coordinate space for map geometry,
 * overlays, hit testing and pointer interaction, preventing independent X/Y stretching.
 */
internal class MapCanvasProjection(val sourceBounds: GeoBounds) {
    private val westRadians = sourceBounds.west.toRadians()
    private val longitudeSpanRadians = (sourceBounds.east - sourceBounds.west).toRadians()
    private val northMercator = mercatorY(sourceBounds.north)
    private val southMercator = mercatorY(sourceBounds.south)

    val sourceWorldBounds = Rect(
        left = 0f,
        top = 0f,
        right = 1f,
        bottom = ((northMercator - southMercator) / longitudeSpanRadians).toFloat(),
    )

    fun geoToWorld(longitude: Double, latitude: Double): Offset = Offset(
        x = ((longitude.toRadians() - westRadians) / longitudeSpanRadians).toFloat(),
        y = ((northMercator - mercatorY(latitude)) / longitudeSpanRadians).toFloat(),
    )

    fun normalizedToWorld(point: Offset): Offset {
        val longitude = sourceBounds.west + (sourceBounds.east - sourceBounds.west) * point.x
        val latitude = sourceBounds.north - (sourceBounds.north - sourceBounds.south) * point.y
        return geoToWorld(longitude, latitude)
    }

    fun worldToNormalized(point: Offset, clampToSource: Boolean = true): Offset {
        val longitude = (westRadians + point.x * longitudeSpanRadians).toDegrees()
        val latitude = inverseMercatorY(northMercator - point.y * longitudeSpanRadians)
        val normalized = Offset(
            x = ((longitude - sourceBounds.west) / (sourceBounds.east - sourceBounds.west)).toFloat(),
            y = ((sourceBounds.north - latitude) / (sourceBounds.north - sourceBounds.south)).toFloat(),
        )
        return if (clampToSource) {
            Offset(normalized.x.coerceIn(0f, 1f), normalized.y.coerceIn(0f, 1f))
        } else {
            normalized
        }
    }

    private fun Double.toRadians(): Double = this * PI / 180.0
    private fun Double.toDegrees(): Double = this * 180.0 / PI

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

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

    private companion object {
        const val MAX_MERCATOR_LATITUDE = 85.05112878
    }
}

/** Aspect-correct view of one normalized viewport on a concrete canvas. */
internal class MapCanvasTransform(
    private val projection: MapCanvasProjection,
    private val viewport: MapViewport,
    private val canvasWidth: Float,
    private val canvasHeight: Float,
) {
    private val projectedViewport: Rect = run {
        val topLeft = projection.normalizedToWorld(Offset(viewport.left, viewport.top))
        val bottomRight = projection.normalizedToWorld(Offset(viewport.right, viewport.bottom))
        val rawWidth = (bottomRight.x - topLeft.x).coerceAtLeast(MIN_WORLD_SPAN)
        val rawHeight = (bottomRight.y - topLeft.y).coerceAtLeast(MIN_WORLD_SPAN)
        val canvasAspect = canvasWidth.coerceAtLeast(1f) / canvasHeight.coerceAtLeast(1f)
        val rawAspect = rawWidth / rawHeight
        val centerX = (topLeft.x + bottomRight.x) / 2f
        val centerY = (topLeft.y + bottomRight.y) / 2f
        val fittedWidth = if (rawAspect < canvasAspect) rawHeight * canvasAspect else rawWidth
        val fittedHeight = if (rawAspect > canvasAspect) rawWidth / canvasAspect else rawHeight
        Rect(
            left = centerX - fittedWidth / 2f,
            top = centerY - fittedHeight / 2f,
            right = centerX + fittedWidth / 2f,
            bottom = centerY + fittedHeight / 2f,
        )
    }

    fun geoToScreen(longitude: Double, latitude: Double): Offset = worldToScreen(
        projection.geoToWorld(longitude, latitude),
    )

    fun normalizedToScreen(point: Offset): Offset = worldToScreen(projection.normalizedToWorld(point))

    fun screenToNormalized(point: Offset, clampToSource: Boolean = true): Offset {
        val relativeX = point.x / canvasWidth.coerceAtLeast(1f)
        val relativeY = point.y / canvasHeight.coerceAtLeast(1f)
        return projection.worldToNormalized(
            Offset(
                projectedViewport.left + relativeX * projectedViewport.width,
                projectedViewport.top + relativeY * projectedViewport.height,
            ),
            clampToSource,
        )
    }

    fun normalizedAreaToScreenRect(area: NormalizedMapArea): Rect {
        val topLeft = normalizedToScreen(Offset(area.left, area.top))
        val bottomRight = normalizedToScreen(Offset(area.right, area.bottom))
        return Rect(topLeft, bottomRight)
    }

    fun visibleNormalizedArea(): NormalizedMapArea {
        val topLeft = projection.worldToNormalized(projectedViewport.topLeft, clampToSource = true)
        val bottomRight = projection.worldToNormalized(projectedViewport.bottomRight, clampToSource = true)
        return NormalizedMapArea(
            left = minOf(topLeft.x, bottomRight.x),
            top = minOf(topLeft.y, bottomRight.y),
            right = maxOf(topLeft.x, bottomRight.x),
            bottom = maxOf(topLeft.y, bottomRight.y),
        )
    }

    /** Normalized viewport movement matching a retained map translation in screen pixels. */
    fun panDeltaForScreenTranslation(translation: Offset): Offset {
        val center = Offset(canvasWidth / 2f, canvasHeight / 2f)
        val oldCenter = screenToNormalized(center, clampToSource = false)
        val newCenter = screenToNormalized(center - translation, clampToSource = false)
        return newCenter - oldCenter
    }

    private fun worldToScreen(point: Offset): Offset = Offset(
        x = (point.x - projectedViewport.left) / projectedViewport.width * canvasWidth,
        y = (point.y - projectedViewport.top) / projectedViewport.height * canvasHeight,
    )

    private companion object {
        const val MIN_WORLD_SPAN = 0.000_000_1f
    }
}
