-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- ANSi escape replay for static art files
-- @module plugins.ansipreview.replay

local common = require("core.common")

local replay = {}

local DEFAULT_COLUMNS = 80
local DEFAULT_ROWS = 25
local MAX_CSI_ROW_COUNT = DEFAULT_ROWS
local TAB_WIDTH = 8

replay.DEFAULT_COLUMNS = DEFAULT_COLUMNS
replay.DEFAULT_ROWS = DEFAULT_ROWS

-- Show visible CP437 symbols stored in the low byte range
local USE_CP437_LOW_GLYPHS = true

-- Stop at the DOS end-of-file marker
local TREAT_SUB_AS_EOF = true

-- CP437 stores visible DOS symbols in part of the low control-byte range
-- Real terminal controls are still handled separately in the replay loop below
local LOW_CP437_GLYPHS = {
	[0x01] = "☺",
	[0x02] = "☻",
	[0x03] = "♥",
	[0x04] = "♦",
	[0x05] = "♣",
	[0x06] = "♠",
	[0x07] = "•",
	[0x08] = "◘",
	[0x09] = "○",
	[0x0A] = "◙",
	[0x0B] = "♂",
	[0x0C] = "♀",
	[0x0D] = "♪",
	[0x0E] = "♫",
	[0x0F] = "☼",
	[0x10] = "►",
	[0x11] = "◄",
	[0x12] = "↕",
	[0x13] = "‼",
	[0x14] = "¶",
	[0x15] = "§",
	[0x16] = "▬",
	[0x17] = "↨",
	[0x18] = "↑",
	[0x19] = "↓",
	[0x1A] = "→",
	[0x1B] = "←",
	[0x1C] = "∟",
	[0x1D] = "↔",
	[0x1E] = "▲",
	[0x1F] = "▼",
}

--- Build the default ANSi cell attribute set
-- @return table attributes
local function new_attr()
	return {
		fg = 7,
		bg = 0,
		bold = false,
		inverse = false,
		hidden = false,
	}
end

--- Copy one cell attribute table for cell-local storage
-- @param attr table source attributes
-- @return table copied attributes
local function copy_attr(attr)
	return {
		fg = attr.fg,
		bg = attr.bg,
		bold = attr.bold,
		inverse = attr.inverse,
		hidden = attr.hidden,
	}
end

--- Build a blank ANSi cell
-- @param attr table current attributes
-- @return table cell
local function new_cell(attr)
	return {
		text = " ",
		attr = copy_attr(attr),
	}
end

