---@diagnostic disable: duplicate-set-field,need-check-nil,undefined-field
-- 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.

--- Modal outline list popup for the active document view
--- Inspired by Colorer Outliner, my daily go-to whenever I'm in Far

local core = require("core")
local command = require("core.command")
local common = require("core.common")
local config = require("core.config")
local style = require("core.style")
local keymap = require("core.keymap")
local CommandView = require("core.command.view")
local WorkspaceView = require("core.workspace.view")
local Outliner = require("core.text.outliner")

config.outlinelist_width_ratio = config.outlinelist_width_ratio or 0.62
config.outlinelist_height_ratio = config.outlinelist_height_ratio or 0.90
config.outlinelist_padding = config.outlinelist_padding or 10

-- Keep filter text between popup opens unless explicitly disabled
config.outlinelist_remember_filter = config.outlinelist_remember_filter ~= false

-- Tracks popup lifecycle and current filtered list viewport state
local state = {
	active = false,
	view = nil,
	doc = nil,
	all_items = {},
	items = {},
	selected = 1,
	top = 1,
	visible_rows = 1,
	filter_text = "",
	saved_filter_text = "",
	restore_selection = nil,
	restore_scroll = nil,
	opening_line = nil,
}

-- Normalizes left/right modifier key names to keymap modifier slots
local modkey_names = {
	["left ctrl"] = "ctrl",
	["right ctrl"] = "ctrl",
	["left shift"] = "shift",
	["right shift"] = "shift",
	["left alt"] = "alt",
	["right alt"] = "altgr",
}

--- Build a canonical stroke string from one key and the currently pressed modifiers
-- @param key string key name reported by the event layer
-- @return string stroke normalized shortcut text
local function stroke_for_key(key)
	local stroke = ""
	local order = { "ctrl", "alt", "altgr", "shift" }

	for i = 1, #order do
		local mk = order[i]
		if keymap.modkeys[mk] then
			stroke = stroke .. mk .. "+"
		end
	end

	return stroke .. key
end

--- Close the popup when the pressed shortcut currently resolves to its toggle command
-- @param key string key name reported by the event layer
-- @return boolean handled true when the outline toggle handled the shortcut
local function trigger_toggle_if_bound(key)
	local stroke = stroke_for_key(key)
	-- Resolve the active editor context so user bindings are treated exactly like ordinary key dispatch
	local _, commands = keymap.get_commands_for_stroke(stroke)

	for _, cmd in ipairs(commands) do
		if cmd == "outlinelist:toggle" then
			command.perform(cmd)
			return true
		end
	end

	return false
end

-- Returns true when 'v' is a selectable document editor view (not command view)
local function is_doc_editor(v)
	return v and v.doc and core.view_can_select(v) and not v:is(CommandView)
end

--- Build popup rows or one temporary scan-status row
-- @param view table document view
-- @return table items popup items
-- @return boolean initialized true when outline items are complete
local function build_items(view)
	local outliner = Outliner.for_doc(view.doc)
	local items, initialized = outliner:build_items("all")

	if not initialized then
		items[1] = { line = nil, text = "(scanning outline...)" }

		return items, false
	end

	if #items == 0 then
		items[1] = { line = nil, text = "(no outline patterns in current syntax)" }
	end

	return items, true
end

-- Computes centered popup geometry from screen size and config ratios
local function get_popup_rect()
	local screen_width, screen_height = renderer.get_size()
	local popup_width = math.floor(screen_width * common.clamp(config.outlinelist_width_ratio or 0.62, 0.1, 0.95) + 0.5)
	local popup_height =
		math.floor(screen_height * common.clamp(config.outlinelist_height_ratio or 0.90, 0.1, 0.98) + 0.5)
	local popup_x = math.floor((screen_width - popup_width) / 2)
	local popup_y = math.floor((screen_height - popup_height) / 2)

	return popup_x, popup_y, popup_width, popup_height
end

