-- lx-bundle:true

--- Detect ANSI and OEM-style text art from raw bytes
-- Shared by runtime code and command-line tooling

-- Keeps scan profiles with the detector so runtime and tooling share the same rules

-- Scan only first N bytes, thresholds per extension.
-- returns: { oem=bool, escapes=bool, ansi=bool, binary=bool }

local dosdetect = {}

-- Scan profile settings:
--
-- scan_bytes:
--   Maximum number of bytes to inspect from file start.
--   Limits work while still seeing structural signals early.
--   Implemented via min(file_size, scan_bytes) during scan.
-- oem_p_box:
--   Minimum density of bytes in 0xB0–0xDF CP437 box range.
--   Distinguishes real box art from occasional decorative chars.
--   Computed as (box_range_bytes / scanned_bytes).
-- oem_p_lines:
--   Minimum density of CP437 line-drawing subset.
--   Helps catch frame-style art even when blocks are sparse.
--   Computed as (line_bytes / scanned_bytes).
-- oem_box_repeats:
--   Minimum excess length of repeated CP437 box bytes.
--   Detects long horizontal rules typical of ANSI/ASCII art.
--   Counted when runs ≥6 of bytes within 0xB0–0xDF.
-- ansi_cursor:
--   Cursor-move count needed to flag ANSI art.
--   True screen art repositions the cursor frequently.
--   Counted from CSI finals H/f/A/B/C/D.
-- ansi_clear:
--   Screen/line clear count needed to flag ANSI art.
--   Full-screen art commonly clears regions before drawing.
--   Counted from CSI finals J/K.
-- ansi_csi_per_kb:
--   Minimum CSI density (per KB) for art classification.
--   ANSI art is escape-dense compared to normal terminal logs.
--   Computed as (CSI_count / scanned_kilobytes).
-- ansi_csi_min:
--   Absolute CSI count required for strong art confidence.
--   Prevents small files from triggering on density alone.
--   Compared against total parsed CSI sequences.
-- ansi_cursor_min:
--   Minimum cursor moves required with high CSI volume.
--   Guards against color-heavy logs being misclassified as art.
-- 	 Applied alongside ansi_csi_min threshold.
-- ansi_nul_ratio_max:
--   Maximum tolerated NUL-byte ratio for ANSI-heavy '.ans' files only.
--   Allows padded legacy art files without weakening other formats.

local scan_baseline_profile = {
	scan_bytes = 48 * 1024,

	-- OEM (CP437) box detection
	-- Conservative: avoids flagging prose with a few box chars
	oem_p_box = 0.16,
	oem_p_lines = 0.04,
	oem_box_repeats = 16,

	-- ANSI art detection
	-- Requires meaningful cursor activity or density
	ansi_cursor = 10,
	ansi_clear = 5,
	ansi_csi_per_kb = 22,
	ansi_csi_min = 80,
	ansi_cursor_min = 4,
	ansi_nul_ratio_max = 0,
}

-- Override per extension (lowercase, without dot)
local scan_profiles = {
	-- NFO is often prose, but often includes small CP437 separators/frames
	-- Lower OEM thresholds so we don't miss "scene NFO with box lines"
	nfo = {
		scan_bytes = 32 * 1024,
		oem_p_box = 0.08,
		oem_p_lines = 0.015,
		oem_box_repeats = 8,
	},

	-- ANS may be ANSI art (escape-heavy) so we want ansi=true quickly
	-- OEM thresholds unchanged
	-- Many ANS files are pure escapes + ASCII, some also include CP437
	ans = {
		scan_bytes = 64 * 1024,
		ansi_cursor = 4,
		ansi_clear = 2,
		ansi_csi_per_kb = 10,
		ansi_csi_min = 40,
		ansi_cursor_min = 2,
		ansi_nul_ratio_max = 0.20,
	},

	-- ASC may be "ASCII art" that is often actually CP437 art (boxes, blocks), usually no escapes
	-- Slightly higher than .nfo because art is denser and more line-oriented.
	asc = {
		scan_bytes = 32 * 1024,
		oem_p_box = 0.12,
		oem_p_lines = 0.025,
		oem_box_repeats = 12,
	},

	-- FILE_ID.DIZ is short
	-- If it uses boxes it's usually obvious, but keep thresholds a bit forgiving
	diz = {
		scan_bytes = 8 * 1024,
		oem_p_box = 0.09,
		oem_p_lines = 0.015,
		oem_box_repeats = 6,
	},

	-- TXT is ambiguous
	-- Keep conservative thresholds to avoid flagging normal text
	txt = {
		scan_bytes = 16 * 1024,
		oem_p_box = 0.18,
		oem_p_lines = 0.05,
		oem_box_repeats = 20,
	},

	-- VT is a terminal capture (VT100-ish)
	-- Escapes are common, but it's usually not "ANSI art".
	-- Make ansi=true harder to trigger
	-- Also effectively disable OEM (box bytes in UTF-8 would be rare)
	vt = {
		scan_bytes = 64 * 1024,

		-- OEM: basically disable (VT logs rarely CP437 art)
		oem_p_box = 0.25,
		oem_p_lines = 0.08,
		oem_box_repeats = 40,

		-- ANSI art: make harder to trigger
		ansi_cursor = 20,
		ansi_clear = 10,
		ansi_csi_per_kb = 40,
		ansi_csi_min = 120,
		ansi_cursor_min = 10,
	},
}

