-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

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

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

local TextViewer = TextSurface:extend()
local SCROLL_WIDTH_SCAN_LINES_PER_UPDATE = 128

-- 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 = nil
	self.wrap_enabled = TextSurface.get_initial_word_wrap(doc, opts, config.text_viewer_word_wrap)
	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 a valid incremental horizontal-width cache
-- @return table cache width scan state
function TextViewer:get_scroll_width_cache()
	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
	local font = self:get_font()
	local indent_size = self.doc:get_indent_size()

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

	cache = {
		change_id = change_id,
		line_count = line_count,
		font = font,
		indent_size = indent_size,
		next_line = 1,
		widest_line = 0,
		complete = line_count == 0,
	}
	self._scroll_width_cache = cache

	return cache
end

--- Include one line in the known horizontal range
-- @param cache table width scan state
-- @param line number line index
-- @return nil
function TextViewer:measure_scroll_width_line(cache, line)
	local text = self.doc.lines[line]
	local line_width = self:get_col_x_offset(line, get_line_end_col(text))

	if line_width > cache.widest_line then
		cache.widest_line = line_width
	end
end

--- Advance the horizontal range scan by a bounded number of lines
-- @param max_lines number maximum lines to measure
-- @return nil
function TextViewer:scan_scroll_width_lines(max_lines)
	if self:is_wrap_enabled() then
		return
	end

	local cache = self:get_scroll_width_cache()
	local last_line = math.min(cache.line_count, cache.next_line + math.max(0, max_lines) - 1)

	for line = cache.next_line, last_line do
		self:measure_scroll_width_line(cache, line)
	end

	cache.next_line = last_line + 1
	cache.complete = cache.next_line > cache.line_count
end

--- Include currently visible lines in the known horizontal range
-- @return nil
function TextViewer:measure_visible_scroll_width_lines()
	if self:is_wrap_enabled() then
		return
	end

	local cache = self:get_scroll_width_cache()
	local first_line, last_line = self:get_visible_line_range()

	for line = first_line, last_line do
		self:measure_scroll_width_line(cache, line)
	end
end

--- Return the horizontal end of the longest currently visible line
-- @return number max_scroll maximum visible-line x scroll value
function TextViewer:get_visible_scroll_max_x()
	if self:is_wrap_enabled() then
		return 0
	end

	local first_line, last_line = self:get_visible_line_range()
	local widest_line = 0

	for line = first_line, last_line do
		local text = self.doc.lines[line]
		local line_width = self:get_col_x_offset(line, get_line_end_col(text))
		widest_line = math.max(widest_line, line_width)
	end

	return math.max(0, widest_line - self:get_text_viewport_width())
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

	self:measure_visible_scroll_width_lines()
	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
		self:measure_visible_scroll_width_lines()
		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

--- Jump to the horizontal end of the longest currently visible line
function TextViewer:scroll_to_rightmost_visible()
	local target_x = self:get_visible_scroll_max_x()

	self.scroll.x = target_x
	self.scroll.to.x = target_x
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()
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()

	if config.animate_page_scroll ~= true then
		self.scroll.y = self.scroll.to.y
	end
end

--- Capture the logical line at the top before word wrap changes
-- @return table anchor logical line, wrapped subrow, and pixel offset
function TextViewer:capture_word_wrap_anchor()
	local line_height = self:get_line_height()
	local scroll_y = self.scroll.y or 0
	local top_row = math.max(1, math.floor(scroll_y / line_height) + 1)
	local top_line = top_row
	local subrow = 1

	if self:is_wrap_enabled() then
		local row_info = self:get_visual_row_info(top_row)

		if row_info then
			local first_row = self:get_visual_rows_for_line(row_info.line)
			top_line = row_info.line
			subrow = top_row - first_row + 1
		end
	end

	return {
		line = top_line,
		subrow = subrow,
		row_offset = scroll_y - (top_row - 1) * line_height,
	}
end

