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

--- Draw indent guides without rescanning large blank runs on every row
-- @module plugins.indentguide

local style = require("core.style")
local core = require("core")
local config = require("core.config")

--- Return the optional indent-guide profile for one view
-- @param view table document view
-- @return table profile active phase counters
local function get_profile(view)
	local profile = view._indentguide_profile

	if not profile then
		profile = {
			calls = 0,
			guided = 0,
			rows = 0,
			spans = 0,
			rects = 0,
			cache = 0,
			indent = 0,
			active = 0,
			metrics = 0,
			row_work = 0,
			draw = 0,
		}
		view._indentguide_profile = profile
	end

	return profile
end

--- Return the current indent cache for one document
-- Cache results per change id so large blank-line ranges stay cheap to draw
-- @param doc table document
-- @return table cache
local function get_indent_cache(doc)
	local change_id = (doc.get_change_id and doc:get_change_id()) or 0
	local indent_size = doc:get_indent_size()
	local cache = rawget(doc, "_indentguide_cache")

	if cache and cache.change_id == change_id and cache.indent_size == indent_size then
		return cache
	end

	cache = {
		change_id = change_id,
		indent_size = indent_size,
		line_info = {},
		guide_spaces = {},
		prev_spaces = {},
		next_spaces = {},
		active_line = nil,
		active_col = nil,
		active_spaces = nil,
	}

	doc._indentguide_cache = cache

	return cache
end

--- Return cached indent metadata for one line
-- @param doc table document
-- @param idx number line index
-- @param cache table indent cache
-- @return table|nil line info
local function get_line_info(doc, idx, cache)
	local cached = cache.line_info[idx]
	if cached then
		return cached
	end

	local text = doc.lines[idx]
	if not text then
		return nil
	end

	-- Common case where a visible character starts the line
	local first_byte = text:byte(1)

	if first_byte and first_byte ~= 9 and first_byte ~= 10 and first_byte ~= 13 and first_byte ~= 32 then
		local info = {
			is_blank = false,
			leading_spaces = 0,
		}
		cache.line_info[idx] = info
		return info
	end

	local _, leading_stop = text:find("^%s*")
	local info = {
		is_blank = leading_stop == #text,
		leading_spaces = 0,
	}

	if not info.is_blank then
		info.leading_spaces = doc:get_indent_visual_width(idx)
	end

	cache.line_info[idx] = info

	return info
end

