---@diagnostic disable: duplicate-set-field,param-type-mismatch
-- 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.

local common = require("core.common")
local config = require("core.config")
local TextEditor = require("core.text.editor")

local white = common.color("#ffffff")
local black = common.color("#000000")
local color = {}
local supported_formats = {
	["format.css"] = true,
	["format.html"] = true,
	["format.sass"] = true,
	["format.svg"] = true,
	["format.md"] = true,
}
local hex_lengths = {
	[3] = true,
	[4] = true,
	[6] = true,
	[8] = true,
}

--- Choose readable text for one color background
-- @param red number red channel
-- @param green number green channel
-- @param blue number blue channel
-- @return table text_color readable text color
local function text_color_for_rgb(red, green, blue)
	local luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue

	return luma < 140 and white or black
end

--- Return one hexadecimal digit value
-- @param byte number|nil ASCII byte
-- @return number|nil value hexadecimal value
local function hex_value(byte)
	if not byte then
		return nil
	end
	if byte >= 48 and byte <= 57 then
		return byte - 48
	end
	if byte >= 65 and byte <= 70 then
		return byte - 55
	end
	if byte >= 97 and byte <= 102 then
		return byte - 87
	end

	return nil
end

--- Return whether one ASCII byte can continue a source-code name
-- @param byte number|nil ASCII byte
-- @return boolean word whether the byte is a letter, digit, or underscore
local function is_word_byte(byte)
	if not byte then
		return false
	end

	local number = byte >= 48 and byte <= 57
	local uppercase = byte >= 65 and byte <= 90
	local lowercase = byte >= 97 and byte <= 122

	return number or uppercase or byte == 95 or lowercase
end

--- Return whether a hash belongs to a URL fragment
-- @param text string full logical line
-- @param start_col number hash byte column
-- @return boolean fragment whether the token begins inside a URL
local function is_url_fragment(text, start_col)
	local token_start = start_col - 1
	while token_start > 0 and text:byte(token_start) > 32 do
		token_start = token_start - 1
	end

	local marker = text:find("://", token_start + 1, true)

	return marker ~= nil and marker < start_col
end

--- Convert optional CSS alpha text to a renderer byte
-- @param value string|nil captured alpha value
-- @return number alpha renderer byte
local function parse_alpha(value)
	if value == nil then
		return 255
	end

	local percent = value:sub(-1) == "%"
	local number = tonumber(percent and value:sub(1, -2) or value) or 1
	if percent then
		number = number * 255 / 100
	elseif number <= 1 then
		number = number * 255
	end

	return common.clamp(common.round(number), 0, 255)
end

--- Store one parsed color token
-- @param matches table destination match list
-- @param start_col number first token byte
-- @param end_col number byte after the token
-- @param red number red channel
-- @param green number green channel
-- @param blue number blue channel
-- @param alpha number alpha channel
local function add_match(matches, start_col, end_col, red, green, blue, alpha)
	matches[#matches + 1] = {
		start_col,
		end_col,
		red,
		green,
		blue,
		alpha,
		text_color_for_rgb(red, green, blue),
	}
end

--- Find hexadecimal color tokens with one literal scan
-- @param text string full logical line
-- @param matches table destination match list
local function find_hex_colors(text, matches)
	local search_from = 1
	while true do
		local start_col = text:find("#", search_from, true)
		if not start_col then
			return
		end

		search_from = start_col + 1

		local digit_count = 0
		while digit_count < 9 and hex_value(text:byte(start_col + digit_count + 1)) do
			digit_count = digit_count + 1
		end

		local previous = text:byte(start_col - 1)
		local following = text:byte(start_col + digit_count + 1)
		local boundaries_valid = not is_word_byte(previous)
		boundaries_valid = boundaries_valid and previous ~= 38 -- '&' starts an HTML entity
		boundaries_valid = boundaries_valid and previous ~= 91 -- '[' commonly starts an attribute selector
		boundaries_valid = boundaries_valid and not is_word_byte(following)
		if hex_lengths[digit_count] and boundaries_valid and not is_url_fragment(text, start_col) then
			local first = hex_value(text:byte(start_col + 1))
			local second = hex_value(text:byte(start_col + 2))
			local red, green, blue, alpha

			if digit_count <= 4 then
				red = first * 17
				green = second * 17
				blue = hex_value(text:byte(start_col + 3)) * 17
				alpha = digit_count == 4 and hex_value(text:byte(start_col + 4)) * 17 or 255
			else
				red = first * 16 + second
				green = hex_value(text:byte(start_col + 3)) * 16 + hex_value(text:byte(start_col + 4))
				blue = hex_value(text:byte(start_col + 5)) * 16 + hex_value(text:byte(start_col + 6))
				if digit_count == 8 then
					alpha = hex_value(text:byte(start_col + 7)) * 16 + hex_value(text:byte(start_col + 8))
				else
					alpha = 255
				end
			end

			add_match(matches, start_col, start_col + digit_count + 1, red, green, blue, alpha)
		end
	end
