import Foundation
import zlib

/// Minimal ZIP reader for course packs (store / deflate).
enum ZipUtility {
    static func unzip(fileAt zipURL: URL, to destination: URL) throws {
        let data = try Data(contentsOf: zipURL)
        var offset = 0
        while offset + 30 <= data.count {
            let sig = readUInt32(data, offset)
            if sig == 0x02014b50 || sig == 0x06054b50 { break }
            guard sig == 0x04034b50 else {
                throw CoursePackError.unzipFailed("bad local header")
            }
            let method = Int(readUInt16(data, offset + 8))
            let general = Int(readUInt16(data, offset + 6))
            let compSize = Int(readUInt32(data, offset + 18))
            let nameLen = Int(readUInt16(data, offset + 26))
            let extraLen = Int(readUInt16(data, offset + 28))
            let nameStart = offset + 30
            let nameEnd = nameStart + nameLen
            guard nameEnd + extraLen <= data.count else {
                throw CoursePackError.unzipFailed("truncated zip")
            }
            let nameData = data.subdata(in: nameStart..<nameEnd)
            guard let name = String(data: nameData, encoding: .utf8) else {
                throw CoursePackError.unzipFailed("bad entry name")
            }
            let dataStart = nameEnd + extraLen
            // Data descriptor flag
            if general & 0x08 != 0, compSize == 0 {
                throw CoursePackError.unzipFailed("zip data descriptor unsupported")
            }
            let dataEnd = dataStart + compSize
            guard dataEnd <= data.count else {
                throw CoursePackError.unzipFailed("truncated entry")
            }
            let payload = data.subdata(in: dataStart..<dataEnd)
            offset = dataEnd

            if name.hasSuffix("/") {
                let dir = destination.appendingPathComponent(name)
                try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
                continue
            }
            if name.contains("..") { throw CoursePackError.pathEscape }

            let outURL = destination.appendingPathComponent(name)
            try FileManager.default.createDirectory(
                at: outURL.deletingLastPathComponent(),
                withIntermediateDirectories: true
            )
            let bytes: Data
            switch method {
            case 0: bytes = payload
            case 8: bytes = try inflateRaw(payload)
            default: throw CoursePackError.unzipFailed("unsupported compression \(method)")
            }
            try bytes.write(to: outURL)
        }
    }

    private static func readUInt16(_ data: Data, _ offset: Int) -> UInt16 {
        data.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: offset, as: UInt16.self).littleEndian }
    }

    private static func readUInt32(_ data: Data, _ offset: Int) -> UInt32 {
        data.withUnsafeBytes { $0.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian }
    }

    /// Raw DEFLATE (ZIP method 8). Feed the full entry once; only grow the output buffer.
    /// The old per-64KB input loop could hit `Z_BUF_ERROR` (-5) on large deflated media.
    private static func inflateRaw(_ data: Data) throws -> Data {
        if data.isEmpty { return Data() }
        guard data.count <= Int(UInt32.max) else {
            throw CoursePackError.unzipFailed("entry too large")
        }

        var stream = z_stream()
        var status = inflateInit2_(&stream, -MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
        guard status == Z_OK else { throw CoursePackError.unzipFailed("inflateInit \(status)") }
        defer { inflateEnd(&stream) }

        var output = Data()
        output.reserveCapacity(max(data.count * 2, 64 * 1024))
        let outChunk = 256 * 1024

        try data.withUnsafeBytes { (srcBuf: UnsafeRawBufferPointer) in
            guard let inBase = srcBuf.bindMemory(to: Bytef.self).baseAddress else {
                throw CoursePackError.unzipFailed("inflate empty")
            }
            stream.next_in = UnsafeMutablePointer(mutating: inBase)
            stream.avail_in = uInt(data.count)

            repeat {
                var buffer = [Bytef](repeating: 0, count: outChunk)
                let produced = buffer.withUnsafeMutableBufferPointer { outBuf -> Int in
                    stream.next_out = outBuf.baseAddress
                    stream.avail_out = uInt(outChunk)
                    status = inflate(&stream, Z_NO_FLUSH)
                    return outChunk - Int(stream.avail_out)
                }
                if produced > 0 {
                    output.append(buffer, count: produced)
                }
                if status == Z_STREAM_END { break }
                // Z_BUF_ERROR with progress just means we need a larger/another out buffer.
                if status == Z_BUF_ERROR, produced > 0 { continue }
                if status != Z_OK {
                    throw CoursePackError.unzipFailed("inflate \(status)")
                }
            } while true
        }

        guard status == Z_STREAM_END else {
            throw CoursePackError.unzipFailed("inflate incomplete")
        }
        return output
    }
}
