-- lx-bundle:true

--- Minimal legacy Word DOC text extractor.
-- Reads the WordDocument stream plus 0Table/1Table CLX piece table.
-- @module plugins.docutils.reader

local cfb = require("plugins.docutils.cfb")

local reader = {}

local function bytez(data, offset)
	return data:byte(offset + 1) or 0
end

local function u16z(data, offset)
	return bytez(data, offset) | (bytez(data, offset + 1) << 8)
end

local function u32z(data, offset)
	return bytez(data, offset)
		| (bytez(data, offset + 1) << 8)
		| (bytez(data, offset + 2) << 16)
		| (bytez(data, offset + 3) << 24)
end

local function i32z(data, offset)
	local value = u32z(data, offset)
	if value >= 0x80000000 then
		return value - 0x100000000
	end
	return value
end

local function substrz(data, offset, len)
	if len <= 0 then
		return ""
	end
	return data:sub(offset + 1, offset + len)
end

local cp1252 = {
	[0x80] = 0x20ac, [0x82] = 0x201a, [0x83] = 0x0192, [0x84] = 0x201e,
	[0x85] = 0x2026, [0x86] = 0x2020, [0x87] = 0x2021, [0x88] = 0x02c6,
	[0x89] = 0x2030, [0x8a] = 0x0160, [0x8b] = 0x2039, [0x8c] = 0x0152,
	[0x8e] = 0x017d, [0x91] = 0x2018, [0x92] = 0x2019, [0x93] = 0x201c,
	[0x94] = 0x201d, [0x95] = 0x2022, [0x96] = 0x2013, [0x97] = 0x2014,
	[0x98] = 0x02dc, [0x99] = 0x2122, [0x9a] = 0x0161, [0x9b] = 0x203a,
	[0x9c] = 0x0153, [0x9e] = 0x017e, [0x9f] = 0x0178,
}

local function safe_utf8_char(code)
	if not code or code == 0 then
		return ""
	end
	local ok, value = pcall(utf8.char, code)
	return ok and value or ""
end

