-- lx-bundle:true

--- Read-only text viewing surface with fast scrolling
-- @module core.text.viewer

local config = require("core.config")
local style = require("core.style")
local doc_monitor = require("core.doc.monitor")
local DocView = require("core.doc.view")
local TextSurface = require("core.text.surface")

local TextViewer = TextSurface:extend()

-- Keep horizontal keyboard scrolling bounded to actual visible text width
local function get_line_end_col(text)
	if type(text) ~= "string" then
		return 1
	end

	return #text + ((text:sub(-1) == "\n") and 0 or 1)
end

--- Return the current maximum vertical scroll target
-- @return number max_scroll maximum y scroll value
local function get_max_scroll_y(view)
	return math.max(0, view:get_scrollable_size() - view:get_scroll_viewport_height())
end

--- Return whether the viewer is already following the end of the file
-- @param view table viewer instance
-- @return boolean at_end true when scroll target is already at EOF
local function is_scrolled_to_end(view)
	local scroll_y = view.scroll.to.y or view.scroll.y or 0
	return math.abs(scroll_y - get_max_scroll_y(view)) <= view:get_line_height()
end

--- Build one read-only text viewer pane
-- @param doc table document model
-- @param opts table|nil view construction options
function TextViewer:new(doc, opts)
	TextViewer.super.new(self, doc)
	self.mouse_cursor = "arrow"
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.use_syntax_highlighting = config.text_viewer_syntax_highlighting == true
	self.gutter_visible = config.text_viewer_gutter == true
	self.line_numbers_visible = config.text_viewer_line_numbers == true
	self._scroll_width_cache = false
	if type(opts) == "table" and opts.word_wrap ~= nil then
		self.wrap_enabled = opts.word_wrap == true
	else
		self.wrap_enabled = config.text_viewer_word_wrap == true
	end
	self:invalidate_wrap_layout(1, #self.doc.lines, "viewer_new")
end

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

--- Return the maximum horizontal scroll target for the current viewer doc
-- Uses cached widest-line measurement until the doc changes
-- @return number max_scroll maximum x scroll value
function TextViewer:get_scroll_max_x()
	if self:is_wrap_enabled() then
		return 0
	end

	local cache = self._scroll_width_cache
	local change_id = (self.doc.get_change_id and self.doc:get_change_id()) or false
	local line_count = #self.doc.lines

	if cache and cache.change_id == change_id and cache.line_count == line_count then
		return cache.max_scroll
	end

	local widest_line = 0

	for line = 1, line_count do
		local text = self.doc.lines[line]
		local line_width = self:get_col_x_offset(line, get_line_end_col(text))

		if line_width > widest_line then
			widest_line = line_width
		end
	end

	local text_w = self:get_text_viewport_width()
	local max_scroll = math.max(0, widest_line - text_w)

	self._scroll_width_cache = {
		change_id = change_id,
		line_count = line_count,
		max_scroll = max_scroll,
	}

	return max_scroll
end

--- Scroll the viewer horizontally by one keyboard step
-- @param direction number negative for left, positive for right
-- @param large boolean|nil use a larger one-third viewport step
function TextViewer:scroll_horizontal_step(direction, large)
	if self:is_wrap_enabled() then
		return
	end

	local text_w = self:get_text_viewport_width()
	local small_step = math.max(self:get_font():get_width(" ") * 4, self:get_line_height())
	local step = (large == true) and math.max(1, math.floor(text_w / 3)) or small_step

	self.scroll.to.x = (self.scroll.to.x or self.scroll.x or 0) + direction * step
	self:clamp_scroll_position()
end

--- Jump the viewer to the first or last horizontal scroll edge
-- @param to_end boolean|nil true jumps to the right edge, false jumps to the left edge
function TextViewer:scroll_to_horizontal_edge(to_end)
	if self:is_wrap_enabled() then
		self.scroll.x = 0
		self.scroll.to.x = 0
		return
	end

	if to_end then
		local target_x = self:get_scroll_max_x()
		self.scroll.x = target_x
		self.scroll.to.x = target_x
		return
	end

	self.scroll.x = 0
	self.scroll.to.x = 0
end

--- Scroll the viewer vertically by one rendered line
-- @param direction number negative for up, positive for down
function TextViewer:scroll_vertical_step(direction)
	local step = self:get_line_height()

	self.scroll.to.y = (self.scroll.to.y or self.scroll.y or 0) + direction * step
	self:clamp_scroll_position()
	self.scroll.y = self.scroll.to.y
end

--- Scroll the viewer vertically by one visible page
-- @param direction number negative for up, positive for down
function TextViewer:scroll_page(direction)
	local viewport_h = select(4, self:get_content_viewport_rect())
	local step = math.max(self:get_line_height(), viewport_h - self:get_line_height())

	self.scroll.to.y = (self.scroll.to.y or self.scroll.y or 0) + direction * step
	self:clamp_scroll_position()
	self.scroll.y = self.scroll.to.y
end

--- Jump the viewer to the first or last visible line
-- @param to_end boolean|nil true jumps to end, false jumps to top
function TextViewer:scroll_to_vertical_edge(to_end)
	if to_end then
		local target_y = get_max_scroll_y(self)
		self.scroll.y = target_y
		self.scroll.to.y = target_y
		return
	end

	self.scroll.y = 0
	self.scroll.to.y = 0
end

--- Ignore editor cursor-column tracking in the viewer
function TextViewer:sync_cursor_column_from_selection()
end

--- Ignore pointer-based text selection in the viewer
function TextViewer:on_mouse_down(button, x, y, clicks)
	return DocView.super.on_mouse_down(self, button, x, y, clicks)
end

--- Keep viewer pointer handling limited to base scrollbar behavior
function TextViewer:on_mouse_move(x, y, dx, dy)
	DocView.super.on_mouse_move(self, x, y, dx, dy)
	self.mouse_cursor = "arrow"
end

--- Ignore pointer-based text selection in the viewer
function TextViewer:on_mouse_up(button, x, y)
	return DocView.super.on_mouse_up(self, button, x, y)
end

--- Clear transient pointer state through the base view only
function TextViewer:on_mouse_left(...)
	self.mouse_cursor = "arrow"
	return DocView.super.on_mouse_left(self, ...)
end

--- Ignore text input because the viewer is read-only
-- @param text string typed text
function TextViewer:on_text_input(text)
	return nil
end

--- Capture enough scroll state to keep the same viewport anchor after reload
-- @return table state captured scroll state
function TextViewer:capture_reload_view_state()
	if self:is_wrap_enabled() then
		local line_height = self:get_line_height()
		local scroll_y = self.scroll.to.y or self.scroll.y or 0
		local top_row = math.max(1, math.floor(scroll_y / line_height) + 1)

		return {
			scroll_x = 0,
			target_scroll_x = 0,
			top_row = top_row,
			top_offset = scroll_y - (top_row - 1) * line_height,
			follow_end = is_scrolled_to_end(self),
		}
	end

	local line_height = self:get_line_height()
	local scroll_y = self.scroll.to.y or self.scroll.y or 0
	local top_line = math.max(1, math.floor(scroll_y / line_height) + 1)

	return {
		scroll_x = self.scroll.x or 0,
		target_scroll_x = self.scroll.to.x or 0,
		top_line = top_line,
		top_offset = scroll_y - (top_line - 1) * line_height,
		follow_end = is_scrolled_to_end(self),
	}
end

--- Restore scroll state after a viewer auto-reload
-- @param state table|nil captured reload state
function TextViewer:restore_reload_view_state(state)
	if type(state) ~= "table" then
		return
	end

	local target_y = 0

	if self:is_wrap_enabled() then
		if state.follow_end then
			target_y = get_max_scroll_y(self)
		else
			local line_height = self:get_line_height()
			local top_row = math.max(1, math.min(state.top_row or 1, self:get_visual_row_count()))

			target_y = math.max(0, (top_row - 1) * line_height + (state.top_offset or 0))
			target_y = math.min(target_y, get_max_scroll_y(self))
		end
	elseif state.follow_end then
		target_y = get_max_scroll_y(self)
	else
		local line_height = self:get_line_height()
		local top_line = math.max(1, math.min(state.top_line or 1, #self.doc.lines))

		target_y = math.max(0, (top_line - 1) * line_height + (state.top_offset or 0))
		target_y = math.min(target_y, get_max_scroll_y(self))
	end

	self.scroll.x = self:is_wrap_enabled() and 0 or (state.scroll_x or 0)
	self.scroll.to.x = self:is_wrap_enabled() and 0 or (state.target_scroll_x or self.scroll.x)
	self.scroll.y = target_y
	self.scroll.to.y = target_y
end

--- Reload stale monitor-backed docs automatically for the read-only viewer
-- @return boolean reloaded true when one reload happened
function TextViewer:reload_stale_doc_if_needed()
	local state = doc_monitor.get_state and doc_monitor.get_state(self.doc)

	if not state or not state.stale or state.deleted then
		return false
	end

	if self.doc.is_dirty and self.doc:is_dirty() then
		return false
	end

	local reload_state = self:capture_reload_view_state()
	local ok = doc_monitor.reload_doc(self.doc, {
		force = true,
		silent_success = true,
		source = "viewer_auto",
	})

	if ok then
		self:restore_reload_view_state(reload_state)
		return true
	end

	return false
end

--- Keep viewer updates free from caret and blink management
function TextViewer:update()
	self.mouse_cursor = "arrow"
	self:reload_stale_doc_if_needed()
	return DocView.super.update(self)
end

--- Draw one line body without selection or caret overlays
-- @param idx number line index
-- @param x number line origin x
-- @param y number line origin y
function TextViewer:draw_line_body(idx, x, y)
	self:draw_line_text(idx, x, y)
end

--- Draw plain gutter line numbers without caret-selection emphasis
-- @param idx number line index
-- @param x number gutter origin x
-- @param y number gutter origin y
function TextViewer:draw_line_gutter(idx, x, y)
	if not self:are_line_numbers_visible() then
		return
	end

	local yoffset = self:get_line_text_y_offset()

	x = x + style.padding.x
	renderer.draw_text(self:get_font(), idx, x, y + yoffset, style.line_number)
end

return TextViewer