--- Resolve one scan profile from a filename
-- @param name string filename or path
-- @return table profile copied profile table
function dosdetect.get_scan_profile_for_filename(name)
	local ext = tostring(name or ""):match("%.([%w]+)$")
	ext = ext and ext:lower() or ""

	local profile = {}

	for k, v in pairs(scan_baseline_profile) do
		profile[k] = v
	end

	local override = scan_profiles[ext]

	if override then
		for k, v in pairs(override) do
			profile[k] = v
		end
	end

	return profile
end

-- Detect one valid UTF-8 multibyte sequence and return its byte length
-- Used by the main byte scan so OEM byte heuristics do not misread UTF-8 bytes as CP437 art
-- This very specific job here is: from a raw byte buffer, at a specific byte offset,
-- with a hard max_scan boundary, decide whether there is exactly one valid 2/3/4-byte
-- UTF-8 sequence starting there and return its byte length or 0.
local function utf8_sequence_len(bytes, i, max_scan)
	local b1 = bytes:byte(i)

	if not b1 or b1 < 0xC2 then
		return 0
	end

	local b2 = (i + 1 <= max_scan) and bytes:byte(i + 1) or nil

	if b1 <= 0xDF then
		if b2 and b2 >= 0x80 and b2 <= 0xBF then
			return 2
		end

		return 0
	end

	local b3 = (i + 2 <= max_scan) and bytes:byte(i + 2) or nil

	if b1 <= 0xEF then
		if not (b2 and b3) then
			return 0
		end

		if b1 == 0xE0 then
			if b2 < 0xA0 or b2 > 0xBF then
				return 0
			end
		elseif b1 == 0xED then
			if b2 < 0x80 or b2 > 0x9F then
				return 0
			end
		elseif b2 < 0x80 or b2 > 0xBF then
			return 0
		end

		if b3 < 0x80 or b3 > 0xBF then
			return 0
		end

		return 3
	end

	if b1 > 0xF4 then
		return 0
	end

	local b4 = (i + 3 <= max_scan) and bytes:byte(i + 3) or nil

	if not (b2 and b3 and b4) then
		return 0
	end

	if b1 == 0xF0 then
		if b2 < 0x90 or b2 > 0xBF then
			return 0
		end
	elseif b1 == 0xF4 then
		if b2 < 0x80 or b2 > 0x8F then
			return 0
		end
	elseif b2 < 0x80 or b2 > 0xBF then
		return 0
	end

	if b3 < 0x80 or b3 > 0xBF or b4 < 0x80 or b4 > 0xBF then
		return 0
	end

	return 4
end

