package de.arxsilex.maptools.converter.desktop

import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import java.util.zip.CRC32
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.assertNotNull

class ZipArchiveImporterTest {
    @Test
    fun `mixed archive exposes and extracts only selected supported formats`() {
        val directory = createTempDirectory("zip-import-mixed-")
        try {
            val archive = directory.resolve("bundle.zip")
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                zip.entry("maps/region.map", byteArrayOf(1, 2, 3))
                zip.entry("pois/region.poi", byteArrayOf(4, 5))
                zip.entry("height/N50E008.hgt", byteArrayOf(6, 7, 8, 9))
                zip.entry("notes/readme.txt", "ignored".encodeToByteArray())
                zip.entry("nested/data.zip", byteArrayOf(10))
            }

            val inspection = ZipArchiveImporter.inspect(archive)

            assertEquals(listOf("region.map", "region.poi", "N50E008.hgt"), inspection.entries.map { it.fileName })
            assertEquals(1, inspection.ignoredEntryCount)
            assertEquals(1, inspection.nestedArchiveCount)
            val selected = inspection.entries.filter { it.fileName != "region.poi" }.mapTo(mutableSetOf()) { it.ordinal }
            val outputs = ZipArchiveImporter.extractSelected(
                inspection,
                selected,
                directory.resolve("extract"),
                ZipImportPolicy(freeSpaceReserveBytes = 0L),
            )
            assertEquals(listOf("region.map", "N50E008.hgt"), outputs.map { it.fileName.toString() })
            assertTrue(outputs.all(Files::isRegularFile))
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    @Test
    fun `zip slip path is rejected before extraction`() {
        val directory = createTempDirectory("zip-import-slip-")
        try {
            val archive = directory.resolve("unsafe.zip")
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                zip.entry("../outside.map", byteArrayOf(1))
            }

            assertFailsWith<ZipImportException> { ZipArchiveImporter.inspect(archive) }
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    @Test
    fun `suspicious compression ratio is rejected as a zip bomb`() {
        val directory = createTempDirectory("zip-import-bomb-")
        try {
            val archive = directory.resolve("bomb.zip")
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                zip.entry("terrain.tif", ByteArray(2 * 1024 * 1024))
            }

            assertFailsWith<ZipImportException> {
                ZipArchiveImporter.inspect(archive, ZipImportPolicy(maximumCompressionRatio = 20.0))
            }
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    @Test
    fun `selected total is checked against policy before extraction`() {
        val directory = createTempDirectory("zip-import-limit-")
        try {
            val archive = directory.resolve("limit.zip")
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                zip.entry("a.map", ByteArray(32))
                zip.entry("b.poi", ByteArray(32))
            }

            assertFailsWith<ZipImportException> {
                ZipArchiveImporter.inspect(
                    archive,
                    ZipImportPolicy(maximumEntryBytes = 64, maximumTotalBytes = 48, freeSpaceReserveBytes = 0),
                )
            }
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    @Test
    fun `extracted elevation file passes the normal input inspection`() {
        val directory = createTempDirectory("zip-import-elevation-")
        try {
            val archive = directory.resolve("terrain.zip")
            val asc = """
                ncols 2
                nrows 2
                xllcorner 8.0
                yllcorner 50.0
                cellsize 0.01
                NODATA_value -9999
                1 2
                3 4
            """.trimIndent().encodeToByteArray()
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                zip.entry("terrain/height.asc", asc)
            }
            val inspection = ZipArchiveImporter.inspect(archive)
            val output = ZipArchiveImporter.extractSelected(
                inspection,
                inspection.entries.mapTo(mutableSetOf()) { it.ordinal },
                directory.resolve("extract"),
                ZipImportPolicy(freeSpaceReserveBytes = 0L),
            ).single()

            val input = inspectInputFile(output.toFile()).getOrThrow()

            assertNotNull(input.elevationMetadata)
            assertEquals(8.02, input.bounds?.east ?: Double.NaN, 0.000_000_1)
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    @Test
    fun `changed archive payload is rejected even when size and timestamp are restored`() {
        val directory = createTempDirectory("zip-import-crc-")
        try {
            val archive = directory.resolve("changed.zip")
            val payload = "unique-map-payload-4f903c".encodeToByteArray()
            ZipOutputStream(Files.newOutputStream(archive)).use { zip ->
                val checksum = CRC32().apply { update(payload) }
                val entry = ZipEntry("region.map").apply {
                    method = ZipEntry.STORED
                    size = payload.size.toLong()
                    compressedSize = payload.size.toLong()
                    crc = checksum.value
                }
                zip.putNextEntry(entry)
                zip.write(payload)
                zip.closeEntry()
            }
            val inspection = ZipArchiveImporter.inspect(archive)
            val bytes = Files.readAllBytes(archive)
            val payloadOffset = bytes.indexOfSequence(payload)
            assertTrue(payloadOffset >= 0)
            bytes[payloadOffset] = (bytes[payloadOffset].toInt() xor 0x01).toByte()
            Files.write(archive, bytes)
            Files.setLastModifiedTime(archive, inspection.lastModified)

            assertFailsWith<ZipImportException> {
                ZipArchiveImporter.extractSelected(
                    inspection,
                    setOf(inspection.entries.single().ordinal),
                    directory.resolve("extract"),
                    ZipImportPolicy(freeSpaceReserveBytes = 0L),
                )
            }
        } finally {
            directory.toFile().deleteRecursively()
        }
    }

    private fun ZipOutputStream.entry(name: String, bytes: ByteArray) {
        putNextEntry(ZipEntry(name))
        write(bytes)
        closeEntry()
    }

    private fun ByteArray.indexOfSequence(sequence: ByteArray): Int {
        if (sequence.isEmpty()) return 0
        for (start in 0..size - sequence.size) {
            if (sequence.indices.all { offset -> this[start + offset] == sequence[offset] }) return start
        }
        return -1
    }
}
