package net.sergeych.toread.fb2

internal object Fb2Zip {
    private const val LocalFileHeader = 0x04034b50
    private const val CentralDirectoryHeader = 0x02014b50
    private const val EndOfCentralDirectory = 0x06054b50
    private const val Stored = 0
    private const val Deflated = 8

    fun extractFb2Xml(zip: ByteArray): String {
        val entries = readCentralDirectory(zip)
        val entry = entries.firstOrNull { it.name.endsWith(".fb2", ignoreCase = true) }
            ?: entries.firstOrNull { !it.name.endsWith("/") }
            ?: throw Fb2ParseException("ZIP archive does not contain an FB2 entry")
        return Fb2XmlEncoding.decodeXml(readEntry(zip, entry))
    }

    fun createStoredZip(entryName: String, content: ByteArray): ByteArray {
        val nameBytes = entryName.encodeToByteArray()
        val crc = crc32(content)
        val local = ByteWriter()
        local.i32(LocalFileHeader)
        local.i16(20)
        local.i16(0)
        local.i16(Stored)
        local.i16(0)
        local.i16(0)
        local.i32(crc)
        local.i32(content.size)
        local.i32(content.size)
        local.i16(nameBytes.size)
        local.i16(0)
        local.bytes(nameBytes)
        local.bytes(content)

        val centralOffset = local.size
        val central = ByteWriter()
        central.i32(CentralDirectoryHeader)
        central.i16(20)
        central.i16(20)
        central.i16(0)
        central.i16(Stored)
        central.i16(0)
        central.i16(0)
        central.i32(crc)
        central.i32(content.size)
        central.i32(content.size)
        central.i16(nameBytes.size)
        central.i16(0)
        central.i16(0)
        central.i16(0)
        central.i16(0)
        central.i32(0)
        central.i32(0)
        central.bytes(nameBytes)

        val end = ByteWriter()
        end.i32(EndOfCentralDirectory)
        end.i16(0)
        end.i16(0)
        end.i16(1)
        end.i16(1)
        end.i32(central.size)
        end.i32(centralOffset)
        end.i16(0)

        return local.toByteArray() + central.toByteArray() + end.toByteArray()
    }

    private fun readEntry(zip: ByteArray, entry: ZipEntry): ByteArray {
        if (zip.i32(entry.localHeaderOffset) != LocalFileHeader) {
            throw Fb2ParseException("Invalid ZIP local header for ${entry.name}")
        }
        val nameSize = zip.i16(entry.localHeaderOffset + 26)
        val extraSize = zip.i16(entry.localHeaderOffset + 28)
        val dataOffset = entry.localHeaderOffset + 30 + nameSize + extraSize
        if (dataOffset + entry.compressedSize > zip.size) {
            throw Fb2ParseException("ZIP entry ${entry.name} is truncated")
        }
        val compressed = zip.copyOfRange(dataOffset, dataOffset + entry.compressedSize)
        return when (entry.method) {
            Stored -> compressed
            Deflated -> inflateRaw(compressed, entry.uncompressedSize)
            else -> throw Fb2ParseException("Unsupported ZIP compression method ${entry.method} for ${entry.name}")
        }
    }

    private fun readCentralDirectory(zip: ByteArray): List<ZipEntry> {
        val eocd = findEndOfCentralDirectory(zip)
        val entryCount = zip.i16(eocd + 10)
        val centralSize = zip.i32(eocd + 12)
        val centralOffset = zip.i32(eocd + 16)
        if (centralOffset < 0 || centralSize < 0 || centralOffset + centralSize > zip.size) {
            throw Fb2ParseException("Invalid ZIP central directory")
        }
        val entries = mutableListOf<ZipEntry>()
        var offset = centralOffset
        repeat(entryCount) {
            if (zip.i32(offset) != CentralDirectoryHeader) {
                throw Fb2ParseException("Invalid ZIP central directory header")
            }
            val method = zip.i16(offset + 10)
            val compressedSize = zip.i32(offset + 20)
            val uncompressedSize = zip.i32(offset + 24)
            val nameSize = zip.i16(offset + 28)
            val extraSize = zip.i16(offset + 30)
            val commentSize = zip.i16(offset + 32)
            val localHeaderOffset = zip.i32(offset + 42)
            val nameStart = offset + 46
            val name = zip.copyOfRange(nameStart, nameStart + nameSize).decodeToString()
            entries += ZipEntry(name, method, compressedSize, uncompressedSize, localHeaderOffset)
            offset = nameStart + nameSize + extraSize + commentSize
        }
        return entries
    }

    private fun findEndOfCentralDirectory(zip: ByteArray): Int {
        val min = maxOf(0, zip.size - 65_557)
        var offset = zip.size - 22
        while (offset >= min) {
            if (zip.i32(offset) == EndOfCentralDirectory) return offset
            offset--
        }
        throw Fb2ParseException("ZIP end of central directory was not found")
    }

    private data class ZipEntry(
        val name: String,
        val method: Int,
        val compressedSize: Int,
        val uncompressedSize: Int,
        val localHeaderOffset: Int,
    )

    private class ByteWriter {
        private val bytes = mutableListOf<Byte>()
        val size: Int get() = bytes.size

        fun i16(value: Int) {
            bytes += (value and 0xff).toByte()
            bytes += ((value ushr 8) and 0xff).toByte()
        }

        fun i32(value: Int) {
            i16(value and 0xffff)
            i16((value ushr 16) and 0xffff)
        }

        fun bytes(value: ByteArray) {
            value.forEach { bytes += it }
        }

        fun toByteArray(): ByteArray = ByteArray(bytes.size) { bytes[it] }
    }
}

internal expect fun inflateRaw(input: ByteArray, expectedSize: Int): ByteArray

private fun ByteArray.i16(offset: Int): Int {
    if (offset + 2 > size) throw Fb2ParseException("Unexpected end of ZIP data")
    return (this[offset].toInt() and 0xff) or ((this[offset + 1].toInt() and 0xff) shl 8)
}

private fun ByteArray.i32(offset: Int): Int {
    if (offset + 4 > size) throw Fb2ParseException("Unexpected end of ZIP data")
    return i16(offset) or (i16(offset + 2) shl 16)
}

private fun crc32(input: ByteArray): Int {
    var crc = -1
    input.forEach { byte ->
        crc = crc xor (byte.toInt() and 0xff)
        repeat(8) {
            crc = if ((crc and 1) != 0) {
                (crc ushr 1) xor 0xedb88320.toInt()
            } else {
                crc ushr 1
            }
        }
    }
    return crc.inv()
}