--- Detect ANSI and OEM-style text art from raw bytes
-- oem:
-- 	CP437-style box/block text likely present.
-- 	Indicates DOS/scene art using high CP437 box density or long runs.
-- 	Detected via byte range 0xB0–0xDF density and repeated box patterns.
-- escapes:
-- 	ANSI/VT escape sequences present in the stream.
-- 	Indicates colored or controlled terminal output (not necessarily art).
-- 	Detected by scanning for ESC '[' (CSI) sequences.
-- ansi:
-- 	ANSI art / full-screen layout likely.
-- 	Indicates cursor-positioned screen drawing rather than simple color.
-- 	Detected via heavy cursor moves, clears, and high CSI density.
-- binary:
-- 	Sample looks binary rather than text.
-- 	Indicates NUL bytes or too many disallowed control bytes.
-- @param bytes string raw file bytes
-- @param profile table|nil scan profile table
-- @return table result with oem escapes ansi binary booleans and NUL stats
function dosdetect.detect(bytes, profile)
	local n = #bytes

	if n == 0 then
		return { oem = false, escapes = false, ansi = false, binary = false }
	end

	profile = type(profile) == "table" and profile or scan_baseline_profile

	local max_scan = math.min(n, profile.scan_bytes)

	-- OEM/CP437-ish counters
	local count_box_range = 0   -- 0xB0..0xDF (blocks + lines)
	local count_lines = 0       -- likely line drawing
	local count_controls = 0
	local count_box_repeats = 0 -- repeats only within 0xB0..0xDF
	local last, run = nil, 0

	local function flush_run()
		if run >= 6 and last and last >= 0xB0 and last <= 0xDF then
			count_box_repeats = count_box_repeats + (run - 5)
		end
	end

	-- ANSI escape counters
	local esc = 0
	local csi = 0
	local sgr = 0
	local cursor = 0
	local clear = 0
	local nul_count = 0
	local utf8_valid_bytes = 0
	local invalid_high_bytes = 0
	local utf8_skip_until = 0

	local i = 1

	while i <= max_scan do
		local b = bytes:byte(i)

		if b == 0x00 then
			nul_count = nul_count + 1
		end

		-- control chars except TAB/LF/CR/ESC
		-- ESC is part of the terminal language we are trying to detect here
		if b < 0x20 and b ~= 0x09 and b ~= 0x0A and b ~= 0x0D and b ~= 0x1B then
			count_controls = count_controls + 1
		end

		-- Track whether the scanned high-byte stream stays UTF-8-clean throughout the same pass
		if b >= 0x80 and i > utf8_skip_until then
			local utf8_len = utf8_sequence_len(bytes, i, max_scan)

			if utf8_len >= 2 then
				utf8_valid_bytes = utf8_valid_bytes + utf8_len
				utf8_skip_until = i + utf8_len - 1
			else
				invalid_high_bytes = invalid_high_bytes + 1
			end
		end

		-- CP437 box/block region
		if b >= 0xB0 and b <= 0xDF then
			count_box_range = count_box_range + 1
			-- most line drawing chars live here
			if b >= 0xB3 and b <= 0xDA then
				count_lines = count_lines + 1
			end
		end

		-- run detection (────, ████, etc.)
		if b == last then
			run = run + 1
		else
			flush_run()
			last = b
			run = 1
		end

		-- ANSI escape detection
		if b == 0x1B then -- ESC
			esc = esc + 1
			local b2 = (i + 1 <= max_scan) and bytes:byte(i + 1) or nil

			if b2 == 0x5B then -- '[' => CSI
				csi = csi + 1
				local j = i + 2

				while j <= max_scan do
					local bj = bytes:byte(j)

					if (bj >= 0x30 and bj <= 0x39) or bj == 0x3B or bj == 0x3F then
						j = j + 1
					else
						break
					end
				end

				local final = (j <= max_scan) and bytes:byte(j) or nil

				if final then
					if final == 0x6D then
						sgr = sgr + 1
					end

					if final == 0x48 or final == 0x66 or final == 0x41 or final == 0x42 or final == 0x43 or final == 0x44 then
						cursor = cursor + 1 -- H f A B C D
					end

					if final == 0x4A or final == 0x4B then
						clear = clear + 1 -- J K
					end
				end

				i = j + 1
			else
				i = i + 2
			end

			-- early 'ANSI art' confidence
			local scanned = i
			local kb = scanned / 1024.0
			local csi_per_kb = csi / (kb > 0 and kb or 1)

			if cursor >= profile.ansi_cursor
				or clear >= profile.ansi_clear
				or (csi_per_kb >= profile.ansi_csi_per_kb and (cursor >= 2 or clear >= 1))
				or (csi >= profile.ansi_csi_min and cursor >= profile.ansi_cursor_min)
			then
				flush_run()

				local p_box = count_box_range / scanned
				local p_lines = count_lines / scanned
				local control_ratio = count_controls / scanned
				local nul_ratio = nul_count / scanned
				local is_binary = control_ratio > 0.10
				local is_utf8_text = utf8_valid_bytes > 0 and invalid_high_bytes == 0
				local oem = (not is_binary) and (not is_utf8_text)
					and ((p_box >= profile.oem_p_box) or (p_lines >= profile.oem_p_lines) or (count_box_repeats >= profile.oem_box_repeats))
				local allow_ansi_nuls = nul_ratio <= (profile.ansi_nul_ratio_max or 0)

				if is_binary or (nul_count > 0 and not allow_ansi_nuls) then
					return { oem = false, escapes = false, ansi = false, binary = true, nul_count = nul_count, nul_ratio = nul_ratio }
				end

				return { oem = oem, escapes = true, ansi = true, binary = false, nul_count = nul_count, nul_ratio = nul_ratio }
			end
		else
			i = i + 1
		end
	end

	flush_run()

	local scanned = max_scan
	local p_box = count_box_range / scanned
	local p_lines = count_lines / scanned
	local control_ratio = count_controls / scanned
	local nul_ratio = nul_count / scanned
	local is_binary = control_ratio > 0.10 or (nul_count > 0 and nul_ratio > (profile.ansi_nul_ratio_max or 0))
	local is_utf8_text = utf8_valid_bytes > 0 and invalid_high_bytes == 0
	local oem = (not is_binary) and (not is_utf8_text)
		and ((p_box >= profile.oem_p_box) or (p_lines >= profile.oem_p_lines) or (count_box_repeats >= profile.oem_box_repeats))

	local escapes = (esc > 0)

	local kb = scanned / 1024.0
	local csi_per_kb = csi / (kb > 0 and kb or 1)
	local ansi =
		(cursor >= profile.ansi_cursor)
		or (clear >= profile.ansi_clear)
		or (csi_per_kb >= profile.ansi_csi_per_kb and (cursor >= 2 or clear >= 1))
		or (csi >= profile.ansi_csi_min and cursor >= profile.ansi_cursor_min)

	if is_binary then
		return { oem = false, escapes = false, ansi = false, binary = true, nul_count = nul_count, nul_ratio = nul_ratio }
	end

	return { oem = oem, escapes = escapes, ansi = ansi, binary = false, nul_count = nul_count, nul_ratio = nul_ratio }
end

return dosdetect
