-- lx-bundle:true

--- Indentation grid helpers for text documents
-- These helpers keep literal tabs and spaces unchanged while mapping indentation
-- columns through one shared grid size
-- @module core.text.grid

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

local grid = {}

--- Return true when a character belongs to leading indentation
-- @param char string one-byte character
-- @return boolean is_indent_char true for space or tab
local function is_indent_char(char)
	return char == " " or char == "\t"
end

--- Return the next visual grid stop for a tab advance
-- @param visual_column number current visual column
-- @param grid_size number indentation grid size
-- @return number next_visual_column advanced visual column
local function next_grid_stop(visual_column, grid_size)
	return visual_column + (grid_size - (visual_column % grid_size))
end

--- Clamp the configured indentation size into a usable positive grid
-- @return number grid_size indentation grid size
local function get_grid_size()
	local grid_size = math.floor(tonumber(config.indent_size) or 4)

	return math.max(1, grid_size)
end

--- Scan indentation until a target column or the first non-indent character
-- @param text string logical line text without trailing LF
-- @param stop_col number|nil one-based target column boundary
-- @param grid_size number indentation grid size
-- @return number col column reached after the scan
-- @return number visual_column visual column reached after the scan
local function scan_indentation(text, stop_col, grid_size)
	local visual_column = 0
	local col = 1
	local limit_col = stop_col or (#text + 1)

	while col < limit_col and col <= #text do
		local char = text:sub(col, col)

		if char == " " then
			visual_column = visual_column + 1
		elseif char == "\t" then
			visual_column = next_grid_stop(visual_column, grid_size)
		else
			break
		end

		col = col + 1
	end

	return col, visual_column
end

--- Return the active indentation grid size
-- @return number grid_size indentation grid size
function grid.get_indent_size(self)
	return get_grid_size()
end

--- Return the first column after leading indentation on a line
-- @param line number one-based line index
-- @return number col first non-indent column or logical line end
function grid.get_indent_end_col(self, line)
	line = common.clamp(line, 1, #self.lines)
	local text = self:get_logical_line(line)
	local grid_size = get_grid_size()
	local col = scan_indentation(text, nil, grid_size)

	return col
end

--- Return true when a position is inside leading indentation
-- @param line number one-based line index
-- @param col number one-based document column
-- @return boolean in_indentation true when position is within leading whitespace
function grid.is_in_indentation(self, line, col)
	line, col = self:sanitize_position(line, col)

	return col <= self:get_indent_end_col(line)
end

--- Return the visual indentation column for a document position
-- Returns nil when the position is outside leading indentation
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number|nil visual_column zero-based visual indentation column
function grid.get_indent_visual_column(self, line, col)
	line, col = self:sanitize_position(line, col)
	local text = self:get_logical_line(line)
	local grid_size = get_grid_size()
	local reached_col, visual_column = scan_indentation(text, col, grid_size)

	if reached_col ~= col then
		return nil
	end

	return visual_column
end

--- Return the visual width of a line's leading indentation
-- @param line number one-based line index
-- @return number visual_width zero-based visual column after indentation
function grid.get_indent_visual_width(self, line)
	line = common.clamp(line, 1, #self.lines)
	local text = self:get_logical_line(line)
	local grid_size = get_grid_size()
	local _, visual_width = scan_indentation(text, nil, grid_size)

	return visual_width
end

--- Map a visual indentation column back to a stored document column
-- Returns nil when the target is beyond the stored indentation width
-- When the target lands inside a tab span, bias chooses the left edge, right
-- edge, or nearest reachable boundary
-- @param line number one-based line index
-- @param target_visual_column number zero-based visual indentation column
-- @param bias string|nil 'left', 'right', or 'nearest'
-- @return number|nil col one-based stored column boundary
function grid.get_indent_col_for_visual_column(self, line, target_visual_column, bias)
	line = common.clamp(line, 1, #self.lines)
	target_visual_column = math.max(0, math.floor(tonumber(target_visual_column) or 0))
	bias = bias or "nearest"

	local text = self:get_logical_line(line)
	local grid_size = get_grid_size()
	local visual_column = 0
	local col = 1

	if target_visual_column == 0 then
		return 1
	end

	while col <= #text do
		local char = text:sub(col, col)

		if not is_indent_char(char) then
			break
		end

		local next_visual_column

		if char == " " then
			next_visual_column = visual_column + 1
		else
			next_visual_column = next_grid_stop(visual_column, grid_size)
		end

		if target_visual_column == next_visual_column then
			return col + 1
		end

		if target_visual_column > visual_column and target_visual_column < next_visual_column then
			if bias == "left" then
				return col
			end

			if bias == "right" then
				return col + 1
			end

			local left_distance = target_visual_column - visual_column
			local right_distance = next_visual_column - target_visual_column

			if left_distance <= right_distance then
				return col
			end

			return col + 1
		end

		visual_column = next_visual_column
		col = col + 1
	end

	if target_visual_column == visual_column then
		return col
	end

	return nil
end

-- Keep indentation caret and deletion movement on the shared visual grid

--- Return the target visual column for one indentation-grid move
-- Returns nil when the source position is outside indentation or the move would
-- leave the stored indentation span
-- @param self table text document instance
-- @param line number one-based line index
-- @param col number one-based document column
-- @param visual_offset number signed visual column delta
-- @return number|nil target_visual_column zero-based destination visual column
local function get_indent_target_visual_column(self, line, col, visual_offset)
	line, col = self:sanitize_position(line, col)
	local visual_column = self:get_indent_visual_column(line, col)

	if visual_column == nil then
		return nil
	end

	local target_visual_column = visual_column + visual_offset
	local indent_visual_width = self:get_indent_visual_width(line)

	if target_visual_column < 0 or target_visual_column > indent_visual_width then
		return nil
	end

	return target_visual_column
end

--- Return the previous caret stop inside leading indentation
-- The stop is resolved on the shared visual grid and biased to the left edge of
-- any tab span so a tab remains one stored character
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number|nil target_col one-based stored destination column
function grid.get_previous_indent_caret_col(self, line, col)
	local target_visual_column = get_indent_target_visual_column(self, line, col, -1)

	if target_visual_column == nil then
		return nil
	end

	return self:get_indent_col_for_visual_column(line, target_visual_column, "left")
end

--- Return the next caret stop inside leading indentation
-- The stop is resolved on the shared visual grid and biased to the right edge
-- of any tab span so a tab remains one stored character
-- @param line number one-based line index
-- @param col number one-based document column
-- @return number|nil target_col one-based stored destination column
function grid.get_next_indent_caret_col(self, line, col)
	local target_visual_column = get_indent_target_visual_column(self, line, col, 1)

	if target_visual_column == nil then
		return nil
	end

	return self:get_indent_col_for_visual_column(line, target_visual_column, "right")
end

--- Return one grid-resolved deletion range inside leading indentation
-- Backward deletion uses the previous caret stop while forward deletion uses
-- the next caret stop so tabs stay atomic and spaces stay single-width
-- @param line number one-based line index
-- @param col number one-based document column
-- @param direction number signed delete direction
-- @return number|nil line1 range start line
-- @return number|nil col1 range start column
-- @return number|nil line2 range end line
-- @return number|nil col2 range end column
function grid.get_indent_delete_range(self, line, col, direction)
	line, col = self:sanitize_position(line, col)

	local target_col

	if direction < 0 then
		target_col = self:get_previous_indent_caret_col(line, col)

		if target_col == nil then
			return nil
		end

		return line, target_col, line, col
	end

	if direction > 0 then
		target_col = self:get_next_indent_caret_col(line, col)

		if target_col == nil then
			return nil
		end

		return line, col, line, target_col
	end

	return nil
end

return grid
