package de.arxsilex.maptools.converter.desktop

import de.arxsilex.maptools.converter.core.GeoBounds
import de.arxsilex.maptools.converter.core.GeoCircleRegion
import de.arxsilex.maptools.converter.core.GeoRectangleRegion
import de.arxsilex.maptools.converter.core.GeoSelectionRegion
import de.arxsilex.maptools.converter.core.ContentSelection
import de.arxsilex.maptools.converter.core.ElevationOutputFormat
import de.arxsilex.maptools.converter.core.MapFormatVersion
import de.arxsilex.maptools.converter.core.MapsforgeProcessorPolicy
import de.arxsilex.maptools.converter.core.MapsforgeProcessorType
import de.arxsilex.maptools.converter.core.PoiSchemaVersion
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.ArrayDeque
import java.util.Comparator
import java.util.UUID
import kotlinx.coroutines.CancellationException

internal data class WorkerResult(val outputs: Map<Path, Long>, val logPath: Path)

internal class WorkerExecutionException(message: String, val logPath: Path, cause: Throwable? = null) :
    IllegalStateException(message, cause)

internal class WorkerCancelledException(cause: Throwable? = null) :
    CancellationException("Der Export wurde abgebrochen.") {
    init {
        if (cause != null) initCause(cause)
    }
}

