-- lx-bundle:true

--- Text editing surface for document-backed text panes
-- @module core.text.editor

local config = require("core.config")
local core = require("core")
local common = require("core.common")
local style = require("core.style")
local system = require("system")
local TextSurface = require("core.text.surface")
local surround = require("core.text.surround")

local TextEditor = TextSurface:extend()
local text_input_area_owner = nil	-- Editor whose IME area the operating system currently knows

--- Clip an editor input area to the visible Lua workspace
-- @param x integer area left edge
-- @param y integer area top edge
-- @param width integer area width
-- @param height integer area height
-- @param cursor integer caret offset from the left edge
-- @return boolean visible true when some of the input area remains visible
-- @return integer x clipped left edge
-- @return integer y clipped top edge
-- @return integer width clipped width
-- @return integer height clipped height
-- @return integer cursor clipped caret offset
local function clip_text_input_area_to_workspace(x, y, width, height, cursor)
	local workspace = core.workspace_view
	if not workspace or not workspace.position or not workspace.size then
		return true, x, y, width, height, cursor
	end

	local workspace_left = workspace.position.x
	local workspace_top = workspace.position.y
	local workspace_right = workspace_left + workspace.size.x
	local workspace_bottom = workspace_top + workspace.size.y
	local clipped_left = math.max(x, workspace_left)
	local clipped_top = math.max(y, workspace_top)
	local clipped_right = math.min(x + width, workspace_right)
	local clipped_bottom = math.min(y + height, workspace_bottom)

	if clipped_left >= clipped_right or clipped_top >= clipped_bottom then
		return false, 0, 0, 0, 0, 0
	end

	local absolute_cursor = x + cursor
	local clipped_width = clipped_right - clipped_left
	local clipped_cursor = common.clamp(absolute_cursor - clipped_left, 0, clipped_width)

	return true, clipped_left, clipped_top, clipped_width, clipped_bottom - clipped_top, clipped_cursor
end

--- Return key binding contexts for the text editor surface
-- The editor keeps its own context ahead of the shared document scope
-- @return table contexts ordered from most specific to broadest
function TextEditor:get_keymap_contexts()
	return { "text_editor", "doc_view", "global" }
end

--- Build one editable text surface
-- @param doc table document model
-- @param opts table|nil view construction options
function TextEditor:new(doc, opts)
	TextEditor.super.new(self, doc)
	self.gutter_visible = config.text_editor_gutter ~= false
	self.line_numbers_visible = config.text_editor_line_numbers ~= false
	self.wrap_enabled = TextSurface.get_initial_word_wrap(doc, opts, config.text_editor_word_wrap)
	self:invalidate_wrap_layout(1, #self.doc.lines, "editor_new")
end

--- Refresh cached word highlight only when its source changes
function TextEditor:update_word_highlight()
	local doc = self.doc

	-- Suppressed states must not retain a highlight from an earlier caret position
	if core.active_view ~= self or doc:has_any_selection() or doc.match_selection then
		self._word_highlight_pattern = nil
		self._word_highlight_source = nil
		return
	end

	local line, col = doc:get_selection()
	local change_id = doc.get_change_id and doc:get_change_id() or false
	local source = self._word_highlight_source

	if source and source.line == line and source.col == col and source.change_id == change_id then
		return
	end

	local word = self:get_word_under_cursor()
	local pattern

	if word ~= "" then
		-- Pattern breakdown: frontier assertions require word boundaries and the capture escapes punctuation
		pattern = "%f[%w_]" .. word:gsub("(%W)", "%%%1") .. "%f[^%w_]"
	end

	self._word_highlight_pattern = pattern
	self._word_highlight_source = {
		line = line,
		col = col,
		change_id = change_id,
	}
end

--- Draw one text highlight across its visible wrapped rows
-- @param text string logical line text
-- @param idx number logical line index
-- @param start_col number highlight start byte column
-- @param end_col number inclusive highlight end byte column
-- @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 color table highlight color
function TextEditor:draw_text_highlight(text, idx, start_col, end_col, first_row, last_row, x, y, color)
	local match_first_row = self:get_visual_row_for_position(idx, start_col)
	local match_last_row = self:get_visual_row_for_position(idx, end_col + 1, true)
	local draw_first_row = math.max(first_row, match_first_row)
	local draw_last_row = math.min(last_row, match_last_row)
	local line_height = self:get_line_height()

	for row = draw_first_row, draw_last_row do
		local segment_start = start_col
		local segment_end = end_col + 1
		local row_info

		if self:is_wrap_enabled() then
			row_info = self:get_visual_row_info(row)
			segment_start = math.max(segment_start, row_info.start_col)
			segment_end = math.min(segment_end, row_info.end_col)
		end

		if segment_end > segment_start then
			local chunk = text:sub(segment_start, segment_end - 1)
			local x_offset = row_info
				and row_info.indent_x + self:get_col_x_offset(idx, segment_start) - row_info.start_x
				or self:get_col_x_offset(idx, segment_start)
			local x1 = x + x_offset
			local row_y = y + (row - first_row) * line_height + self:get_line_text_y_offset()
			renderer.draw_text(self:get_font(), chunk, x1, row_y, color)
		end
	end
end

--- Draw cached current-word matches for one visible logical line
-- @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)
	TextEditor.super.draw_line_overlay(self, idx, first_row, last_row, x, y)

	local pattern = self._word_highlight_pattern
	if not pattern then
		return
	end

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

	local start_col = 1
	local color = style.word_highlight or style.candidate or style.selection or style.syntax.comment

	if not color then
		return
	end

	while true do
		local match_start, match_end = text:find(pattern, start_col)
		if not match_start then
			break
		end

		self:draw_text_highlight(text, idx, match_start, match_end, first_row, last_row, x, y, color)
		start_col = match_end + 1
	end