--- Return nearest non-blank indent width from one direction
-- Cache and reuse the resolved width for every blank line in the walked range
-- @param doc table document
-- @param start_idx number line index
-- @param dir number scan direction
-- @param cache table indent cache
-- @return number spaces
local function get_neighbor_spaces(doc, start_idx, dir, cache)
	local store = dir < 0 and cache.prev_spaces or cache.next_spaces
	local visited = {}
	local idx = start_idx
	local spaces = 0

	while true do
		local cached = store[idx]
		if cached ~= nil then
			spaces = cached
			break
		end

		local info = get_line_info(doc, idx, cache)
		if not info then
			spaces = 0
			break
		end

		if not info.is_blank then
			spaces = info.leading_spaces
			store[idx] = spaces
			break
		end

		visited[#visited + 1] = idx
		idx = idx + dir
	end

	for i = 1, #visited do
		store[visited[i]] = spaces
	end

	return spaces
end

--- Return indent guide width for one line
-- Blank lines inherit the stronger indent from the closest text above or below
-- @param doc table document
-- @param idx number line index
-- @param cache table indent cache
-- @return number spaces
local function get_line_indent_guide_spaces(doc, idx, cache)
	local cached = cache.guide_spaces[idx]
	if cached ~= nil then
		return cached
	end

	local info = get_line_info(doc, idx, cache)
	if not info then
		return 0
	end

	local spaces = info.leading_spaces
	if info.is_blank then
		spaces = math.max(
			get_neighbor_spaces(doc, idx - 1, -1, cache),
			get_neighbor_spaces(doc, idx + 1, 1, cache)
		)
	end

	cache.guide_spaces[idx] = spaces

	return spaces
end

--- Return active indent guide width for the current cursor
-- Cache the selection result so each redraw only computes it once
-- @param doc table document
-- @param cache table indent cache
-- @return number|nil spaces
local function get_active_guide_spaces(doc, cache)
	local line, col = doc:get_selection()
	if cache.active_line == line and cache.active_col == col then
		return cache.active_spaces
	end

	local line_spaces = get_line_indent_guide_spaces(doc, line, cache)
	local active_spaces = nil

	if line_spaces > 0 then
		local col_spaces = doc:get_indent_visual_column(line, col)
		local indent_size = cache.indent_size
		local max_level = math.max(0, line_spaces - indent_size)

		-- In text or at end-of-line, keep highlighting the innermost guide
		if not col_spaces or col_spaces >= line_spaces then
			active_spaces = max_level
		else
			-- In leading whitespace, follow cursor depth
			local level = math.floor(col_spaces / indent_size) * indent_size
			active_spaces = math.min(level, max_level)
		end
	end

	cache.active_line = line
	cache.active_col = col
	cache.active_spaces = active_spaces

	return active_spaces
end

--- Draw cached indent guides across one visible visual-row span
-- @param idx number line index
-- @param first_row number first visible visual row
-- @param last_row number last visible visual row
-- @param x number screen x
-- @param y number screen y
-- @param line_first_row number first visual row owned by the complete logical line
local function draw_indent_guides(view, idx, first_row, last_row, x, y, line_first_row)
	-- Keep timing out of the normal path unless the benchmark explicitly enables it
	local profile = config.text_wrap_profile_enabled == true and get_profile(view) or nil
	if profile then
		profile.calls = profile.calls + 1
	end

	local cache_started_at = profile and system.get_time() or nil
	local cache = get_indent_cache(view.doc)
	if profile then
		profile.cache = profile.cache + system.get_time() - cache_started_at
	end

	local indent_started_at = profile and system.get_time() or nil
	local spaces = get_line_indent_guide_spaces(view.doc, idx, cache)
	if profile then
		profile.indent = profile.indent + system.get_time() - indent_started_at
	end

	-- Skip all guide drawing work for lines that cannot show a guide
	if spaces <= 0 then
		return
	end
	if profile then
		profile.guided = profile.guided + 1
		profile.rows = profile.rows + last_row - first_row + 1
	end

	-- Only the active pane tracks the highlighted guide depth
	local active_started_at = profile and system.get_time() or nil
	local active_spaces = (view == core.active_view) and get_active_guide_spaces(view.doc, cache) or nil
	if profile then
		profile.active = profile.active + system.get_time() - active_started_at
	end

	local metrics_started_at = profile and system.get_time() or nil
	local sw = view:get_font():get_width(" ")
	local w = math.ceil(1 * SCALE)
	local lh = view:get_line_height()
	if profile then
		profile.metrics = profile.metrics + system.get_time() - metrics_started_at
	end

	-- Shift guides slightly right for better visual centering
	local x_shift = 1

	-- Keep the active guide fallback chain unchanged
	local active_color = style.guide_active
		or style.bracketmatch_color
		or style.syntax["function"]
		or style.guide
		or style.selection

	local draw_elapsed = 0

	local function get_row_spaces(row)
		if not view:is_wrap_enabled() or row == line_first_row then
			return spaces
		end

		local row_info = view:get_visual_row_info(row)
		local continuation_spaces = row_info and math.floor(row_info.indent_x / sw + 0.5) or 0

		return math.min(spaces, continuation_spaces)
	end

	local function draw_span(span_spaces, span_first_row, span_last_row)
		if span_spaces <= 0 then
			return
		end

		local draw_started_at = profile and system.get_time() or nil
		local span_y = y + (span_first_row - first_row) * lh
		local span_h = (span_last_row - span_first_row + 1) * lh
		local rects = math.floor((span_spaces - 1) / cache.indent_size) + 1
		local active_index = active_spaces
			and active_spaces < span_spaces
			and math.floor(active_spaces / cache.indent_size)
			or -1

		-- Submit one repeated-rule command instead of one rectangle per indentation level
		renderer.draw_vertical_rules(
			x + x_shift,
			span_y,
			w,
			span_h,
			sw * cache.indent_size,
			rects,
			active_index,
			style.guide or style.selection,
			active_color
		)

		if profile then
			local elapsed = system.get_time() - draw_started_at
			draw_elapsed = draw_elapsed + elapsed
			profile.spans = profile.spans + 1
			profile.rects = profile.rects + rects
		end
	end

	-- Merge adjacent rows with the same guide depth to minimize renderer calls
	local row_work_started_at = profile and system.get_time() or nil
	local span_first_row = first_row
	local span_spaces = get_row_spaces(first_row)

	for row = first_row + 1, last_row do
		local row_spaces = get_row_spaces(row)
		if row_spaces ~= span_spaces then
			draw_span(span_spaces, span_first_row, row - 1)
			span_first_row = row
			span_spaces = row_spaces
		end
	end

	draw_span(span_spaces, span_first_row, last_row)
	if profile then
		profile.draw = profile.draw + draw_elapsed
		profile.row_work = profile.row_work + system.get_time() - row_work_started_at - draw_elapsed
	end
end

return {
	draw = draw_indent_guides,
}