local function decode_cp1252(data)
	local out = {}
	for i = 1, #data do
		local b = data:byte(i)
		out[#out + 1] = safe_utf8_char(cp1252[b] or b)
	end
	return table.concat(out)
end

local function decode_utf16le(data)
	local out = {}
	local i = 1

	while i < #data do
		local lo, hi = data:byte(i, i + 1)
		local code = lo | (hi << 8)
		i = i + 2

		if code >= 0xd800 and code <= 0xdbff and i < #data then
			local lo2, hi2 = data:byte(i, i + 1)
			local trail = lo2 | (hi2 << 8)
			if trail >= 0xdc00 and trail <= 0xdfff then
				code = 0x10000 + ((code - 0xd800) << 10) + (trail - 0xdc00)
				i = i + 2
			end
		end

		out[#out + 1] = safe_utf8_char(code)
	end

	return table.concat(out)
end

local function parse_fib(word)
	local ident = u16z(word, 0)
	if ident == 0xa5dc then
		local fc_min = u32z(word, 0x18)
		local fc_mac = u32z(word, 0x1c)
		local ccp_text = i32z(word, 0x34)

		if fc_min >= fc_mac or fc_mac > #word then
			return nil, "invalid Word 6/95 text bounds"
		end

		return {
			format = "word6",
			ccp_text = math.max(0, ccp_text),
			fc_min = fc_min,
			fc_mac = fc_mac,
		}
	end

	if ident ~= 0xa5ec then
		return nil, string.format("unsupported WordDocument FIB magic 0x%04x", ident)
	end

	local pos = 0
	pos = pos + 2 -- wIdent
	pos = pos + 2 -- nFib
	pos = pos + 2 -- unused
	pos = pos + 2 -- lid
	pos = pos + 2 -- pnNext
	local flags = u16z(word, pos)
	pos = pos + 2

	local encrypted = ((flags >> 8) & 1) ~= 0
	if encrypted then
		return nil, "encrypted DOC files are not supported"
	end

	pos = pos + 2 -- nFibBack
	pos = pos + 4 -- lKey
	pos = pos + 1 -- envr
	pos = pos + 1 -- flags2
	pos = pos + 2 -- reserved3
	pos = pos + 2 -- reserved4
	pos = pos + 4 -- reserved5
	pos = pos + 4 -- reserved6
	pos = pos + 2 -- csw
	pos = pos + (14 * 2) -- fibRgW
	pos = pos + 2 -- cslw
	pos = pos + 4 -- cbMac
	pos = pos + 4 -- reserved1
	pos = pos + 4 -- reserved2
	local ccp_text = i32z(word, pos)
	pos = pos + 4
	pos = pos + (18 * 4) -- remaining fibRgLw fields through reserved14

	local cb_rg_fc_lcb = u16z(word, pos)
	pos = pos + 2
	if cb_rg_fc_lcb < 34 then
		return nil, "FIB table is too small for CLX"
	end

	local fc_clx_offset = pos + ((34 - 1) * 8)
	local fc_clx = u32z(word, fc_clx_offset)
	local lcb_clx = u32z(word, fc_clx_offset + 4)

	return {
		format = "word97",
		ccp_text = math.max(0, ccp_text),
		which_table = ((flags >> 9) & 1) == 1 and "1Table" or "0Table",
		fc_clx = fc_clx,
		lcb_clx = lcb_clx,
	}
end

local function parse_piece_table(table_stream, fc_clx, lcb_clx)
	local clx = substrz(table_stream, fc_clx, lcb_clx)
	local pos = 0

	while pos < #clx do
		local marker = bytez(clx, pos)
		pos = pos + 1

		if marker == 0x01 then
			local cb_prc = u16z(clx, pos)
			pos = pos + 2 + cb_prc
		elseif marker == 0x02 then
			local lcb = u32z(clx, pos)
			pos = pos + 4
			local count = (lcb - 4) // 12
			if count <= 0 then
				return nil, "empty DOC piece table"
			end

			local cps = {}
			for i = 1, count + 1 do
				cps[i] = u32z(clx, pos)
				pos = pos + 4
			end

			local pieces = {}
			for i = 1, count do
				pos = pos + 2 -- pcd flags
				local fc_compressed = u32z(clx, pos)
				pos = pos + 4
				pos = pos + 2 -- prm
				pieces[i] = {
					cp_start = cps[i],
					cp_end = cps[i + 1],
					compressed = (fc_compressed & 0x40000000) ~= 0,
					fc = fc_compressed,
				}
			end

			return pieces
		else
			return nil, "unsupported DOC CLX marker " .. tostring(marker)
		end
	end

	return nil, "DOC piece table not found"
end

local function piece_offset(piece)
	if piece.compressed then
		return (piece.fc & 0x3fffffff) // 2
	end
	return piece.fc
end

local function decode_piece(word, piece, from_cp, to_cp)
	local skip_chars = from_cp - piece.cp_start
	local char_count = to_cp - from_cp
	if char_count <= 0 then
		return ""
	end

	local offset = piece_offset(piece)
	if piece.compressed then
		return decode_cp1252(substrz(word, offset + skip_chars, char_count))
	end

	return decode_utf16le(substrz(word, offset + (skip_chars * 2), char_count * 2))
end

local function cleanup_text(text)
	text = text:gsub("\r\n", "\n"):gsub("\r", "\n")
	text = text:gsub("\007", "\t")
	text = text:gsub("\011", "\n")
	text = text:gsub("\012", "\n\n")
	text = text:gsub("[%z\001-\006\008\014-\031]", "")
	text = text:gsub("[ \t]+\n", "\n")
	text = text:gsub("\n\n\n+", "\n\n")
	return text:match("^%s*(.-)%s*$") or text
end

local function load_document(filename)
	local archive, err = cfb.open(filename)
	if not archive then
		return nil, err
	end

	local word, word_err = archive:read_stream("WordDocument")
	if not word then
		return nil, word_err or "WordDocument stream not found"
	end

	local fib, fib_err = parse_fib(word)
	if not fib then
		return nil, fib_err
	end

	if fib.format == "word6" then
		return {
			text = cleanup_text(decode_cp1252(substrz(word, fib.fc_min, fib.fc_mac - fib.fc_min))),
			fib = fib,
		}
	end

	local table_stream = archive:read_stream(fib.which_table)
		or archive:read_stream(fib.which_table == "1Table" and "0Table" or "1Table")
	if not table_stream then
		return nil, "DOC table stream not found"
	end

	local pieces, pieces_err = parse_piece_table(table_stream, fib.fc_clx, fib.lcb_clx)
	if not pieces then
		return nil, pieces_err
	end

	local out = {}
	for _, piece in ipairs(pieces) do
		local from_cp = math.max(piece.cp_start, 0)
		local to_cp = math.min(piece.cp_end, fib.ccp_text)
		if to_cp > from_cp then
			out[#out + 1] = decode_piece(word, piece, from_cp, to_cp)
		end
	end

	return {
		text = cleanup_text(table.concat(out)),
		fib = fib,
	}
end

function reader.read(filename)
	return load_document(filename)
end

function reader.extract_text(filename)
	local doc, err = load_document(filename)
	if not doc then
		return nil, err
	end
	return doc.text
end

function reader.extract_markdown(filename)
	local doc, err = load_document(filename)
	if not doc then
		return nil, err
	end
	return doc.text
end

return reader