end

--- Retain unfinished native text without changing the document
-- @param text string current composition text
-- @param start number selected composition character offset
-- @param length number selected composition character count
function TextEditor:on_text_editing(text, start, length)
	self.composition_text = text or ""
	self.composition_start = math.max(0, tonumber(start) or 0)
	self.composition_length = math.max(0, tonumber(length) or 0)
	core.redraw = true
end

--- Wrap selected text when its syntax owns the committed input character
-- Unsupported input continues through the ordinary text insertion path
-- @param text string committed input text
function TextEditor:on_text_input(text)
	if surround.handle_text_input(self.doc, text) then
		-- A handled pair replaces the temporary native composition
		self.composition_text = ""
		self.composition_start = 0
		self.composition_length = 0
		return
	end

	TextEditor.super.on_text_input(self, text)
end

--- Report the visible caret row to native IME composition and candidate windows
function TextEditor:update_text_input_area()
	if core.active_view ~= self then
		return
	end

	local line, col = self.doc:get_selection()
	local viewport_x, viewport_y, viewport_width, viewport_height = self:get_content_viewport_rect()
	local text_offset_x = self:get_text_x_offset()
	local line_height = self:get_line_height()
	local selection = self.doc.selection
	local preferred_row = selection and self._selection_visual_rows[selection.id] or nil
	local wrapped = self:is_wrap_enabled()
	local doc_change = self.doc.get_change_id and self.doc:get_change_id() or false
	local font = self:get_font()
	local scroll_x = self.scroll.x
	local scroll_y = self.scroll.y
	local previous = self._text_input_area_source

	-- Compare cheap source values first so an idle redraw does not measure wrapped text or call into C++
	if text_input_area_owner == self
		and previous
		and previous.line == line
		and previous.col == col
		and previous.preferred_row == preferred_row
		and previous.wrapped == wrapped
		and previous.doc_change == doc_change
		and previous.font == font
		and previous.line_height == line_height
		and previous.viewport_x == viewport_x
		and previous.viewport_y == viewport_y
		and previous.viewport_width == viewport_width
		and previous.viewport_height == viewport_height
		and previous.text_offset_x == text_offset_x
		and previous.scroll_x == scroll_x
		and previous.scroll_y == scroll_y then
		return
	end

	-- Use the same final caret position so unfinished text and native IME windows cannot drift apart
	local caret_x, row_y = self:get_caret_screen_position(line, col, preferred_row)

	local area_x = math.floor(viewport_x + text_offset_x + 0.5)
	local area_width = math.max(1, math.floor(viewport_width - text_offset_x + 0.5))
	local area_height = math.max(1, math.min(math.floor(line_height + 0.5), math.floor(viewport_height + 0.5)))
	local maximum_y = viewport_y + math.max(0, viewport_height - area_height)
	local area_y = math.floor(common.clamp(row_y, viewport_y, maximum_y) + 0.5)
	local cursor = math.floor(common.clamp(caret_x - area_x, 0, area_width) + 0.5)
	local visible
	visible, area_x, area_y, area_width, area_height, cursor = clip_text_input_area_to_workspace(
		area_x,
		area_y,
		area_width,
		area_height,
		cursor
	)
	-- A newly activated tab may draw once before the workspace has assigned its final bounds
	if not visible then
		return
	end

	local updated, update_error = system.set_text_input_area(area_x, area_y, area_width, area_height, cursor)

	-- Report a new platform failure once without flooding the log on every redraw
	if not updated and update_error ~= self._text_input_area_error then
		core.error("Native IME placement failed: %s", tostring(update_error))
	end

	if updated then
		-- Reuse one table per editor so steady drawing does not create garbage for the Lua collector
		local source = previous or {}
		source.line = line
		source.col = col
		source.preferred_row = preferred_row
		source.wrapped = wrapped
		source.doc_change = doc_change
		source.font = font
		source.line_height = line_height
		source.viewport_x = viewport_x
		source.viewport_y = viewport_y
		source.viewport_width = viewport_width
		source.viewport_height = viewport_height
		source.text_offset_x = text_offset_x
		source.scroll_x = scroll_x
		source.scroll_y = scroll_y
		self._text_input_area_source = source
		text_input_area_owner = self
	end

	self._text_input_area_error = updated and nil or update_error
end

--- Draw the editor and then publish the caret geometry from that completed layout
function TextEditor:draw()
	-- Disabled feature performs no document lookup, pattern work, or line scan
	if config.highlight_word_under_cursor and self.word_highlight_enabled ~= false then
		self:update_word_highlight()
	elseif self._word_highlight_pattern then
		self._word_highlight_pattern = nil
		self._word_highlight_source = nil
	end

	TextEditor.super.draw(self)
	self:update_text_input_area()
end

return TextEditor