internal object WorkerLauncher {
    fun elevation(
        inputs: List<Path>,
        output: Path,
        outputFormat: ElevationOutputFormat,
        areas: List<GeoSelectionRegion>,
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        logDirectory: Path = PlatformDirectories.logDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult {
        require(inputs.isNotEmpty()) { "Mindestens eine Höhendatenquelle ist erforderlich." }
        return runWorker(
            arguments = buildList {
                add("elevation")
                inputs.forEach { input -> addAll(listOf("--input", input.toString())) }
                addAll(
                    listOf(
                        "--output", output.toString(),
                        "--format", outputFormat.name,
                        "--temp-dir", temporaryDirectory.toString(),
                        "--areas", encodeAreas(areas),
                    ),
                )
            },
            outputs = listOf(output),
            logDirectory = logDirectory,
            onLogLine = onLogLine,
            isCancelled = isCancelled,
        )
    }

    fun convertPoi(
        input: Path,
        output: Path,
        sourceSchema: PoiSchemaVersion,
        targetSchema: PoiSchemaVersion,
        areas: List<GeoSelectionRegion>? = null,
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        logDirectory: Path = PlatformDirectories.logDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult = runWorker(
        arguments = buildList {
            addAll(
                listOf(
                    "poi-convert",
                    "--input", input.toString(),
                    "--output", output.toString(),
                    "--source-schema", sourceSchema.metadataVersion.toString(),
                    "--target-schema", targetSchema.metadataVersion.toString(),
                    "--temp-dir", temporaryDirectory.toString(),
                ),
            )
            areas?.let { addAll(listOf("--areas", encodeAreas(it))) }
        },
        outputs = listOf(output),
        logDirectory = logDirectory,
        onLogLine = onLogLine,
        isCancelled = isCancelled,
    )

    fun mergePoi(
        sources: List<Pair<Path, PoiSchemaVersion>>,
        output: Path,
        targetSchema: PoiSchemaVersion,
        areas: List<GeoSelectionRegion>,
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        logDirectory: Path = PlatformDirectories.logDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult {
        require(sources.isNotEmpty()) { "Mindestens eine POI-Quelle ist erforderlich." }
        return runWorker(
            arguments = buildList {
                add("poi-merge")
                sources.forEach { (input, schema) ->
                    addAll(listOf("--input", input.toString(), "--source-schema", schema.metadataVersion.toString()))
                }
                addAll(
                    listOf(
                        "--output", output.toString(),
                        "--target-schema", targetSchema.metadataVersion.toString(),
                        "--temp-dir", temporaryDirectory.toString(),
                        "--areas", encodeAreas(areas),
                    ),
                )
            },
            outputs = listOf(output),
            logDirectory = logDirectory,
            onLogLine = onLogLine,
            isCancelled = isCancelled,
        )
    }

    fun materializeMapsforgeSource(
        input: Path,
        output: Path,
        bounds: GeoBounds,
        namespace: Int,
        areas: List<GeoSelectionRegion> = listOf(GeoRectangleRegion(bounds)),
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult = runWorker(
        arguments = listOf(
            "mapsforge-source",
            "--input", input.toString(),
            "--output", output.toString(),
            "--temp-dir", temporaryDirectory.toString(),
            "--namespace", namespace.toString(),
            "--areas", encodeAreas(areas),
        ),
        outputs = listOf(output),
        onLogLine = onLogLine,
        isCancelled = isCancelled,
    )

    fun cropPbf(
        input: Path,
        output: Path,
        bounds: GeoBounds,
        areas: List<GeoSelectionRegion> = listOf(GeoRectangleRegion(bounds)),
        cacheDirectory: Path = PlatformDirectories.conversionCacheDirectory(),
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult {
        return runWorker(
            arguments = listOf(
                "crop",
                "--input", input.toString(), "--output", output.toString(),
                "--cache-dir", cacheDirectory.toString(),
                "--temp-dir", temporaryDirectory.toString(),
                "--areas", encodeAreas(areas),
            ),
            outputs = listOf(output),
            onLogLine = onLogLine,
            isCancelled = isCancelled,
        )
    }

    fun combinePbf(
        inputs: List<Path>,
        output: Path,
        bounds: GeoBounds,
        areas: List<GeoSelectionRegion> = listOf(GeoRectangleRegion(bounds)),
        preCroppedInputs: Set<Path> = emptySet(),
        cacheDirectory: Path = PlatformDirectories.conversionCacheDirectory(),
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult {
        require(inputs.size >= 2) { "Zum Kombinieren werden mindestens zwei Quellen benötigt." }
        return runWorker(
            arguments = buildList {
                add("combine")
                inputs.forEach { input ->
                    add("--input")
                    add(input.toString())
                }
                preCroppedInputs.forEach { input ->
                    require(input in inputs) { "Eine bereits zugeschnittene Quelle fehlt in den Eingaben." }
                    add("--pre-cropped-input")
                    add(input.toString())
                }
                addAll(
                    listOf(
                        "--output", output.toString(),
                        "--cache-dir", cacheDirectory.toString(),
                        "--temp-dir", temporaryDirectory.toString(),
                        "--areas", encodeAreas(areas),
                    ),
                )
            },
            outputs = listOf(output),
            onLogLine = onLogLine,
            isCancelled = isCancelled,
        )
    }

    fun mapsforge(
        input: Path,
        mapOutput: Path?,
        poiOutput: Path?,
        bounds: GeoBounds,
        content: ContentSelection,
        mapFormatVersion: MapFormatVersion,
        poiSchemaVersion: PoiSchemaVersion,
        preferDiskProcessor: Boolean = false,
        temporaryDirectory: Path = PlatformDirectories.sessionTemporaryDirectory(),
        logDirectory: Path = PlatformDirectories.logDirectory(),
        onLogLine: (String) -> Unit = {},
        isCancelled: () -> Boolean = { false },
    ): WorkerResult {
        val primaryProcessor = MapsforgeProcessorPolicy.select(Files.size(input), preferDiskProcessor)
        fun arguments(processorType: MapsforgeProcessorType): List<String> = listOf(
            "mapsforge",
            "--input", input.toString(),
            "--temp-dir", temporaryDirectory.toString(),
            "--processor", processorType.cliValue,
            "--map-output", mapOutput?.toString() ?: "-",
            "--poi-output", poiOutput?.toString() ?: "-",
            "--map-format", mapFormatVersion.metadataVersion.toString(),
            "--poi-schema", poiSchemaVersion.metadataVersion.toString(),
            "--west", bounds.west.toString(), "--south", bounds.south.toString(),
            "--east", bounds.east.toString(), "--north", bounds.north.toString(),
            "--roads", content.roads.toString(), "--paths", content.pathsAndTrails.toString(),
            "--buildings", content.buildings.toString(), "--land-water", content.landAndWater.toString(),
            "--pois", content.pointsOfInterest.toString(), "--metadata", content.sourceMetadata.toString(),
        )
        val outputs = listOfNotNull(mapOutput, poiOutput)
        return try {
            runWorker(
                arguments(primaryProcessor), outputs, logDirectory = logDirectory,
                onLogLine = onLogLine, isCancelled = isCancelled,
            )
        } catch (exception: WorkerExecutionException) {
            val fallbackProcessor = mapOutput?.let {
                mapsforgeFallbackProcessor(
                    primaryProcessor = primaryProcessor,
                    failureLog = runCatching { Files.readString(exception.logPath) }.getOrDefault(""),
                    allowRamFallback = !preferDiskProcessor,
                )
            } ?: throw exception
            runWorker(
                arguments(fallbackProcessor), outputs, logDirectory = logDirectory,
                onLogLine = onLogLine, isCancelled = isCancelled,
            )
        }
    }

    private fun encodeAreas(areas: List<GeoSelectionRegion>): String {
        require(areas.isNotEmpty()) { "Mindestens ein Auswahlbereich ist erforderlich." }
        return areas.joinToString(";") {
            when (it) {
                is GeoRectangleRegion -> "r,${it.bounds.west},${it.bounds.south},${it.bounds.east},${it.bounds.north}"
                is GeoCircleRegion -> "c,${it.centerLatitude},${it.centerLongitude},${it.radiusKilometers}"
            }
        }
    }

    private fun runWorker(
        arguments: List<String>,
        outputs: List<Path>,
        logDirectory: Path = PlatformDirectories.logDirectory(),
        onLogLine: (String) -> Unit,
        isCancelled: () -> Boolean,
    ): WorkerResult {
        require(outputs.isNotEmpty()) { "Der Worker benötigt mindestens eine Ausgabedatei." }
        outputs.forEach { output ->
            require(!Files.exists(output)) { "Die Zieldatei existiert bereits: $output" }
        }
        val command = PackagedWorkerResolver.find()?.let { workerExecutable ->
            listOf(workerExecutable.toString()) + arguments
        } ?: developmentWorkerCommand(arguments)
        val logPath = createLogPath(logDirectory)
        val logWriter = try {
            Files.newBufferedWriter(
                logPath,
                Charsets.UTF_8,
                StandardOpenOption.CREATE_NEW,
                StandardOpenOption.WRITE,
            )
        } catch (exception: Exception) {
            throw WorkerExecutionException("Das Diagnoseprotokoll konnte nicht angelegt werden.", logPath, exception)
        }
        val process = try {
            ProcessBuilder(command).apply {
                redirectErrorStream(true)
                workerTemporaryDirectory(arguments)?.let { temporaryDirectory ->
                    Files.createDirectories(temporaryDirectory)
                    environment()["TEMP"] = temporaryDirectory.toString()
                    environment()["TMP"] = temporaryDirectory.toString()
                    environment()["TMPDIR"] = temporaryDirectory.toString()
                }
            }.start()
        } catch (exception: Exception) {
            runCatching {
                logWriter.appendLine("MTC-LAUNCH status=failed executable=${command.firstOrNull() ?: "-"}")
                logWriter.appendLine(
                    "MTC-LAUNCH exception=${exception.javaClass.name} message=${exception.message ?: "-"}",
                )
                logWriter.flush()
            }
            logWriter.close()
            val detail = exception.message?.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty()
            throw WorkerExecutionException(
                "Der Worker konnte nicht gestartet werden$detail. Details stehen im Protokoll.",
                logPath,
                exception,
            )
        }
        val logTail = ArrayDeque<String>(200)
        val logThread = Thread({
            logWriter.use { writer ->
                process.inputStream.bufferedReader().useLines { lines ->
                    lines.forEach { line ->
                        if (logTail.size == 200) logTail.removeFirst()
                        logTail.addLast(line)
                        writer.appendLine(line)
                        writer.flush()
                        runCatching { onLogLine(line) }
                    }
                }
            }
        }, "maptools-worker-log").apply { start() }
        val childProcesses = linkedSetOf<ProcessHandle>()
        fun captureChildProcesses() {
            runCatching {
                process.toHandle().descendants().forEach(childProcesses::add)
            }
        }
        fun stopProcessTree() {
            captureChildProcesses()
            childProcesses.toList().asReversed().forEach { handle ->
                if (handle.isAlive) runCatching { handle.destroyForcibly() }
            }
            if (process.isAlive) process.destroyForcibly()
        }
        var completed = false
        try {
            // The Windows jpackage GUI launcher creates a second process and may report its own
            // completion before that JVM worker has closed stdout. Keep consuming the inherited
            // pipe until EOF so the UI waits for the actual conversion, not just for the launcher.
            while (process.isAlive || logThread.isAlive) {
                captureChildProcesses()
                if (isCancelled()) {
                    stopProcessTree()
                    throw WorkerCancelledException()
                }
                if (process.isAlive) {
                    process.waitFor(200, java.util.concurrent.TimeUnit.MILLISECONDS)
                } else {
                    logThread.join(200)
                }
            }
            val exitCode = process.waitFor()
            logThread.join()
            val reportedFailure = logTail.lastOrNull { it.startsWith("MTC-WORKER status=failed") }
            if (exitCode != 0 || reportedFailure != null) {
                val summary = reportedFailure
                    ?.substringAfter("message=", missingDelimiterValue = reportedFailure)
                    ?.takeIf(String::isNotBlank)
                    ?: "Unbekannter Workerfehler"
                throw WorkerExecutionException(
                    "Konvertierung fehlgeschlagen: $summary\nDetails: ${logPath.fileName}",
                    logPath,
                )
            }
            val resultFiles = outputs.associateWith { output ->
                val bytes = outputBytes(output)
                if (bytes <= 0L) {
                    throw WorkerExecutionException(
                        "Der Worker hat keine gültige Ausgabe erzeugt: $output",
                        logPath,
                    )
                }
                bytes
            }
            completed = true
            return WorkerResult(resultFiles, logPath)
        } catch (exception: InterruptedException) {
            stopProcessTree()
            Thread.currentThread().interrupt()
            throw WorkerCancelledException(exception)
        } finally {
            if (!completed) stopProcessTree()
            logThread.join(2_000)
            outputs.forEach { output ->
                deleteOutput(output.resolveSibling("${output.fileName}.partial"))
                if (!completed) deleteOutput(output)
            }
        }
    }

    private fun outputBytes(output: Path): Long = when {
        Files.isRegularFile(output) -> Files.size(output)
        Files.isDirectory(output) -> Files.walk(output).use { paths ->
            paths.filter(Files::isRegularFile).mapToLong(Files::size).sum()
        }
        else -> 0L
    }

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

    private fun developmentWorkerCommand(arguments: List<String>): List<String> {
        val javaExecutable = Path.of(
            System.getProperty("java.home"),
            "bin",
            if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) "java.exe" else "java",
        )
        return listOf(
            javaExecutable.toString(), "-Xmx4g", "--enable-native-access=ALL-UNNAMED",
            "-cp", System.getProperty("java.class.path"),
            "de.arxsilex.maptools.converter.worker.WorkerMainKt",
        ) + arguments
    }

    private fun createLogPath(directory: Path): Path {
        Files.createDirectories(directory)
        val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
        return directory.resolve("export-$timestamp-${UUID.randomUUID().toString().take(8)}.log")
    }

    private fun workerTemporaryDirectory(arguments: List<String>): Path? {
        val index = arguments.indexOf("--temp-dir")
        return arguments.getOrNull(index + 1)?.takeIf { index >= 0 }?.let(Path::of)
            ?.toAbsolutePath()?.normalize()
    }
}

internal fun mapsforgeFallbackProcessor(
    primaryProcessor: MapsforgeProcessorType,
    failureLog: String,
    allowRamFallback: Boolean,
): MapsforgeProcessorType? = when {
    primaryProcessor == MapsforgeProcessorType.RAM && "OutOfMemoryError" in failureLog ->
        MapsforgeProcessorType.HD
    primaryProcessor == MapsforgeProcessorType.HD && allowRamFallback &&
        "Cannot begin reading in Add stage" in failureLog -> MapsforgeProcessorType.RAM
    else -> null
}

internal object PackagedWorkerResolver {
    fun find(
        resourcesDirectory: String? = System.getProperty("compose.application.resources.dir"),
        osName: String = System.getProperty("os.name"),
        exists: (Path) -> Boolean = Files::isRegularFile,
    ): Path? {
        val resources = resourcesDirectory?.takeIf { it.isNotBlank() }?.let(Path::of) ?: return null
        val isWindows = osName.startsWith("Windows", ignoreCase = true)
        val applicationRoot = if (isWindows) {
            resources.parent?.parent
        } else {
            resources.parent?.parent?.parent
        } ?: return null
        val executable = if (isWindows) {
            applicationRoot.resolve("maptools-worker.exe")
        } else {
            applicationRoot.resolve("bin").resolve("maptools-worker")
        }
        return executable.takeIf(exists)
    }
}