end

--- Parse the channels inside one rgb or rgba function
-- @param contents string text between the parentheses
-- @return number|nil red red channel
-- @return number|nil green green channel
-- @return number|nil blue blue channel
-- @return number|nil alpha alpha channel
local function parse_rgb_channels(contents)
	-- Pattern breakdown: three comma-separated channels and an alpha value after ',' or '/'
	local red, green, blue, alpha = contents:match(
		"^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*[,/]%s*([%d%.]+%%?)%s*$"
	)
	if not red then
		-- Pattern breakdown: three space-separated channels and an alpha value after '/'
		red, green, blue, alpha = contents:match(
			"^%s*(%d+)%s+(%d+)%s+(%d+)%s*/%s*([%d%.]+%%?)%s*$"
		)
	end
	if not red then
		-- Pattern breakdown: exactly three comma-separated decimal channels
		red, green, blue = contents:match("^%s*(%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*$")
	end
	if not red then
		-- Pattern breakdown: exactly three space-separated decimal channels
		red, green, blue = contents:match("^%s*(%d+)%s+(%d+)%s+(%d+)%s*$")
	end
	if not red then
		return nil
	end

	return tonumber(red), tonumber(green), tonumber(blue), parse_alpha(alpha)
end

--- Find rgb and rgba color tokens with one literal scan
-- @param text string full logical line
-- @param matches table destination match list
local function find_rgb_colors(text, matches)
	local search_from = 1
	while true do
		local start_col = text:find("rgb", search_from, true)
		if not start_col then
			return
		end

		search_from = start_col + 3
		if not is_word_byte(text:byte(start_col - 1)) then
			local open_col = start_col + 3
			if text:byte(open_col) == 97 then -- optional 'a' in 'rgba'
				open_col = open_col + 1
			end

			if text:byte(open_col) == 40 then -- '('
				local end_col = text:find(")", open_col + 1, true)
				if end_col and not is_word_byte(text:byte(end_col + 1)) then
					local contents = text:sub(open_col + 1, end_col - 1)
					local red, green, blue, alpha = parse_rgb_channels(contents)
					if red then
						add_match(matches, start_col, end_col + 1, red, green, blue, alpha)
					end
				end
			end
		end
	end
end

--- Return cached color tokens for one unchanged line
-- @param view table active text editor
-- @param line number logical line index
-- @param text string full logical line
-- @return table matches parsed color tokens
local function get_matches(view, line, text)
	local cache = view._inline_color_cache
	if not cache or cache.doc ~= view.doc then
		cache = { doc = view.doc, lines = {} }
		view._inline_color_cache = cache
	end

	local cached = cache.lines[line]
	if cached and cached.text == text then
		return cached.matches
	end

	local matches = {}
	find_hex_colors(text, matches)
	find_rgb_colors(text, matches)
	cache.lines[line] = { text = text, matches = matches }

	return matches
end

--- Return whether a selection suppresses inline colors on one logical line
-- @param view table active text editor
-- @param line number logical line index
-- @return boolean hidden whether the preview should be hidden
local function selection_hides_inline_colors(view, line)
	if not view.doc:has_selection() then
		return false
	end

	local line1, _, line2 = view.doc:get_selection(true)

	return line >= line1 and line <= line2
