-- lx-bundle:true

--- File probing and text-vs-binary detection helpers
-- Keep low-level file classification outside 'core.init'
-- @module core.filetype

local filetype = {}

--- Read the first bytes of a file for format probing
-- @param filename string file path
-- @param max_bytes number|nil byte limit
-- @return string|nil data
-- @return string|nil err
function filetype.read_file_head(filename, max_bytes)
	local file_handle = io.open(filename, "rb")

	if not file_handle then
		return nil, "open_failed"
	end

	local data = file_handle:read(max_bytes or 4096) or ""
	file_handle:close()

	return data
end

--- Detect a BOM and return its encoding name and byte length
-- @param data string byte sample
-- @return string|nil encoding
-- @return number|nil length
local function detect_bom(data)
	if data:sub(1, 4) == "\255\254\000\000" then
		return "utf32le", 4
	end

	if data:sub(1, 4) == "\000\000\254\255" then
		return "utf32be", 4
	end

	if data:sub(1, 3) == "\239\187\191" then
		return "utf8", 3
	end

	if data:sub(1, 2) == "\255\254" then
		return "utf16le", 2
	end

	if data:sub(1, 2) == "\254\255" then
		return "utf16be", 2
	end
end

--- Heuristically detect UTF-16 content when no BOM is present
-- @param data string byte sample
-- @return boolean detected
-- @return string|nil hint
local function detect_utf16_without_bom(data)
	-- Validate decoded UTF-16 code units to avoid binary false positives
	--- Score one byte sample as likely UTF-16 text
	-- @param sample string byte sample
	-- @param little_endian boolean interpret sample as little-endian when true
	-- @return boolean likely
	local function is_likely_utf16_text(sample, little_endian)
		local pairs = math.floor(#sample / 2)

		if pairs < 16 then
			return false
		end

		local textish = 0
		local bad_ctrl = 0
		local nul = 0
		local index = 1

		while index <= #sample - 1 do
			local first_byte, second_byte = sample:byte(index, index + 1)
			local code_unit

			if little_endian then
				code_unit = first_byte + second_byte * 256
			else
				code_unit = second_byte + first_byte * 256
			end

			if code_unit == 0 then
				nul = nul + 1
			elseif code_unit == 9 or code_unit == 10 or code_unit == 13 then
				textish = textish + 1
			elseif code_unit < 32 or (code_unit >= 127 and code_unit <= 159) then
				bad_ctrl = bad_ctrl + 1
			else
				textish = textish + 1
			end

			index = index + 2
		end

		local total_pairs = pairs
		local text_ratio = textish / total_pairs
		local bad_ctrl_ratio = bad_ctrl / total_pairs
		local nul_ratio = nul / total_pairs

		return text_ratio >= 0.75 and bad_ctrl_ratio <= 0.02 and nul_ratio <= 0.02
	end

	local sample = data:sub(1, 4096)
	local pairs = 0
	local odd_nul = 0
	local even_nul = 0

	for index = 1, #sample - 1, 2 do
		local first_byte, second_byte = sample:byte(index, index + 1)

		if not second_byte then
			break
		end

		pairs = pairs + 1

		if first_byte == 0 then
			even_nul = even_nul + 1
		end

		if second_byte == 0 then
			odd_nul = odd_nul + 1
		end
	end

	if pairs < 8 then
		return false
	end

	local odd_ratio = odd_nul / pairs
	local even_ratio = even_nul / pairs

	if odd_ratio > 0.30 and even_ratio < 0.05 and is_likely_utf16_text(sample, true) then
		return true, "utf16le_no_bom"
	end

	if even_ratio > 0.30 and odd_ratio < 0.05 and is_likely_utf16_text(sample, false) then
		return true, "utf16be_no_bom"
	end

	return false
end

--- Heuristically classify binary data from a byte sample
-- @param data string byte sample
-- @param start_at number|nil sample offset
-- @return boolean is_binary
local function detect_binary_data(data, start_at)
	local sample = data:sub(start_at or 1)

	if sample == "" then
		return false
	end

	if sample:find("\0", 1, true) then
		return true
	end

	local non_text = 0

	for index = 1, #sample do
		local byte_value = sample:byte(index)
		local text_byte =
			(byte_value == 8 or byte_value == 9 or byte_value == 10 or byte_value == 13 or byte_value == 26 or byte_value == 27)
			or (byte_value >= 32 and byte_value <= 126)
			or byte_value >= 128

		if not text_byte then
			non_text = non_text + 1
		end
	end

	return (non_text / #sample) > 0.10
end

--- Check whether a byte sequence is valid UTF-8
-- @param text string byte sample
-- @return boolean valid
local function is_valid_utf8(text)
	local index = 1
	local text_length = #text

	while index <= text_length do
		local first_byte = text:byte(index)

		if first_byte <= 0x7F then
			index = index + 1
		elseif first_byte >= 0xC2 and first_byte <= 0xDF then
			local second_byte = text:byte(index + 1)

			if not (second_byte and second_byte >= 0x80 and second_byte <= 0xBF) then
				return false
			end

			index = index + 2
		elseif first_byte >= 0xE0 and first_byte <= 0xEF then
			local second_byte, third_byte = text:byte(index + 1, index + 2)

			if not (second_byte and third_byte and second_byte >= 0x80 and second_byte <= 0xBF and third_byte >= 0x80 and third_byte <= 0xBF) then
				return false
			end

			local codepoint = (first_byte - 0xE0) * 0x1000 + (second_byte - 0x80) * 0x40 + (third_byte - 0x80)

			if codepoint < 0x0800 or (codepoint >= 0xD800 and codepoint <= 0xDFFF) then
				return false
			end

			index = index + 3
		elseif first_byte >= 0xF0 and first_byte <= 0xF4 then
			local second_byte, third_byte, fourth_byte = text:byte(index + 1, index + 3)

			if
				not (
					second_byte
					and third_byte
					and fourth_byte
					and second_byte >= 0x80
					and second_byte <= 0xBF
					and third_byte >= 0x80
					and third_byte <= 0xBF
					and fourth_byte >= 0x80
					and fourth_byte <= 0xBF
				)
			then
				return false
			end

			local codepoint = (first_byte - 0xF0) * 0x40000 + (second_byte - 0x80) * 0x1000 + (third_byte - 0x80) * 0x40 + (fourth_byte - 0x80)

			if codepoint < 0x10000 or codepoint > 0x10FFFF then
				return false
			end

			index = index + 4
		else
			return false
		end
	end

	return true
end

--- Build a file-type probe used by open-flow safety checks
-- @param filename string file path
-- @return table|nil probe
-- @return string|nil err
function filetype.detect_file_type(filename)
	local data, err = filetype.read_file_head(filename, 4096)

	if not data then
		return nil, err
	end

	local bom, bom_length = detect_bom(data)
	local utf16_no_bom, utf16_hint = detect_utf16_without_bom(data)
	local is_utf16 = bom == "utf16le" or bom == "utf16be" or utf16_no_bom
	local is_binary = detect_binary_data(data, (bom_length or 0) + 1)
	local kind
	local encoding

	if bom then
		kind = "text_with_bom"
	elseif is_binary then
		kind = "binary"
	else
		kind = "text_without_bom"
	end

	if is_utf16 then
		if bom == "utf16be" or utf16_hint == "utf16be_no_bom" then
			encoding = "UTF-16 BE"
		else
			encoding = "UTF-16"
		end
	elseif is_binary then
		encoding = "Binary"
	elseif bom == "utf8" then
		encoding = "UTF-8 BOM"
	elseif is_valid_utf8(data) then
		encoding = "UTF-8"
	else
		encoding = "ANSI"
	end

	return {
		kind = kind,
		is_binary = is_binary,
		has_bom = bom ~= nil,
		bom = bom,
		is_utf16 = is_utf16,
		utf16_hint = utf16_hint,
		encoding = encoding,
	}
end

return filetype
