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

--- Heading outline shown beside rendered Markdown
-- @module plugins.mdpreview.outline

local common = require("core.common")
local config = require("core.config")
local core = require("core")
local markdown_design = require("plugins.mdpreview.design")
local style = require("core.style")

local outline = {}
local LANE_ID = "markdown_outline"	-- Core-owned right pane
local LANE_ORDER = 100				-- Outside document scrollbar

--- Return whether the outline should currently occupy its lane
-- @param view table Markdown preview
-- @return boolean visible
function outline.is_visible(view)
	if config.plugins.mdpreview.auto_document_outline
		and view.doc.markdown_outline_visible == nil
	then
		-- Explicit document visibility still wins for generated previews
		return core.window_mode == "maximized" or core.window_mode == "fullscreen"
	end

	return view.outline_visible == true
end

--- Return width currently reserved for the outline
-- @param view table Markdown preview
-- @return number width scaled screen pixels
function outline.get_width(view)
	if not outline.is_visible(view) then
		view:set_lane_size("right", LANE_ID, 0, LANE_ORDER)

		return 0
	end

	local padding = markdown_design.document.padding_horizontal * (SCALE or 1) * 2
	local minimum_document_width = markdown_design.document.minimum_content_width + padding
	local available_width = view.size.x - minimum_document_width
	local width = math.min(markdown_design.outline.width * (SCALE or 1), available_width)

	if width < markdown_design.outline.minimum_width * (SCALE or 1) then
		view:set_lane_size("right", LANE_ID, 0, LANE_ORDER)

		return 0
	end

	-- Reserve a real outer pane so document scrollbar stays beside the document
	view:set_lane_size("right", LANE_ID, width, LANE_ORDER)

	return width
end

--- Return outline panel rectangle
-- @param view table Markdown preview
-- @return number x
-- @return number y
-- @return number width
-- @return number height
function outline.get_rect(view)
	local width = outline.get_width(view)
	local x, y, lane_width, height = view:get_lane_rect("right", LANE_ID)

	if x then
		return x, y, lane_width, height
	end

	return view.position.x + view.size.x, view.position.y, width, view.size.y
end

--- Return whether a point belongs to visible outline panel
-- @param view table Markdown preview
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean inside
function outline.contains(view, x, y)
	local panel_x, panel_y, panel_width, panel_height = outline.get_rect(view)

	return panel_width > 0
		and x >= panel_x
		and x < panel_x + panel_width
		and y >= panel_y
		and y < panel_y + panel_height
end

--- Find last heading at or before one source line
-- @param entries table ordered heading entries
-- @param source_line number visible source line
-- @return number index active entry index
local function find_active_index(entries, source_line)
	local low = 1
	local high = #entries
	local active_index = #entries > 0 and 1 or 0

	while low <= high do
		local middle = math.floor((low + high) / 2)

		if entries[middle].source_line <= source_line then
			active_index = middle
			low = middle + 1
		else
			high = middle - 1
		end
	end

	return active_index
end

--- Find last rendered heading at or above one document position
-- @param view table Markdown preview
-- @param document_y number document position used for comparison
-- @return number|nil index active rendered heading index
local function find_rendered_index_at_or_before(view, document_y)
	local active_index
	local first_rendered_index

	for entry_index = 1, #view.outline_entries do
		local heading_y = view.heading_anchors[view.outline_entries[entry_index].anchor]

		if heading_y and not first_rendered_index then
			first_rendered_index = entry_index
		end

		if heading_y and heading_y <= document_y then
			active_index = entry_index
		end
	end

	return active_index or first_rendered_index
end

