package de.arxsilex.maptools.converter.desktop

import de.arxsilex.maptools.converter.core.InputFileInspector
import de.arxsilex.maptools.converter.core.InputFormat
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.attribute.FileTime
import java.util.Comparator
import java.util.UUID
import java.util.zip.CRC32
import java.util.zip.ZipException
import java.util.zip.ZipFile

internal data class ZipImportPolicy(
    val maximumEntryCount: Int = 20_000,
    val maximumEntryBytes: Long = 16L * 1024L * 1024L * 1024L,
    val maximumTotalBytes: Long = 64L * 1024L * 1024L * 1024L,
    val maximumCompressionRatio: Double = 200.0,
    val freeSpaceReserveBytes: Long = 512L * 1024L * 1024L,
)

internal data class ZipImportEntry(
    val ordinal: Int,
    val archivePath: String,
    val fileName: String,
    val format: InputFormat,
    val compressedBytes: Long,
    val uncompressedBytes: Long,
    val crc32: Long,
)

internal data class ZipArchiveInspection(
    val archive: Path,
    val archiveBytes: Long,
    val lastModified: FileTime,
    val entries: List<ZipImportEntry>,
    val ignoredEntryCount: Int,
    val nestedArchiveCount: Int,
    val totalSelectedCapableBytes: Long,
)

internal class ZipImportException(message: String, cause: Throwable? = null) :
    IllegalArgumentException(message, cause)

/** Securely inspects ZIP containers and extracts only entries explicitly selected by the user. */
internal object ZipArchiveImporter {
    fun inspect(archive: Path, policy: ZipImportPolicy = ZipImportPolicy()): ZipArchiveInspection {
        val source = archive.toAbsolutePath().normalize()
        require(Files.isRegularFile(source)) { "ZIP-Archiv nicht gefunden: $source" }
        require(source.fileName.toString().endsWith(".zip", ignoreCase = true)) {
            "Nur ZIP-Archive werden unterstützt."
        }
        validatePolicy(policy)
        val archiveBytes = Files.size(source)
        val lastModified = Files.getLastModifiedTime(source)
        val supported = mutableListOf<ZipImportEntry>()
        var ignored = 0
        var nested = 0
        var totalBytes = 0L
        try {
            ZipFile(source.toFile()).use { zip ->
                val entries = zip.entries()
                var ordinal = 0
                while (entries.hasMoreElements()) {
                    val entry = entries.nextElement()
                    ordinal++
                    if (ordinal > policy.maximumEntryCount) {
                        throw ZipImportException(
                            "Das ZIP-Archiv enthält mehr als ${policy.maximumEntryCount} Einträge.",
                        )
                    }
                    if (entry.isDirectory) continue
                    val safeName = validateEntryName(entry.name)
                    if (safeName.endsWith(".zip", ignoreCase = true)) {
                        nested++
                        continue
                    }
                    val fileName = safeName.substringAfterLast('/')
                    val format = InputFileInspector.detectFormat(fileName)
                    if (format == null || (!format.isMapData && !format.isPoiData && !format.isElevationData)) {
                        ignored++
                        continue
                    }
                    val uncompressed = entry.size
                    val compressed = entry.compressedSize
                    if (uncompressed < 0L || compressed < 0L) {
                        throw ZipImportException("Für '$safeName' fehlen verlässliche ZIP-Größenangaben.")
                    }
                    if (entry.crc < 0L) {
                        throw ZipImportException("Für '$safeName' fehlt eine verlässliche CRC-Prüfsumme.")
                    }
                    if (uncompressed > policy.maximumEntryBytes) {
                        throw ZipImportException("'$safeName' überschreitet die zulässige Einzelgröße.")
                    }
                    val ratio = when {
                        uncompressed == 0L -> 0.0
                        compressed == 0L -> Double.POSITIVE_INFINITY
                        else -> uncompressed.toDouble() / compressed.toDouble()
                    }
                    if (ratio > policy.maximumCompressionRatio) {
                        throw ZipImportException(
                            "'$safeName' besitzt ein verdächtiges Kompressionsverhältnis (${"%.1f".format(ratio)}:1).",
                        )
                    }
                    totalBytes = safeAdd(totalBytes, uncompressed)
                    if (totalBytes > policy.maximumTotalBytes) {
                        throw ZipImportException("Die unterstützten ZIP-Inhalte überschreiten die zulässige Gesamtgröße.")
                    }
                    supported += ZipImportEntry(
                        ordinal = ordinal,
                        archivePath = safeName,
                        fileName = fileName,
                        format = format,
                        compressedBytes = compressed,
                        uncompressedBytes = uncompressed,
                        crc32 = entry.crc,
                    )
                }
            }
        } catch (exception: ZipImportException) {
            throw exception
        } catch (exception: ZipException) {
            throw ZipImportException("Das ZIP-Archiv ist beschädigt, verschlüsselt oder nicht unterstützt.", exception)
        }
        if (supported.isEmpty()) {
            throw ZipImportException("Das ZIP-Archiv enthält keine unterstützten Karten-, POI- oder Höhendateien.")
        }
        return ZipArchiveInspection(
            archive = source,
            archiveBytes = archiveBytes,
            lastModified = lastModified,
            entries = supported,
            ignoredEntryCount = ignored,
            nestedArchiveCount = nested,
            totalSelectedCapableBytes = totalBytes,
        )
    }