--- Ensure one replay row exists and is wide enough
-- @param screen table replay state
-- @param row number row index
-- @param col number last needed column
-- @param attr_override table|nil optional blank-cell attributes
local function ensure_row(screen, row, col, attr_override)
	local cells = screen.rows[row]

	if not cells then
		cells = {}
		screen.rows[row] = cells
	end

	local fill_attr = attr_override or new_attr()

	while #cells < col do
		cells[#cells + 1] = new_cell(fill_attr)
	end
end

--- Apply column-sentinel wrapping before processing the next byte
-- @param screen table replay state
local function advance_wrap_if_needed(screen)
	if screen.cursor_col <= screen.columns then
		return
	end
	if screen.wrap_mode == false then
		-- No-wrap terminals keep replacing the final cell instead of growing the row
		screen.cursor_col = screen.columns
		return
	end

	screen.cursor_row = math.max(1, screen.cursor_row + 1)
	screen.cursor_col = 1
end

--- Return the next fixed-width terminal tab stop
-- @param col number current one-based column
-- @return number next_col
local function get_next_tab_stop(col)
	return col + TAB_WIDTH - ((col - 1) % TAB_WIDTH)
end

--- Write one visible glyph into the replay grid
-- @param screen table replay state
-- @param text string visible glyph
local function put_char(screen, text)
	advance_wrap_if_needed(screen)

	local row = math.max(1, screen.cursor_row)
	local col = math.max(1, screen.cursor_col)

	ensure_row(screen, row, col)

	screen.rows[row][col] = {
		text = text,
		attr = copy_attr(screen.current_attr),
	}
	screen.cursor_row = row
	screen.max_row = math.max(screen.max_row, row)
	screen.max_col = math.max(screen.max_col, col)
	screen.cursor_col = col + 1
end

--- Clear one inclusive row segment using current blank attributes
-- @param screen table replay state
-- @param row number row index
-- @param from_col number first cleared col
-- @param to_col number last cleared col
local function clear_row_range(screen, row, from_col, to_col)
	from_col = common.clamp(from_col, 1, screen.columns)
	to_col = common.clamp(to_col, from_col, screen.columns)
	ensure_row(screen, row, to_col, screen.current_attr)

	for col = from_col, to_col do
		screen.rows[row][col] = new_cell(screen.current_attr)
	end
end

--- Erase rows in one inclusive range
-- @param screen table replay state
-- @param from_row number first row
-- @param to_row number last row
local function clear_rows(screen, from_row, to_row)
	from_row = math.max(1, from_row)
	to_row = math.max(from_row, to_row)

	for row = from_row, to_row do
		clear_row_range(screen, row, 1, screen.columns)
	end
end

--- Normalize numeric CSI parameters within a safe command limit
-- @param value string|number|nil raw value
-- @param default number fallback
-- @param maximum number largest accepted value
-- @return number normalized value
local function csi_number(value, default, maximum)
	local number = tonumber(value)

	if not number or number <= 0 or number ~= number then
		return default
	end

	if number >= maximum then
		return maximum
	end

	return math.floor(number)
end

--- Move cursor to a valid replay location
-- @param screen table replay state
-- @param row number target row
-- @param col number target col
local function set_cursor(screen, row, col)
	screen.cursor_row = math.max(1, row)
	screen.cursor_col = common.clamp(col, 1, screen.columns + 1)
	screen.max_row = math.max(screen.max_row, screen.cursor_row)
	screen.max_col = math.max(screen.max_col, screen.cursor_col)
end

--- Return the furthest row CSI cursor commands may address
-- Explicit cursor movement stays inside the terminal extent already reached by replay
-- @param screen table replay state
-- @return number row_limit
local function get_csi_row_limit(screen)
	return math.max(DEFAULT_ROWS, screen.max_row)
end

--- Move a CSI-controlled cursor within the current terminal extent
-- @param screen table replay state
-- @param row number target row
-- @param col number target col
local function set_csi_cursor(screen, row, col)
	set_cursor(screen, common.clamp(row, 1, get_csi_row_limit(screen)), col)
end

--- Split one semicolon-delimited CSI parameter list
-- Keeps empty parameters so defaults follow terminal rules
-- @param text string raw parameter text
-- @return table params
local function split_params(text)
	if text == "" then
		return {}
	end

	local params = {}

	for item in (text .. ";"):gmatch("(.-);") do
		params[#params + 1] = item
	end

	return params
end

--- Parse one CSI body while preserving private-mode markers
-- ANSi art sometimes uses private mode sequences like '?7h' for wrap control
-- @param text string raw parameter text
-- @return table params
-- @return boolean is_private
local function parse_csi_params(text)
	local is_private = text:sub(1, 1) == "?"

	if is_private then
		text = text:sub(2)
	end

	return split_params(text), is_private
end

--- Apply one SGR parameter sequence to the current attributes
-- Supports standard, bright, 256-color, and truecolor paths
-- @param attr table current attributes
-- @param params table parsed SGR params
local function apply_sgr(attr, params)
	if #params == 0 then
		params = { "0" }
	end

	local index = 1

	while index <= #params do
		local code = tonumber(params[index]) or 0

		if code == 0 then
			attr.fg = 7
			attr.bg = 0
			attr.bold = false
			attr.inverse = false
			attr.hidden = false
		elseif code == 1 then
			attr.bold = true
		elseif code == 7 then
			attr.inverse = true
		elseif code == 8 then
			attr.hidden = true
		elseif code == 21 or code == 22 then
			attr.bold = false
		elseif code == 27 then
			attr.inverse = false
		elseif code == 28 then
			attr.hidden = false
		elseif code >= 30 and code <= 37 then
			attr.fg = code - 30
		elseif code == 39 then
			attr.fg = 7
		elseif code >= 40 and code <= 47 then
			attr.bg = code - 40
		elseif code == 49 then
			attr.bg = 0
		elseif code >= 90 and code <= 97 then
			attr.fg = code - 90 + 8
		elseif code >= 100 and code <= 107 then
			attr.bg = code - 100 + 8
		elseif (code == 38 or code == 48) and params[index + 1] == "5" then
			local palette_index = tonumber(params[index + 2])
			if palette_index then
				if code == 38 then
					attr.fg = common.clamp(math.floor(palette_index), 0, 255)
				else
					attr.bg = common.clamp(math.floor(palette_index), 0, 255)
				end
			end
			index = index + 2
		elseif (code == 38 or code == 48) and params[index + 1] == "2" then
			local red = tonumber(params[index + 2]) or 0
			local green = tonumber(params[index + 3]) or 0
			local blue = tonumber(params[index + 4]) or 0
			local color = {
				common.clamp(math.floor(red), 0, 255),
				common.clamp(math.floor(green), 0, 255),
				common.clamp(math.floor(blue), 0, 255),
				255,
			}

			if code == 38 then
				attr.fg = color
			else
				attr.bg = color
			end
			index = index + 4
		end

		index = index + 1
	end
end

--- Apply one parsed CSI control sequence
-- @param screen table replay state
-- @param final string final CSI command byte
-- @param params table parameter list
-- @param is_private boolean whether the sequence uses a private marker
local function apply_csi(screen, final, params, is_private)
	if final == "A" then
		-- 'CUU' moves the cursor up without drawing
		set_csi_cursor(
			screen,
			screen.cursor_row - csi_number(params[1], 1, MAX_CSI_ROW_COUNT),
			screen.cursor_col
		)
	elseif final == "B" then
		-- 'CUD' moves the cursor down without drawing
		set_csi_cursor(
			screen,
			screen.cursor_row + csi_number(params[1], 1, MAX_CSI_ROW_COUNT),
			screen.cursor_col
		)
	elseif final == "C" then
		-- 'CUF' moves right but should stop on the last visible ANSi column
		-- Only printable writes should advance into the wrap sentinel column
		set_csi_cursor(
			screen,
			screen.cursor_row,
			math.min(screen.columns, screen.cursor_col + csi_number(params[1], 1, screen.columns))
		)
	elseif final == "D" then
		-- 'CUB' moves left without drawing
		set_csi_cursor(
			screen,
			screen.cursor_row,
			math.max(1, screen.cursor_col - csi_number(params[1], 1, screen.columns))
		)
	elseif final == "E" then
		-- 'CNL' moves down and returns to column 1
		set_csi_cursor(screen, screen.cursor_row + csi_number(params[1], 1, MAX_CSI_ROW_COUNT), 1)
	elseif final == "F" then
		-- 'CPL' moves up and returns to column 1
		set_csi_cursor(
			screen,
			math.max(1, screen.cursor_row - csi_number(params[1], 1, MAX_CSI_ROW_COUNT)),
			1
		)
	elseif final == "G" then
		-- 'CHA' jumps to an absolute column on the current row
		set_csi_cursor(screen, screen.cursor_row, csi_number(params[1], 1, screen.columns))
	elseif final == "H" or final == "f" then
		-- 'CUP'/'HVP' jumps to an absolute row and column
		set_csi_cursor(
			screen,
			csi_number(params[1], 1, get_csi_row_limit(screen)),
			csi_number(params[2], 1, screen.columns)
		)
	elseif final == "J" then
		-- 'ED' erases display cells and paints blanks using current attributes
		local mode = tonumber(params[1]) or 0
		if mode == 0 then
			clear_row_range(screen, screen.cursor_row, screen.cursor_col, screen.columns)
			clear_rows(screen, screen.cursor_row + 1, math.max(screen.max_row, DEFAULT_ROWS))
		elseif mode == 1 then
			clear_rows(screen, 1, screen.cursor_row - 1)
			clear_row_range(screen, screen.cursor_row, 1, screen.cursor_col)
		else
			clear_rows(screen, 1, math.max(screen.max_row, DEFAULT_ROWS))
		end
	elseif final == "K" then
		-- 'EL' erases line cells and paints blanks using current attributes
		local mode = tonumber(params[1]) or 0
		if mode == 1 then
			clear_row_range(screen, screen.cursor_row, 1, screen.cursor_col)
		elseif mode == 2 then
			clear_row_range(screen, screen.cursor_row, 1, screen.columns)
		else
			clear_row_range(screen, screen.cursor_row, screen.cursor_col, screen.columns)
		end
	elseif final == "L" then
		-- 'IL' inserts blank rows at the cursor row
		local count = csi_number(params[1], 1, MAX_CSI_ROW_COUNT)
		local row = math.max(1, screen.cursor_row)
		local row_limit = get_csi_row_limit(screen)

		for target_row = row_limit, row, -1 do
			local source_row = target_row - count
			screen.rows[target_row] = source_row >= row and screen.rows[source_row] or {}
		end
	elseif final == "M" then
		-- 'DL' deletes rows starting at the cursor row
		local count = csi_number(params[1], 1, MAX_CSI_ROW_COUNT)
		local row = math.max(1, screen.cursor_row)
		local row_limit = get_csi_row_limit(screen)
		count = math.min(count, math.max(0, row_limit - row + 1))

		for target_row = row, row_limit do
			local source_row = target_row + count
			screen.rows[target_row] = source_row <= row_limit and screen.rows[source_row] or nil
		end
	elseif final == "P" then
		-- 'DCH' deletes characters and shifts the rest of the row left
		local row = math.max(1, screen.cursor_row)
		local count = csi_number(params[1], 1, screen.columns)
		count = math.min(count, math.max(0, screen.columns - screen.cursor_col + 1))
		ensure_row(screen, row, screen.columns)
		for _ = 1, count do
			table.remove(screen.rows[row], screen.cursor_col)
				screen.rows[row][screen.columns] = new_cell(screen.current_attr)
			end
	elseif final == "X" then
		-- 'ECH' erases characters in place using current attributes
		local count = csi_number(params[1], 1, screen.columns)
		local to_col = math.min(screen.columns, screen.cursor_col + count - 1)
		clear_row_range(screen, screen.cursor_row, screen.cursor_col, to_col)
	elseif final == "@" then
		-- 'ICH' inserts blank characters and shifts the row right
		local row = math.max(1, screen.cursor_row)
		local count = csi_number(params[1], 1, screen.columns)
		count = math.min(count, math.max(0, screen.columns - screen.cursor_col + 1))
		ensure_row(screen, row, screen.columns)
		for _ = 1, count do
			table.insert(screen.rows[row], screen.cursor_col, new_cell(screen.current_attr))
				table.remove(screen.rows[row], screen.columns + 1)
			end
	elseif final == "m" then
		-- 'SGR' updates the active foreground, background, and text attributes
		apply_sgr(screen.current_attr, params)
	elseif is_private and final == "h" then
		if tonumber(params[1]) == 7 then
			-- DEC private mode '?7h' enables wrap-at-edge behavior
			screen.wrap_mode = true
		end
	elseif is_private and final == "l" then
		if tonumber(params[1]) == 7 then
			-- DEC private mode '?7l' disables wrap-at-edge behavior
			screen.wrap_mode = false
			screen.cursor_col = common.clamp(screen.cursor_col, 1, screen.columns)
		end
	elseif final == "s" then
		-- Save the current cursor so later 'u' can restore it
		screen.saved_cursor = { row = screen.cursor_row, col = screen.cursor_col }
	elseif final == "u" then
		-- Restore the last cursor saved by 's' or ESC 7
		local saved = screen.saved_cursor or { row = 1, col = 1 }
		set_cursor(screen, saved.row, saved.col)
	end
end

--- Return the next UTF-8 glyph and following byte index
-- Replay expects the decoded doc text to already contain stable display glyphs
-- @param text string source text
-- @param index number byte index
-- @return string glyph
-- @return number next index
local function next_glyph(text, index)
	local byte = text:byte(index)

	if not byte then
		return nil, index
	end

	if byte < 0x80 then
		return string.char(byte), index + 1
	end

	local next_index = utf8.offset(text, 2, index)
	if not next_index then
		next_index = #text + 1
	end

	local glyph = text:sub(index, next_index - 1)
	if glyph ~= "" then
		return glyph, next_index
	end

	return nil, index + 1
end

--- Map one low CP437 byte to a visible DOS glyph when enabled
-- This is only for bytes the replay loop would otherwise treat as dropped controls
-- @param byte number raw byte value
-- @return string|nil glyph
local function get_low_cp437_glyph(byte)
	if not USE_CP437_LOW_GLYPHS then
		return nil
	end

	return LOW_CP437_GLYPHS[byte]
end

--- Replay one ANSi-like text buffer into a virtual screen
-- @param text string decoded document text
-- @return table replayed screen
function replay.run(text)
	local screen = {
		rows = {},
		columns = DEFAULT_COLUMNS,
		cursor_row = 1,
		cursor_col = 1,
		wrap_mode = true,
		max_row = DEFAULT_ROWS,
		max_col = DEFAULT_COLUMNS,
		current_attr = new_attr(),
		saved_cursor = { row = 1, col = 1 },
	}

	local index = 1
	local length = #text

	while index <= length do
		local byte = text:byte(index)

		if byte == 27 then
			local next_byte = text:byte(index + 1)

			if next_byte == 91 then
				local tail = index + 2
				while tail <= length do
					local final = text:byte(tail)
					if final and final >= 0x40 and final <= 0x7E then
						-- CSI sequences mutate cursor or attributes without drawing glyphs
						local body = text:sub(index + 2, tail - 1)
						local params, is_private = parse_csi_params(body)
						apply_csi(screen, string.char(final), params, is_private)
						index = tail + 1
						break
					end
					tail = tail + 1
				end
				if tail > length then
					break
				end
			elseif next_byte == 55 then
				-- 'ESC 7' saves the cursor in classic terminal syntax
				screen.saved_cursor = { row = screen.cursor_row, col = screen.cursor_col }
				index = index + 2
			elseif next_byte == 56 then
				-- 'ESC 8' restores the cursor saved by 'ESC 7'
				set_cursor(screen, screen.saved_cursor.row or 1, screen.saved_cursor.col or 1)
				index = index + 2
			elseif next_byte == 93 then
				-- Skip OSC sequences fully because they affect terminal metadata, not ANSi art
				local tail = index + 2
				while tail <= length do
					local osc_byte = text:byte(tail)
					if osc_byte == 7 then
						break
					end
					if osc_byte == 27 and text:byte(tail + 1) == 92 then
						tail = tail + 1
						break
					end
					tail = tail + 1
				end
				index = math.min(length + 1, tail + 1)
			else
				-- Keep unsupported two-byte commands out of artwork
				if next_byte and next_byte >= 0x30 and next_byte <= 0x7E then
					index = index + 2
				else
					-- C0 controls after ESC still keep their normal replay behavior
					index = index + 1
				end
			end
		elseif byte == 13 then
			-- 'CR' returns to column one without advancing a row
			screen.cursor_col = 1
			index = index + 1
		elseif byte == 10 then
			-- 'LF' advances one row while preserving the current column
			set_cursor(screen, screen.cursor_row + 1, screen.cursor_col)
			index = index + 1
		elseif byte == 0 then
			-- Some ANSi files use NUL bytes as real blank cells for horizontal layout
			put_char(screen, " ")
			index = index + 1
		elseif byte == 26 then
			-- DOS text files sometimes store trailing data after a 'SUB' EOF marker
			if TREAT_SUB_AS_EOF then
				break
			end

			index = index + 1
		elseif byte == 8 then
			-- 'BS' moves the cursor one cell left without erasing
			screen.cursor_col = math.max(1, screen.cursor_col - 1)
			index = index + 1
		elseif byte == 9 then
			-- 'TAB' advances the cursor to the next fixed 8-column tab stop
			screen.cursor_col = math.min(screen.columns + 1, get_next_tab_stop(screen.cursor_col))
			index = index + 1
		elseif byte < 32 then
			-- Remaining low bytes can be visible CP437 symbols in DOS art files
			local glyph = get_low_cp437_glyph(byte)

			if glyph then
				put_char(screen, glyph)
			end

			index = index + 1
		elseif byte >= 32 then
			local glyph, next_index = next_glyph(text, index)
			put_char(screen, glyph or "?")
			index = next_index
		else
			index = index + 1
		end
	end

	return screen
end

return replay
