-- lx-bundle:true

--- Minimal ZIP reader for OOXML packages.
-- Supports stored and deflated entries, using central-directory metadata.
-- @module core.lib.zip

local inflate = require("core.lib.inflate")

local zip = {}

local EOCD_SIG = "PK\005\006"
local CENTRAL_SIG = "PK\001\002"
local LOCAL_SIG = "PK\003\004"

local function u16le(data, offset)
	local a, b = data:byte(offset, offset + 1)
	if not b then
		return nil
	end
	return a | (b << 8)
end

local function u32le(data, offset)
	local a, b, c, d = data:byte(offset, offset + 3)
	if not d then
		return nil
	end
	return a | (b << 8) | (c << 16) | (d << 24)
end

local function read_all(filename)
	local file, err = io.open(filename, "rb")
	if not file then
		return nil, err or "open failed"
	end

	local data = file:read("*a") or ""
	file:close()
	return data
end

local function find_eocd(data)
	local min_pos = math.max(1, #data - 65535 - 22)

	for pos = #data - 21, min_pos, -1 do
		if data:sub(pos, pos + 3) == EOCD_SIG then
			return pos
		end
	end

	return nil
end

local Archive = {}
Archive.__index = Archive

function Archive:get_entry(name)
	return self.entries[name]
end

function Archive:list()
	local names = {}

	for name in pairs(self.entries) do
		names[#names + 1] = name
	end

	table.sort(names)
	return names
end

function Archive:read(name)
	local entry = self.entries[name]
	if not entry then
		return nil, "zip entry not found: " .. tostring(name)
	end

	local data = self.data
	local offset = entry.local_header_offset + 1
	if data:sub(offset, offset + 3) ~= LOCAL_SIG then
		return nil, "invalid local file header for " .. name
	end

	local filename_length = u16le(data, offset + 26)
	local extra_length = u16le(data, offset + 28)
	if not filename_length or not extra_length then
		return nil, "truncated local file header for " .. name
	end

	local payload_start = offset + 30 + filename_length + extra_length
	local payload_end = payload_start + entry.compressed_size - 1
	if payload_end > #data then
		return nil, "zip entry payload exceeds archive: " .. name
	end

	local payload = data:sub(payload_start, payload_end)

	if entry.method == 0 then
		return payload
	end

	if entry.method == 8 then
		local text, err = inflate.inflate_raw(payload)
		if not text then
			return nil, err
		end
		return text
	end

	return nil, "unsupported zip compression method " .. tostring(entry.method) .. " for " .. name
end

function zip.open(filename)
	local data, err = read_all(filename)
	if not data then
		return nil, err
	end

	local eocd = find_eocd(data)
	if not eocd then
		return nil, "end of central directory not found"
	end

	local entry_count = u16le(data, eocd + 10)
	local central_size = u32le(data, eocd + 12)
	local central_offset = u32le(data, eocd + 16)

	if not entry_count or not central_size or not central_offset then
		return nil, "truncated end of central directory"
	end

	local entries = {}
	local pos = central_offset + 1
	local central_end = pos + central_size

	for _ = 1, entry_count do
		if pos + 45 > #data or data:sub(pos, pos + 3) ~= CENTRAL_SIG then
			return nil, "invalid central directory"
		end

		local flags = u16le(data, pos + 8)
		local method = u16le(data, pos + 10)
		local compressed_size = u32le(data, pos + 20)
		local uncompressed_size = u32le(data, pos + 24)
		local filename_length = u16le(data, pos + 28)
		local extra_length = u16le(data, pos + 30)
		local comment_length = u16le(data, pos + 32)
		local local_header_offset = u32le(data, pos + 42)

		if not local_header_offset then
			return nil, "truncated central directory entry"
		end

		local name_start = pos + 46
		local name = data:sub(name_start, name_start + filename_length - 1)

		entries[name] = {
			name = name,
			flags = flags,
			method = method,
			compressed_size = compressed_size,
			uncompressed_size = uncompressed_size,
			local_header_offset = local_header_offset,
		}

		pos = name_start + filename_length + extra_length + comment_length
		if pos > central_end + 1 then
			return nil, "central directory entry exceeds declared size"
		end
	end

	return setmetatable({
		filename = filename,
		data = data,
		entries = entries,
	}, Archive)
end

return zip