end

--- Draw one color token segment on a visible visual row
-- @param view table active text editor
-- @param text string full logical line
-- @param line number logical line index
-- @param match table parsed color token
-- @param start_col number segment start byte column
-- @param end_col number segment end byte column
-- @param x number text origin x
-- @param y number visual row y
-- @param font userdata active editor font
-- @param text_y_offset number text offset within the row
-- @param line_height number visual row height
-- @param padding number horizontal color padding
local function draw_color_segment(
	view,
	text,
	line,
	match,
	start_col,
	end_col,
	x,
	y,
	font,
	text_y_offset,
	line_height,
	padding
)
	local x1 = x + view:get_visual_position_x(line, start_col)
	local x2 = x + view:get_visual_position_x(line, end_col, true)
	if x2 <= x1 then
		return
	end

	color[1], color[2], color[3], color[4] = match[3], match[4], match[5], match[6]
	renderer.draw_rect(x1 - padding, y, x2 - x1 + padding * 2, line_height, color)
	renderer.draw_text(font, text:sub(start_col, end_col - 1), x1, y + text_y_offset, match[7])
end

--- Draw one parsed color token across its visible wrapped rows
-- @param view table active text editor
-- @param text string full logical line
-- @param line number logical line index
-- @param match table parsed color token
-- @param first_row number first visible visual row
-- @param last_row number last visible visual row
-- @param x number text origin x
-- @param y number first visible row y
-- @param font userdata active editor font
-- @param text_y_offset number text offset within the row
-- @param line_height number visual row height
-- @param padding number horizontal color padding
local function draw_wrapped_color(
	view,
	text,
	line,
	match,
	first_row,
	last_row,
	x,
	y,
	font,
	text_y_offset,
	line_height,
	padding
)
	local token_first_row = view:get_visual_row_for_position(line, match[1])
	local token_last_row = view:get_visual_row_for_position(line, match[2], true)
	local draw_first_row = math.max(first_row, token_first_row)
	local draw_last_row = math.min(last_row, token_last_row)

	for row = draw_first_row, draw_last_row do
		local row_info = view:get_visual_row_info(row)
		local start_col = math.max(match[1], row_info.start_col)
		local end_col = math.min(match[2], row_info.end_col)
		if end_col > start_col then
			draw_color_segment(
				view,
				text,
				line,
				match,
				start_col,
				end_col,
				x,
				y + (row - first_row) * line_height,
				font,
				text_y_offset,
				line_height,
				padding
			)
		end
	end
end

local draw_line_overlay = TextEditor.draw_line_overlay

--- Draw cached inline colors through the shared visual-row overlay phase
-- @param idx number logical line index
-- @param first_row number first visible visual row
-- @param last_row number last visible visual row
-- @param x number text origin x
-- @param y number first visible row y
function TextEditor:draw_line_overlay(idx, first_row, last_row, x, y)
	draw_line_overlay(self, idx, first_row, last_row, x, y)

	local format_name = self.doc.format and self.doc.format._module_name
	if not supported_formats[format_name] then
		return
	end

	local text = self.doc.lines[idx] or ""
	if #text > (config.long_line_threshold or 5000) or selection_hides_inline_colors(self, idx) then
		return
	end

	local matches = get_matches(self, idx, text)
	if #matches == 0 then
		return
	end

	local font = self:get_font()
	local text_y_offset = self:get_line_text_y_offset()
	local line_height = self:get_line_height()
	local padding = math.max(1, SCALE)
	local wrap_enabled = self:is_wrap_enabled()

	for _, match in ipairs(matches) do
		if wrap_enabled then
			draw_wrapped_color(
				self,
				text,
				idx,
				match,
				first_row,
				last_row,
				x,
				y,
				font,
				text_y_offset,
				line_height,
				padding
			)
		else
			draw_color_segment(
				self,
				text,
				idx,
				match,
				match[1],
				match[2],
				x,
				y,
				font,
				text_y_offset,
				line_height,
				padding
			)
		end
	end
end