    fun extractSelected(
        inspection: ZipArchiveInspection,
        selectedOrdinals: Set<Int>,
        destinationRoot: Path,
        policy: ZipImportPolicy = ZipImportPolicy(),
    ): List<Path> {
        validatePolicy(policy)
        if (selectedOrdinals.isEmpty()) return emptyList()
        val selected = inspection.entries.filter { it.ordinal in selectedOrdinals }
        require(selected.size == selectedOrdinals.size) { "Die ZIP-Auswahl enthält unbekannte Einträge." }
        val archive = inspection.archive.toAbsolutePath().normalize()
        require(Files.size(archive) == inspection.archiveBytes && Files.getLastModifiedTime(archive) == inspection.lastModified) {
            "Das ZIP-Archiv wurde seit der Inhaltsprüfung verändert."
        }
        val requestedBytes = selected.fold(0L) { total, entry -> safeAdd(total, entry.uncompressedBytes) }
        require(requestedBytes <= policy.maximumTotalBytes) { "Die ZIP-Auswahl ist zu groß." }
        val root = destinationRoot.toAbsolutePath().normalize()
        Files.createDirectories(root)
        val usableBytes = Files.getFileStore(root).usableSpace
        val requiredBytes = safeAdd(requestedBytes, policy.freeSpaceReserveBytes)
        require(usableBytes >= requiredBytes) {
            "Für das sichere Entpacken werden ${formatFileSize(requiredBytes)} benötigt; verfügbar sind ${formatFileSize(usableBytes)}."
        }
        val extractionRoot = root.resolve("archive-${UUID.randomUUID()}").normalize()
        require(extractionRoot.parent == root) { "Ungültiges ZIP-Zielverzeichnis." }
        Files.createDirectories(extractionRoot)
        val selectedByOrdinal = selected.associateBy(ZipImportEntry::ordinal)
        val outputs = mutableListOf<Path>()
        var actualTotal = 0L
        try {
            ZipFile(archive.toFile()).use { zip ->
                val entries = zip.entries()
                var ordinal = 0
                while (entries.hasMoreElements()) {
                    val zipEntry = entries.nextElement()
                    ordinal++
                    val selectedEntry = selectedByOrdinal[ordinal] ?: continue
                    require(validateEntryName(zipEntry.name) == selectedEntry.archivePath) {
                        "Das ZIP-Archiv wurde während des Imports verändert."
                    }
                    val entryDirectory = extractionRoot.resolve(ordinal.toString().padStart(6, '0')).normalize()
                    require(entryDirectory.parent == extractionRoot) { "Ungültiges ZIP-Eintragsziel." }
                    Files.createDirectories(entryDirectory)
                    val output = entryDirectory.resolve(selectedEntry.fileName).normalize()
                    require(output.parent == entryDirectory) { "Unsicherer ZIP-Dateiname." }
                    val partial = entryDirectory.resolve(".${selectedEntry.fileName}.partial").normalize()
                    var actualEntry = 0L
                    val crc32 = CRC32()
                    zip.getInputStream(zipEntry).use { input ->
                        Files.newOutputStream(partial).use { target ->
                            val buffer = ByteArray(COPY_BUFFER_BYTES)
                            while (true) {
                                val read = input.read(buffer)
                                if (read < 0) break
                                actualEntry = safeAdd(actualEntry, read.toLong())
                                actualTotal = safeAdd(actualTotal, read.toLong())
                                if (
                                    actualEntry > selectedEntry.uncompressedBytes ||
                                    actualEntry > policy.maximumEntryBytes ||
                                    actualTotal > requestedBytes ||
                                    actualTotal > policy.maximumTotalBytes
                                ) {
                                    throw ZipImportException("ZIP-Inhalt überschreitet die geprüften Größenangaben.")
                                }
                                crc32.update(buffer, 0, read)
                                target.write(buffer, 0, read)
                            }
                        }
                    }
                    if (actualEntry != selectedEntry.uncompressedBytes) {
                        throw ZipImportException("Die entpackte Größe von '${selectedEntry.archivePath}' ist unerwartet.")
                    }
                    if (selectedEntry.crc32 < 0L || crc32.value != selectedEntry.crc32) {
                        throw ZipImportException("Die CRC-Prüfung von '${selectedEntry.archivePath}' ist fehlgeschlagen.")
                    }
                    try {
                        Files.move(partial, output, StandardCopyOption.ATOMIC_MOVE)
                    } catch (_: AtomicMoveNotSupportedException) {
                        Files.move(partial, output)
                    }
                    outputs.add(output)
                }
            }
            require(outputs.size == selected.size) { "Nicht alle ausgewählten ZIP-Einträge wurden gefunden." }
            return outputs
        } catch (exception: Exception) {
            deleteTree(extractionRoot)
            if (exception is ZipImportException) throw exception
            if (exception is ZipException) {
                throw ZipImportException("Das ZIP-Archiv ist beschädigt, verschlüsselt oder nicht unterstützt.", exception)
            }
            throw ZipImportException(exception.message ?: "Das ZIP-Archiv konnte nicht sicher entpackt werden.", exception)
        }
    }