-- Keeps selected row inside the visible list viewport
local function ensure_visible()
	local rows = math.max(1, state.visible_rows or 1)
	local max_top = math.max(1, #state.items - rows + 1)

	if state.selected < state.top then
		state.top = state.selected
	elseif state.selected > state.top + rows - 1 then
		state.top = state.selected - rows + 1
	end

	state.top = common.clamp(state.top, 1, max_top)
end

-- Restore the original editor selection/scroll when popup closes without confirm
local function restore_editor_position()
	if not (state.view and state.doc) then
		return
	end

	if state.restore_selection then
		state.doc:set_selection(table.unpack(state.restore_selection))
	end

	if state.restore_scroll then
		state.view.scroll.x = state.restore_scroll.x
		state.view.scroll.y = state.restore_scroll.y
		state.view.scroll.to.x = state.restore_scroll.to_x
		state.view.scroll.to.y = state.restore_scroll.to_y
	end
end

-- Live-preview the currently selected outline item in the editor
local function preview_selected_item()
	if not (state.active and state.view and state.doc) then
		return
	end

	local item = state.items[state.selected]
	if not (item and item.line) then
		return
	end

	state.doc:set_selection(item.line, 1)
	state.view:scroll_to_line(item.line, false, false)
end

-- Closes popup state; optionally keeps previewed position when confirmed
local function close_popup(confirmed)
	if not state.active then
		return
	end

	-- Persist active filter across closes when configured
	if config.outlinelist_remember_filter then
		state.saved_filter_text = state.filter_text or ""
	else
		state.saved_filter_text = ""
	end

	if not confirmed then
		restore_editor_position()
	end

	state.active = false
	state.view = nil
	state.doc = nil
	state.all_items = {}
	state.items = {}
	state.selected = 1
	state.top = 1
	state.visible_rows = 1
	state.filter_text = state.saved_filter_text or ""
	state.restore_selection = nil
	state.restore_scroll = nil
	state.opening_line = nil
	core.redraw = true
end

--- Apply current filter text and refresh selection
-- @param preferred_line number|nil line to select at or after
local function apply_filter(preferred_line)
	local filter = state.filter_text or ""
	local prev_item = state.items[state.selected]
	local prev_line = prev_item and prev_item.line
	local outliner = Outliner.for_doc(state.doc)

	state.items = outliner:filter_items(state.all_items, filter)

	if #state.items == 0 then
		state.items[1] = { line = nil, text = "(no matches)" }
	end

	state.selected = 1

	if preferred_line then
		for i, item in ipairs(state.items) do
			if item.line then
				state.selected = i
				if item.line >= preferred_line then
					break
				end
			end
		end
	elseif prev_line then
		for i, item in ipairs(state.items) do
			if item.line == prev_line then
				state.selected = i
				break
			end
		end
	end

	state.top = 1
	state.visible_rows = 1

	ensure_visible()
	preview_selected_item()
	core.redraw = true
end

--- Copy the complete search filter and optionally clear it after a successful write
-- @param clear_after_copy boolean true for Cut behavior
local function copy_filter(clear_after_copy)
	local copied = state.filter_text or ""
	local ok, clipboard_error = system.set_clipboard(copied)

	if not ok then
		core.error("%s failed: %s", clear_after_copy and "Cut" or "Copy", tostring(clipboard_error))
		return
	end

	if clear_after_copy and copied ~= "" then
		state.filter_text = ""
		apply_filter()
	end
end

--- Replace the complete search filter with single-line clipboard text
local function paste_filter()
	local pasted, clipboard_error = system.get_clipboard()

	if type(pasted) ~= "string" then
		core.error("Paste failed: %s", tostring(clipboard_error))
		return
	end

	-- Pattern breakdown: one or more CR or LF bytes become one space in this single-line filter
	state.filter_text = pasted:gsub("[\r\n]+", " ")
	apply_filter()
end

-- Remove one UTF-8 codepoint from end of filter text
local function filter_backspace()
	local text = state.filter_text or ""

	if text == "" then
		return
	end
	local i = #text

	while i > 1 and (text:byte(i) or 0) >= 0x80 and (text:byte(i) or 0) < 0xC0 do
		-- Walk left over UTF-8 continuation bytes until codepoint start byte is reached
		i = i - 1
	end

	state.filter_text = text:sub(1, i - 1)
	apply_filter()
end

-- Jumps editor cursor to selected outline item and closes the popup
local function jump_to_selected()
	if not (state.active and state.view and state.doc) then
		return
	end
	local item = state.items[state.selected]

	if item and item.line then
		state.doc:set_selection(item.line, 1)
		state.view:scroll_to_line(item.line, false, false)
	end
	close_popup(true)
end

-- Moves list selection by 'delta' rows with page-flip behavior at edges
local function move_selection(delta)
	if not state.active then
		return
	end
	local prev_selected = state.selected
	local prev_top = state.top
	state.selected = common.clamp(state.selected + delta, 1, #state.items)

	-- Flip page only when crossing from last-visible down or first-visible up
	local rows = math.max(1, state.visible_rows or 1)
	local max_top = math.max(1, #state.items - rows + 1)

	if #state.items > rows then
		local prev_bottom = prev_top + rows - 1
		if delta > 0 and prev_selected == prev_bottom and state.selected > prev_selected then
			state.top = common.clamp(prev_top + rows, 1, max_top)
		elseif delta < 0 and prev_selected == prev_top and state.selected < prev_selected then
			state.top = common.clamp(prev_top - rows, 1, max_top)
		end
	end
	ensure_visible()
	preview_selected_item()
	core.redraw = true
end

-- Flip list by full pages and land on page edge (top for up, bottom for down)
local function move_page(delta_pages)
	if not state.active then
		return
	end

	local rows = math.max(1, state.visible_rows or 1)
	local max_top = math.max(1, #state.items - rows + 1)
	state.top = common.clamp(state.top + (rows * delta_pages), 1, max_top)

	if delta_pages < 0 then
		state.selected = state.top
	else
		state.selected = math.min(#state.items, state.top + rows - 1)
	end

	ensure_visible()
	preview_selected_item()
	core.redraw = true
end

-- Match Esc behavior for keyboard and backdrop click
local function handle_escape()
	if state.filter_text and state.filter_text ~= "" then
		state.filter_text = ""
		apply_filter()
	else
		close_popup()
	end
end

-- Estimate how many rows fit so initial selection can be centered on open
local function estimate_visible_rows()
	local popup_x, popup_y, popup_width, popup_height = get_popup_rect()
	local popup_padding = math.max(1, math.floor((config.outlinelist_padding or 10) * SCALE + 0.5))
	local section_gap = math.max(1, math.floor(popup_padding * 0.6 + 0.5))
	local ui_font_height = style.font:get_height()
	local row_height = math.max(1, math.floor(style.code_font:get_height() * config.line_height))
	local title_y = popup_y + popup_padding
	local hint_y = popup_y + popup_height - popup_padding - ui_font_height
	local content_y = title_y + ui_font_height + section_gap
	local content_height = math.max(1, hint_y - section_gap - content_y)

	return math.max(1, math.floor(content_height / row_height))
end

-- Opens and initializes the outline popup for the active document view
local function open_popup()
	local view = core.active_view
	if not is_doc_editor(view) then
		return
	end
	state.view = view
	state.doc = view.doc
	local cursor_line = state.doc:get_selection()
	state.opening_line = cursor_line

	-- Capture exact editor state so non-confirm close can restore it precisely
	state.restore_selection = { state.doc:get_selection() }
	state.restore_scroll = {
		x = state.view.scroll.x or 0,
		y = state.view.scroll.y or 0,
		to_x = state.view.scroll.to.x or 0,
		to_y = state.view.scroll.to.y or 0,
	}
	state.all_items, state.outline_ready = build_items(view)

	-- Restore previous filter text between opens when enabled
	if config.outlinelist_remember_filter then
		state.filter_text = state.saved_filter_text or ""
	else
		state.filter_text = ""
	end

	state.items = {}
	state.selected = 1
	state.top = 1
	state.visible_rows = 1
	apply_filter(cursor_line)
	if state.outline_ready then
		state.opening_line = nil
	end

	state.active = true
	-- Start with current target around one-third from the top when possible
	state.visible_rows = estimate_visible_rows()
	local rows = math.max(1, state.visible_rows or 1)
	local max_top = math.max(1, #state.items - rows + 1)
	state.top = common.clamp(state.selected - math.floor(rows / 3), 1, max_top)
	ensure_visible()
	preview_selected_item()
	core.redraw = true
end

--- Render modal popup UI, list rows, and scrollbar overlay
-- @return nil
local function draw_popup()
	if not state.active then
		return
	end

	if not state.outline_ready and state.view and state.doc then
		local items, initialized = build_items(state.view)

		if initialized then
			state.all_items = items
			state.outline_ready = true
			apply_filter(state.opening_line)
			state.opening_line = nil
		end
	end

	local screen_width, screen_height = renderer.get_size()
	local popup_x, popup_y, popup_width, popup_height = get_popup_rect()
	local popup_padding = math.max(1, math.floor((config.outlinelist_padding or 10) * SCALE + 0.5))

	-- Keep header/list/footer compact while preserving balanced spacing
	local section_gap = math.max(1, math.floor(popup_padding * 0.6 + 0.5))
	local list_font = style.code_font
	local row_height = math.max(1, math.floor(list_font:get_height() * config.line_height))
	local ui_font = style.font
	local ui_font_height = ui_font:get_height()
	local hint_style = style.line_number2 or style.dim or style.text
	local border_style = hint_style or style.accent or style.text
	local backdrop_style = style.transparent(style.background, 0.6)
	local panel_style = style.background2 or style.background
	local selected_row_style = style.selection or style.line_highlight
	local row_text_style = style.text
	local selected_row_text_style = style.accent or style.text

	-- Use dedicated line-number color in popup rows
	local row_line_number_style = style.line_number2 or style.dim or style.text

	-- Backdrop + framed panel
	renderer.draw_rect(0, 0, screen_width, screen_height, backdrop_style)
	renderer.draw_rect(popup_x, popup_y, popup_width, popup_height, panel_style)
	renderer.draw_rect(popup_x, popup_y, popup_width, 1, border_style)
	renderer.draw_rect(popup_x, popup_y + popup_height - 1, popup_width, 1, border_style)
	renderer.draw_rect(popup_x, popup_y, 1, popup_height, border_style)
	renderer.draw_rect(popup_x + popup_width - 1, popup_y, 1, popup_height, border_style)

	-- Force non-text cursor while modal popup is active
	system.set_mouse_cursor("arrow")

	local title_text = string.format("Outliner [%s]", state.filter_text or "")
	local title_x = popup_x + math.max(0, math.floor((popup_width - ui_font:get_width(title_text)) / 2 + 0.5))
	local title_y = popup_y + popup_padding
	local title_style = ((state.filter_text or "") ~= "") and (style.candidate or style.accent or style.text)
		or border_style

	renderer.draw_text(ui_font, title_text, title_x, title_y, title_style)

	local hint_text = "Type: Filter — Ctrl+X/C/V: Cut/Copy/Paste — [Ctrl] Enter: Jump — Esc: Close"
	local hint_y = popup_y + popup_height - popup_padding - ui_font_height
	local content_y = title_y + ui_font_height + section_gap
	local content_x = popup_x + popup_padding
	local content_width = math.max(1, popup_width - popup_padding * 2)
	local content_height = math.max(1, hint_y - section_gap - content_y)
	local visible_rows = math.max(1, math.floor(content_height / row_height))
	state.visible_rows = visible_rows
	ensure_visible()

	core.push_clip_rect(content_x, content_y, content_width, content_height)
	-- Draw visible slice only; list can be much larger than viewport
	for visible_row_index = 0, visible_rows - 1 do
		local item_index = state.top + visible_row_index

		if item_index > #state.items then
			break
		end
		local row_y = content_y + visible_row_index * row_height
		local item = state.items[item_index]
		local is_selected_row = (item_index == state.selected)

		if is_selected_row then
			renderer.draw_rect(content_x, row_y, content_width, row_height, selected_row_style)
		end
		local row_line_number_text = item.line and string.format("%6d  ", item.line) or "       "
		local row_text_x = content_x + math.floor(2 * SCALE + 0.5)

		renderer.draw_text(list_font, row_line_number_text, row_text_x, row_y, row_line_number_style)
		renderer.draw_text(
			list_font,
			item.text or "",
			row_text_x + list_font:get_width(row_line_number_text),
			row_y,
			is_selected_row and selected_row_text_style or row_text_style
		)
	end
	core.pop_clip_rect()

	if #state.items > visible_rows then
		-- Compute thumb size/position from current top row and visible span
		local scrollbar_track_width = math.max(1, math.floor(2 * SCALE + 0.5))
		local scrollbar_track_x = popup_x + popup_width - popup_padding - scrollbar_track_width
		local scrollbar_track_y = content_y
		local scrollbar_track_height = content_height
		local scrollbar_thumb_height = math.max(
			math.floor(10 * SCALE + 0.5),
			math.floor(scrollbar_track_height * (visible_rows / #state.items) + 0.5)
		)
		local max_top = math.max(1, #state.items - visible_rows + 1)
		local scroll_ratio = (max_top <= 1) and 0 or ((state.top - 1) / (max_top - 1))
		local scrollbar_thumb_y = scrollbar_track_y
			+ math.floor((scrollbar_track_height - scrollbar_thumb_height) * scroll_ratio + 0.5)

		renderer.draw_rect(
			scrollbar_track_x,
			scrollbar_track_y,
			scrollbar_track_width,
			scrollbar_track_height,
			style.line_number2 or style.dim
		)
		renderer.draw_rect(
			scrollbar_track_x,
			scrollbar_thumb_y,
			scrollbar_track_width,
			scrollbar_thumb_height,
			style.accent or style.text
		)
	end

	-- Draw helpful hint text at bottom of window
	local hint_x = popup_x + math.max(0, math.floor((popup_width - ui_font:get_width(hint_text)) / 2 + 0.5))
	renderer.draw_text(ui_font, hint_text, hint_x, hint_y, hint_style)
end

-- Compute current popup list viewport for mouse hit-testing
local function get_content_layout()
	local popup_x, popup_y, popup_width, popup_height = get_popup_rect()
	local popup_padding = math.max(1, math.floor((config.outlinelist_padding or 10) * SCALE + 0.5))

	-- Keep hit-test geometry aligned with compact draw layout
	local section_gap = math.max(1, math.floor(popup_padding * 0.6 + 0.5))
	local ui_font_height = style.font:get_height()
	local row_height = math.max(1, math.floor(style.code_font:get_height() * config.line_height))
	local title_y = popup_y + popup_padding
	local hint_y = popup_y + popup_height - popup_padding - ui_font_height
	local content_y = title_y + ui_font_height + section_gap
	local content_x = popup_x + popup_padding
	local content_width = math.max(1, popup_width - popup_padding * 2)
	local content_height = math.max(1, hint_y - section_gap - content_y)
	local visible_rows = math.max(1, math.floor(content_height / row_height))

	return popup_x,
		popup_y,
		popup_width,
		popup_height,
		content_x,
		content_y,
		content_width,
		content_height,
		row_height,
		visible_rows
end

local _original_root_draw = WorkspaceView.draw

-- Injects popup rendering after normal root view drawing
function WorkspaceView:draw(...)
	_original_root_draw(self, ...)
	draw_popup()
end

local _original_on_event = core.on_event

-- Intercepts input while popup is active and forwards otherwise
function core.on_event(type, ...)
	if state.active then
		if type == "keydown" then
			local key, modifier_state = ...
			-- Keep modal shortcut checks aligned with the latest SDL modifier snapshot
			keymap.sync_modkeys(modifier_state)

			local mk = modkey_names[key]
			if mk then
				keymap.modkeys[mk] = true
				return true
			end
			local handled = false

			if key == "up" then
				move_selection(-1)
				handled = true
			elseif key == "down" then
				move_selection(1)
				handled = true
			elseif key == "home" then
				-- Home jumps to first outline item
				state.selected = 1
				state.top = 1
				ensure_visible()
				preview_selected_item()
				core.redraw = true
				handled = true
			elseif key == "end" then
				-- End jumps to last outline item
				state.selected = #state.items
				local rows = math.max(1, state.visible_rows or 1)
				local max_top = math.max(1, #state.items - rows + 1)
				state.top = max_top
				ensure_visible()
				preview_selected_item()
				core.redraw = true
				handled = true
			elseif key == "pageup" then
				move_page(-1)
				handled = true
			elseif key == "pagedown" then
				move_page(1)
				handled = true
			elseif (key == "return" or key == "keypad enter") and keymap.modkeys.ctrl then
				-- Ctrl+Enter confirms and clears filter so it is not persisted
				state.filter_text = ""
				jump_to_selected()
				handled = true
			elseif (key == "w" or key == "q") and keymap.modkeys.ctrl then
				-- Ctrl+W/Q close the popup immediately without the Esc filter-clear step
				close_popup()
				handled = true
			elseif key == "x" and keymap.modkeys.ctrl then
				-- Ctrl+X copies and clears the complete search filter
				copy_filter(true)
				handled = true
			elseif key == "c" and keymap.modkeys.ctrl then
				-- Ctrl+C copies the complete search filter
				copy_filter(false)
				handled = true
			elseif key == "v" and keymap.modkeys.ctrl then
				-- Ctrl+V replaces the complete search filter instead of appending text
				paste_filter()
				handled = true
			elseif key == "return" or key == "keypad enter" then
				jump_to_selected()
				handled = true
			elseif key == "escape" then
				handle_escape()
				handled = true
			elseif key == "backspace" and keymap.modkeys.ctrl then
				-- Ctrl+Backspace clears the full filter
				if state.filter_text ~= "" then
					state.filter_text = ""
					apply_filter()
				end
				handled = true
			elseif key == "backspace" then
				filter_backspace()
				handled = true
			elseif key == "delete" and keymap.modkeys.shift then
				-- Some layouts emit Shift+Delete where Backspace behavior is expected
				filter_backspace()
				handled = true
			end

			if handled then
				return true
			end

			if trigger_toggle_if_bound(key) then
				return true
			end

			-- Keep popup modal by swallowing ctrl/alt chords while allowing plain textinput
			if keymap.modkeys.ctrl or keymap.modkeys.alt or keymap.modkeys.altgr then
				return true
			end

			return false

		elseif type == "textinput" then
			local text = ...

			if text == "\b" then
				-- Fallback for environments routing Backspace through textinput
				filter_backspace()
			elseif text and text ~= "" then
				state.filter_text = state.filter_text .. text
				apply_filter()
			end

			return true

		elseif type == "mousedown" then
			local button, mx, my, clicks = ...
			local px, py, pw, ph, cx, cy, cw, ch, line_h, rows = get_content_layout()

			-- Right-click anywhere inside popup behaves exactly like Escape
			if button == "right" and (mx >= px and mx < px + pw and my >= py and my < py + ph) then
				-- Right-click on popup uses the exact same path as Esc
				handle_escape()
				return true
			end

			if button == "left" then
				-- Clicking outside panel closes popup and restores editor position
				if not (mx >= px and mx < px + pw and my >= py and my < py + ph) then
					handle_escape()
					return true
				end

				if mx >= cx and mx < cx + cw and my >= cy and my < cy + ch then
					-- Convert click Y to row index in current viewport window
					local row = common.clamp(math.floor((my - cy) / line_h), 0, rows - 1)
					local idx = state.top + row
					if idx >= 1 and idx <= #state.items then
						state.selected = idx
						ensure_visible()
						preview_selected_item()
						if (clicks or 1) >= 2 then
							jump_to_selected()
						end
					end
				end
			end

			return true

		elseif type == "mouseup" or type == "mousemove" then
			return true

		elseif type == "mousewheel" then
			-- Wheel moves outline selection up/down while popup is open
			local a, b, c, d = ...
			local y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a

			if y and y ~= 0 then
				local notches = math.max(1, math.floor(math.abs(y)))
				local steps

				if keymap.modkeys.ctrl then
					-- Ctrl+wheel makes one notch jump half a visible page
					local half_page = math.max(1, math.floor((state.visible_rows or 1) / 2))
					steps = notches * half_page
				else
					-- Default wheel behavior keeps notch-to-line movement
					steps = notches
				end

				move_selection((y > 0) and -steps or steps)
			end

			return true
		end
	end
	return _original_on_event(type, ...)
end

-- Enables popup toggle command with explicit handled/not-handled return
command.register({
	-- Opens the outline popup, or closes it when already open
	["outlinelist:toggle"] = function()
		if state.active then
			close_popup()
			return true
		end
		if is_doc_editor(core.active_view) then
			open_popup()
			return true
		end
		if is_doc_editor(core.last_active_view) then
			core.set_active_view(core.last_active_view)
			open_popup()
			return true
		end
		return false
	end,
})

keymap.bind("plugin", {
	text_editor = {
		["ctrl+d"] = "outlinelist:toggle",
	},
})