--- Build small visible slice of the heading outline
-- @param view table Markdown preview
-- @return table layout panel and visible-entry geometry
function outline.get_layout(view)
	local panel_x, panel_y, panel_width, panel_height = outline.get_rect(view)
	local design = markdown_design.outline
	local font = view.preview_fonts.outline
	local padding_x = design.padding_horizontal * (SCALE or 1)
	local padding_y = design.padding_vertical * (SCALE or 1)
	local row_height = font:get_height() * design.line_height_scale
		+ design.item_gap * (SCALE or 1)
	local visible_count = math.max(1, math.floor((panel_height - padding_y * 2) / row_height))
	local scroll_y = view.scroll.y or 0
	local viewport_height = view:get_scroll_viewport_height()
	local remaining_scroll = math.max(0, view:get_scroll_max_y() - scroll_y)
	local active_probe_y = scroll_y + viewport_height / 2

	-- Move probe from the nearest document edge during its first or final half-page
	if scroll_y < viewport_height / 2 and scroll_y <= remaining_scroll then
		active_probe_y = active_probe_y - viewport_height / 2 + scroll_y
	elseif remaining_scroll < viewport_height / 2 then
		active_probe_y = active_probe_y + viewport_height / 2 - remaining_scroll
	end

	local active_index = find_rendered_index_at_or_before(view, active_probe_y)
		or find_active_index(
			view.outline_entries,
			view:get_visible_source_line(viewport_height / 2)
		)
	local maximum_first_index = math.max(1, #view.outline_entries - visible_count + 1)

	if view.outline_active_anchor then
		for entry_index = 1, #view.outline_entries do
			if view.outline_entries[entry_index].anchor == view.outline_active_anchor then
				active_index = entry_index
				break
			end
		end
	end

	view.outline_first_index = common.clamp(
		view.outline_first_index or active_index - math.floor(visible_count / 3),
		1,
		maximum_first_index
	)

	-- Follow a newly focused heading without disturbing independent manual scrolling
	if view.outline_active_index ~= active_index then
		if active_index < view.outline_first_index then
			view.outline_first_index = active_index
		elseif active_index >= view.outline_first_index + visible_count then
			view.outline_first_index = active_index - visible_count + 1
		end

		view.outline_first_index = common.clamp(
			view.outline_first_index,
			1,
			maximum_first_index
		)
		view.outline_active_index = active_index
	end

	return {
		x = panel_x,
		y = panel_y,
		width = panel_width,
		height = panel_height,
		padding_x = padding_x,
		padding_y = padding_y,
		row_height = row_height,
		visible_count = visible_count,
		first_index = view.outline_first_index,
		maximum_first_index = maximum_first_index,
		active_index = active_index,
		font = font,
	}
end

--- Set independent outline scroll position
-- @param view table Markdown preview
-- @param first_index number first visible heading index
function outline.set_first_index(view, first_index)
	local layout = outline.get_layout(view)
	view.outline_first_index = common.clamp(first_index, 1, layout.maximum_first_index)
	core.redraw = true
end

--- Scroll outline by visible heading rows
-- @param view table Markdown preview
-- @param rows number signed row count
function outline.scroll(view, rows)
	outline.set_first_index(view, (view.outline_first_index or 1) + rows)
end

--- Size and position outline's own scrollbar
-- @param view table Markdown preview
-- @return table layout outline layout
local function update_scrollbar(view, layout)
	layout = layout or outline.get_layout(view)
	local scrollbar_width = math.max(1, style.scrollbar_size or 12)
	local scrollable_height = math.max(
		layout.height,
		#view.outline_entries * layout.row_height + layout.padding_y * 2
	)

	view.outline_scrollbar:set_size(
		layout.x + layout.width - scrollbar_width,
		layout.y,
		scrollbar_width,
		layout.height,
		scrollable_height
	)
	view.outline_scrollbar:set_percent(
		layout.maximum_first_index > 1
			and (layout.first_index - 1) / (layout.maximum_first_index - 1)
			or 0
	)

	return layout
end

--- Handle a press on outline scrollbar
-- @param view table Markdown preview
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled
function outline.on_mouse_down(view, button, x, y)
	local layout = outline.get_layout(view)
	update_scrollbar(view, layout)
	local moved = view.outline_scrollbar:on_mouse_down(button, x, y)

	if type(moved) == "number" then
		outline.set_first_index(
			view,
			1 + math.floor(moved * (layout.maximum_first_index - 1) + 0.5)
		)
	end

	return moved ~= nil or view.outline_scrollbar.dragging
end

--- Handle outline scrollbar dragging
-- @param view table Markdown preview
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean handled
function outline.on_mouse_move(view, x, y, dx, dy)
	local layout = outline.get_layout(view)
	update_scrollbar(view, layout)
	local moved = view.outline_scrollbar:on_mouse_move(x, y, dx, dy)

	if type(moved) == "number" then
		outline.set_first_index(
			view,
			1 + math.floor(moved * (layout.maximum_first_index - 1) + 0.5)
		)
	end

	return moved == true or type(moved) == "number"
end

--- Return heading entry under a pointer
-- @param view table Markdown preview
-- @param x number pointer x
-- @param y number pointer y
-- @return table|nil entry heading entry
function outline.get_entry_at(view, x, y)
	if not outline.contains(view, x, y) then
		return nil
	end

	local layout = outline.get_layout(view)
	update_scrollbar(view, layout)

	if view.outline_scrollbar:overlaps(x, y) then
		return nil
	end
	local row_index = math.floor((y - layout.y - layout.padding_y) / layout.row_height)

	if row_index < 0 or row_index >= layout.visible_count then
		return nil
	end

	return view.outline_entries[layout.first_index + row_index]
end

--- Draw visible heading outline
-- @param view table Markdown preview
function outline.draw(view)
	local layout = outline.get_layout(view)

	if layout.width <= 0 then
		return
	end
	update_scrollbar(view, layout)

	renderer.draw_rect(
		layout.x,
		layout.y,
		layout.width,
		layout.height,
		markdown_design.get_outline_background_color()
	)
	renderer.draw_rect(
		layout.x,
		layout.y,
		math.max(1, markdown_design.outline.separator_width * (SCALE or 1)),
		layout.height,
		markdown_design.get_outline_separator_color()
	)

	core.push_clip_rect(layout.x, layout.y, layout.width, layout.height)
	for visible_index = 0, layout.visible_count - 1 do
		local entry_index = layout.first_index + visible_index
		local entry = view.outline_entries[entry_index]

		if not entry then
			break
		end

		local row_y = layout.y + layout.padding_y + visible_index * layout.row_height
		local indent = (entry.level - 1) * markdown_design.outline.indent_width * (SCALE or 1)

		if entry_index == layout.active_index then
			renderer.draw_rect(
				layout.x,
				row_y,
				layout.width,
				layout.row_height,
				markdown_design.get_outline_active_background_color()
			)
		end

		renderer.draw_text(
			layout.font,
			entry.text,
			layout.x + layout.padding_x + indent,
			row_y + (layout.row_height - layout.font:get_height()) / 2,
			markdown_design.get_outline_text_color(entry_index == layout.active_index)
		)
	end
	core.pop_clip_rect()
	view.outline_scrollbar:draw()
end

return outline