--- Restore the logical top line after word wrap changes
-- @param anchor table captured word-wrap viewport anchor
function TextViewer:restore_word_wrap_anchor(anchor)
	if type(anchor) ~= "table" then
		return
	end

	local line = math.max(1, math.min(anchor.line or 1, #self.doc.lines))
	local target_row = line

	if self:is_wrap_enabled() then
		local first_row, last_row = self:get_visual_rows_for_line(line)
		target_row = math.min(first_row + math.max(1, anchor.subrow or 1) - 1, last_row)
	end

	local target_y = (target_row - 1) * self:get_line_height() + (anchor.row_offset or 0)
	target_y = math.max(0, math.min(target_y, get_max_scroll_y(self)))

	self.scroll.y = target_y
	self.scroll.to.y = target_y
end

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

--- Let lane controls handle presses before shared read-only document interactions
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean|nil handled true when lane or document input consumed the press
function TextViewer:on_mouse_down(button, x, y, clicks)
	local side, id, handler = self:resolve_lane_input_handler_at(x, y)

	if handler and handler.on_mouse_down and handler.on_mouse_down(self, button, x, y, clicks) then
		self._active_lane_input = { side = side, id = id, handler = handler }
		return true
	end

	return DocView.on_mouse_down(self, button, x, y, clicks)
end

--- Update lane dragging or hover before restoring the read-only pointer
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer x movement
-- @param dy number pointer y movement
-- @return boolean|nil handled true when shared document input consumed the move
function TextViewer:on_mouse_move(x, y, dx, dy)
	local active = self._active_lane_input

	if active and active.handler then
		if active.handler.on_mouse_move then
			active.handler.on_mouse_move(self, x, y, dx, dy)
		end
		local mouse_cursor = active.handler.get_mouse_cursor
			and active.handler.get_mouse_cursor(self, x, y, dx, dy)
		self.mouse_cursor = mouse_cursor or "arrow"
		return
	end

	local handled = DocView.on_mouse_move(self, x, y, dx, dy)

	if not self._mouse_pan then
		self.mouse_cursor = "arrow"
	end

	local side, id, hover_handler = self:resolve_lane_input_handler_at(x, y)
	local previous_hover = self._hover_lane_input
	local same_hover = previous_hover and hover_handler
		and previous_hover.side == side and previous_hover.id == id

	if previous_hover and not same_hover then
		if previous_hover.handler and previous_hover.handler.on_mouse_left then
			previous_hover.handler.on_mouse_left(self, x, y, dx, dy)
		end
		self._hover_lane_input = nil
	end

	if hover_handler then
		if hover_handler.on_mouse_move then
			hover_handler.on_mouse_move(self, x, y, dx, dy)
		end
		local mouse_cursor = hover_handler.get_mouse_cursor
			and hover_handler.get_mouse_cursor(self, x, y, dx, dy)
		self.mouse_cursor = mouse_cursor or "arrow"
		self._hover_lane_input = { side = side, id = id, handler = hover_handler }
	end

	return handled
end

--- Finish active lane input and shared read-only document interactions
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean|nil handled shared document result
function TextViewer:on_mouse_up(button, x, y)
	local active = self._active_lane_input

	if active and active.handler then
		if active.handler.on_mouse_up then
			active.handler.on_mouse_up(self, button, x, y)
		end
		self._active_lane_input = nil
	end

	return DocView.on_mouse_up(self, button, x, y)
end

--- Clear lane and shared document pointer state
-- @param ... any pointer-leave payload
-- @return boolean|nil handled shared document result
function TextViewer:on_mouse_left(...)
	local active = self._active_lane_input

	if active and active.handler and active.handler.on_mouse_left then
		active.handler.on_mouse_left(self, ...)
	end
	self._active_lane_input = nil

	local hover = self._hover_lane_input

	if hover and hover.handler and hover.handler.on_mouse_left then
		hover.handler.on_mouse_left(self, ...)
	end
	self._hover_lane_input = nil
	self.mouse_cursor = "arrow"

	return DocView.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

--- Keep viewer updates free from caret and blink management
function TextViewer:update()
	if not self._mouse_pan then
		self.mouse_cursor = "arrow"
	end
	self:reload_stale_doc_if_needed()
	self:scan_scroll_width_lines(SCROLL_WIDTH_SCAN_LINES_PER_UPDATE)
	local result = DocView.super.update(self)
	-- Build the lane after the viewer's bounded core width scan advances
	horizontal_scrollbar.update(self, false)

	return result
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)
	if not self:is_wrap_enabled() then
		self:measure_scroll_width_line(self:get_scroll_width_cache(), idx)
	end

	self:draw_line_underlay(idx, idx, idx, x, y)
	self:draw_line_text(idx, x, y)
	self:draw_line_overlay(idx, idx, 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()
	local font = self:get_font()
	local line_number = tostring(idx)

	-- Keep every line number against the same padded right edge
	x = x + self:get_gutter_width() - style.padding.x - font:get_width(line_number)
	renderer.draw_text(font, line_number, x, y + yoffset, style.line_number)
end

return TextViewer
