---@diagnostic disable: duplicate-set-field
-- lx-bundle:true

local core = require("core")
local style = require("core.style")
local command = require("core.command")
local keymap = require("core.keymap")
local DocView = require("core.text.editor")

local bracket_maps = {
	-- [     ]    (     )    {      }
	{ [91] = 93, [40] = 41, [123] = 125, step = 1 },
	-- ]     [    )     (    }      {
	{ [93] = 91, [41] = 40, [125] = 123, step = -1 },
}

--- Return ignored string and comment ranges for one line
-- @param doc table text document
-- @param line number line index
-- @param cache table ranges cached for this scan
-- @return table ranges half-open byte ranges
local function get_ignored_ranges(doc, line, cache)
	if cache[line] then
		return cache[line]
	end

	local ranges = {}
	cache[line] = ranges
	if not doc.highlighter then
		return ranges
	end

	local start_col = 1
	for _, token_type, token_text in doc.highlighter:each_token(line) do
		local end_col = start_col + #token_text
		if token_type == "string" or token_type == "comment" or token_type == "comment2" then
			ranges[#ranges + 1] = { start_col, end_col }
		end
		start_col = end_col
	end

	return ranges
end

--- Return whether one byte belongs to an ordered syntax range
-- Uses binary search for the isolated cursor checks before a directional scan
-- @param ranges table ordered half-open byte ranges
-- @param col number byte column
-- @return boolean ignored
local function ranges_contain(ranges, col)
	local low = 1
	local high = #ranges
	local candidate = nil

	while low <= high do
		local middle = math.floor((low + high) / 2)
		if ranges[middle][1] <= col then
			candidate = ranges[middle]
			low = middle + 1
		else
			high = middle - 1
		end
	end

	return candidate ~= nil and col < candidate[2]
end

--- Return whether one byte belongs to string or comment syntax
-- @param doc table text document
-- @param line number line index
-- @param col number byte column
-- @param cache table ranges cached for this scan
-- @return boolean ignored
local function is_ignored_position(doc, line, col, cache)
	return ranges_contain(get_ignored_ranges(doc, line, cache), col)
end

--- Advance one ordered range cursor and test a monotonic scan position
-- @param ranges table ordered half-open byte ranges
-- @param range_index number current range index
-- @param col number byte column
-- @param step number scan direction
-- @return boolean ignored
-- @return number range_index advanced range index
local function scan_ranges_contain(ranges, range_index, col, step)
	local range = ranges[range_index]
	if step > 0 then
		while range and col >= range[2] do
			range_index = range_index + 1
			range = ranges[range_index]
		end
	else
		while range and col < range[1] do
			range_index = range_index - 1
			range = ranges[range_index]
		end
	end

	return range ~= nil and col >= range[1] and col < range[2], range_index
end

--- Scan from one bracket until matching depth returns to zero
-- @param doc table text document
-- @param line number start line
-- @param col number start byte column
-- @param line_limit number maximum lines
-- @param open_byte number opening bracket byte
-- @param close_byte number closing bracket byte
-- @param step number scan direction
-- @param ignored_ranges table syntax ranges cached for this scan
-- @return number|nil line matching line
-- @return number|nil col matching byte column
local function get_matching_bracket(doc, line, col, line_limit, open_byte, close_byte, step, ignored_ranges)
	local end_line = line + line_limit * step
	local depth = 0
	local range_line = nil
	local ranges = nil
	local range_index = nil

	while line ~= end_line do
		local byte = doc.lines[line]:byte(col)
		local ignored = false
		if byte == open_byte or byte == close_byte then
			if range_line ~= line then
				range_line = line
				ranges = get_ignored_ranges(doc, line, ignored_ranges)
				range_index = step > 0 and 1 or #ranges
			end
			ignored, range_index = scan_ranges_contain(ranges, range_index, col, step)
		end
		if byte == open_byte and not ignored then
			depth = depth + 1
		elseif byte == close_byte and not ignored then
			depth = depth - 1
			if depth == 0 then
				return line, col
			end
		end

		local prev_line, prev_col = line, col
		line, col = doc:position_offset(line, col, step)
		if line == prev_line and col == prev_col then
			break
		end
	end
end

-- Cached bracket match state for the current cursor and document revision
local state = {}

--- Recompute bracket match state around the active cursor
-- @param line_limit number|nil maximum scanned lines
local function update_state(line_limit)
	line_limit = line_limit or math.huge

	-- Reset when no text document with cursor helpers is active
	local active_view = core.active_view
	local doc = active_view and active_view.doc

	if
		not doc
		or type(doc.get_selection) ~= "function"
		or type(doc.position_offset) ~= "function"
		or type(doc.get_change_id) ~= "function"
	then
		state = {}
		return
	end

	-- Early exit when cursor and document revision are unchanged
	local line, col = doc:get_selection()
	local change_id = doc:get_change_id()
	if
		state.doc == doc
		and state.line == line
		and state.col == col
		and state.change_id == change_id
		and state.limit == line_limit
	then
		return
	end

	-- Check current byte and previous byte so cursor on either side of bracket works
	local line2, col2
	local ignored_ranges = {}
	for _, map in ipairs(bracket_maps) do
		for i = 0, -1, -1 do
			local line, col = doc:position_offset(line, col, i)
			local open = doc.lines[line]:byte(col)
			local close = map[open]
			if close and not is_ignored_position(doc, line, col, ignored_ranges) then
				line2, col2 = get_matching_bracket(
					doc,
					line,
					col,
					line_limit,
					open,
					close,
					map.step,
					ignored_ranges
				)
				goto found
			end
		end
	end
	::found::

	-- Store latest lookup snapshot
	state = {
		change_id = change_id,
		doc = doc,
		line = line,
		col = col,
		line2 = line2,
		col2 = col2,
		limit = line_limit,
	}
end

local update = DocView.update

-- Updates inherited doc view behavior and then refreshes bracket match state
function DocView:update(...)
	update(self, ...)
	update_state(100)
end

local draw_line_overlay = DocView.draw_line_overlay

--- Draw the paired-bracket underline on its visible visual row
-- @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 DocView:draw_line_overlay(idx, first_row, last_row, x, y)
	draw_line_overlay(self, idx, first_row, last_row, x, y)

	if self.doc ~= state.doc or idx ~= state.line2 then
		return
	end

	local match_row = self:get_visual_row_for_position(idx, state.col2)
	if match_row < first_row or match_row > last_row then
		return
	end

	local color = style.bracketmatch_color or style.syntax["function"]
	local x1 = x + self:get_visual_position_x(idx, state.col2)
	local character_width = self:get_col_x_offset(idx, state.col2 + 1) - self:get_col_x_offset(idx, state.col2)
	local h = math.ceil(1 * SCALE)
	local row_y = y + (match_row - first_row) * self:get_line_height()

	renderer.draw_rect(x1, row_y + self:get_line_height() - h, character_width, h, color)
end

command.register({
	-- Moves cursor directly to the matching bracket when available
	["bracket-match:move-to-matching"] = {
		requires = { "doc", "select" },
		perform = function()
		update_state()
		if state.line2 then
			core.active_view.doc:set_selection(state.line2, state.col2)
		end
		end,
	},
})

keymap.bind("plugin", {
	text_editor = {
		["ctrl+m"] = "bracket-match:move-to-matching",
	},
})