    private fun validateEntryName(rawName: String): String {
        val normalized = rawName.replace('\\', '/').trimStart()
        if (
            normalized.isBlank() || normalized.startsWith('/') || normalized.startsWith("//") ||
            normalized.contains('\u0000') || WINDOWS_DRIVE.matchesAt(normalized, 0)
        ) {
            throw ZipImportException("Das ZIP-Archiv enthält einen unsicheren Pfad: '$rawName'.")
        }
        val segments = normalized.split('/')
        if (segments.any { it.isBlank() || it == "." || it == ".." }) {
            throw ZipImportException("Das ZIP-Archiv enthält einen unsicheren Pfad: '$rawName'.")
        }
        if (segments.any { segment ->
                segment.any { it in INVALID_WINDOWS_FILE_CHARS } ||
                    segment.endsWith('.') || segment.endsWith(' ') || isReservedWindowsName(segment)
            }
        ) {
            throw ZipImportException("Das ZIP-Archiv enthält einen nicht portablen Dateinamen: '$rawName'.")
        }
        return segments.joinToString("/")
    }

    private fun isReservedWindowsName(fileName: String): Boolean {
        val stem = fileName.substringBefore('.').uppercase()
        return stem in RESERVED_WINDOWS_NAMES
    }

    private fun validatePolicy(policy: ZipImportPolicy) {
        require(policy.maximumEntryCount > 0)
        require(policy.maximumEntryBytes > 0L)
        require(policy.maximumTotalBytes > 0L)
        require(policy.maximumCompressionRatio >= 1.0)
        require(policy.freeSpaceReserveBytes >= 0L)
    }

    private fun safeAdd(first: Long, second: Long): Long {
        if (second < 0L || first > Long.MAX_VALUE - second) {
            throw ZipImportException("Die ZIP-Größenangaben sind ungültig.")
        }
        return first + second
    }

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

    private val WINDOWS_DRIVE = Regex("^[A-Za-z]:")
    private val INVALID_WINDOWS_FILE_CHARS = setOf('<', '>', ':', '\"', '|', '?', '*')
    private val RESERVED_WINDOWS_NAMES = buildSet {
        addAll(listOf("CON", "PRN", "AUX", "NUL"))
        (1..9).forEach { number ->
            add("COM$number")
            add("LPT$number")
        }
    }
    private const val COPY_BUFFER_BYTES = 128 * 1024
}
