---@diagnostic disable: inject-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.

--- Built-in Browser view and lazy filesystem model
-- @module core.browser.view

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local Doc = require("core.doc")
local keymap = require("core.keymap")
local open_mode = require("core.open_mode")
local style = require("core.style")
local TextDocument = require("core.text.document")
local View = require("core.ui.view")

--- Return path depth by counting slash and backslash separators
local function get_depth(filename)
	local n = 0
	for sep in filename:gmatch("[\\/]") do
		n = n + 1
	end
	return n
end

local Browser = View:extend()

--- Log Browser root and path details only when Browser debug logging is enabled
local function root_debug(fmt, ...)
	if config.debug_browser ~= true then
		return
	end
	core.log_quiet("[browser-root] " .. fmt, ...)
end

--- Return a short file label for Browser messages
-- @param filename string file path
-- @return string label
local function get_file_label(filename)
	-- Pattern breakdown: keep every character after the final slash or backslash
	return filename:match("[^\\/]+$") or filename
end

--- Return the useful native detail from a document-open failure
-- @param open_error any caught document-open failure
-- @return string detail
local function get_open_error_detail(open_error)
	local message = tostring(open_error)
	local operation_labels = {
		"file open failed: ",
		"file read failed: ",
		"file seek failed: ",
		"file close failed: ",
		"path conversion failed: ",
	}

	for index = 1, #operation_labels do
		local label = operation_labels[index]
		local start = message:find(label, 1, true)

		if start then
			return message:sub(start + #label)
		end
	end

	return "The file could not be read"
end

--- Show one non-critical warning for an unreadable Browser file
-- @param filename string file path
-- @param open_error any caught document-open failure
local function show_open_warning(filename, open_error)
	local label = get_file_label(filename)
	local detail = get_open_error_detail(open_error)
	core.status_view:show_warning(string.format('Cannot open "%s": %s', label, detail))
end

--- Return one absolute native path without changing filesystem letter case
local function normalize_path(path)
	if type(path) ~= "string" or path == "" then
		return nil
	end
	local ap = system.absolute_path(path) or path

	return system.normalize_path(ap)
end

--- Return a stable native comparison and cache key
-- @param path string|nil filesystem path
-- @return string|nil key
local function path_key(path)
	local normalized = normalize_path(path)

	return normalized and system.path_key(normalized) or nil
end

--- Return the directory part of a normalized path
local function path_dirname(path)
	local normalized = normalize_path(path)

	return normalized and system.path_dirname(normalized) or nil
end

--- Return whether one path is the root or a descendant of another
-- @param path string candidate path
-- @param root string possible parent root
-- @return boolean within
local function path_is_within(path, root)
	local candidate_key = path_key(path)
	local root_key = path_key(root)

	if not candidate_key or not root_key then
		return false
	end
	if candidate_key == root_key then
		return true
	end
	if candidate_key:sub(1, #root_key) ~= root_key then
		return false
	end
	if root_key:sub(-1) == "/" or root_key:sub(-1) == "\\" then
		return true
	end

	local boundary = candidate_key:sub(#root_key + 1, #root_key + 1)

	return boundary == "/" or boundary == "\\"
end

--- Build a case-insensitive key whose digit runs sort by numeric size
-- @param filename string path or filename
-- @return string key
local function natural_sort_key(filename)
	return (filename:lower():gsub("%d+", function(digits)
		-- Pattern breakdown: '^0+' removes leading zeroes from the matched '%d+' digit run
		local value = digits:gsub("^0+", "")

		if value == "" then
			value = "0"
		end

		return "\0" .. string.format("%08x", #value) .. value .. string.format("%08x", #digits)
	end))
end

--- Compare filesystem entries in natural filename order
-- Letter case breaks otherwise equal ties so the result stays predictable
-- @param first table filesystem entry
-- @param second table filesystem entry
-- @return boolean ordered
local function compare_entries(first, second)
	local first_key = natural_sort_key(first.filename)
	local second_key = natural_sort_key(second.filename)

	if first_key == second_key then
		return first.filename < second.filename
	end

	return first_key < second_key
end

--- Return the real file represented by one document in the Browser
-- Generated unsaved documents point back to their source until first save
-- @param doc table|nil document
-- @return string|nil filename filesystem path
local function get_doc_browser_filename(doc)
	if doc and type(doc.get_reopen_filename) == "function" then
		return doc:get_reopen_filename()
	end

	return doc and doc.filename or nil
end

--- Initialize Browser state and its per-file cache
function Browser:new()
	Browser.super.new(self)
	self.scrollable = true
	self.visible = false -- Final startup visibility is available only after user settings load
	self.init_size = true
	self.cache = {}
	self.doc_root = nil
	self.root_node = nil
	self.root_history = {}
	self.root_items = {}
	self.visible_items = nil
	self.preview_view = nil
	self.editor_view = nil -- Remember where focus should return after using the tree
	self.selected_item = nil
	self.last_clicked_item = nil -- Double-click pinning must stay on one Browser row
end

--- Focus the Browser while remembering the current document view
-- @return boolean focused
function Browser:focus()
	local active_view = core.active_view

	if active_view ~= self and active_view then
		self.editor_view = active_view
	end

	core.set_active_view(self)

	return true
end

--- Return focus to the most recent live document view
-- @return boolean focused
function Browser:focus_editor()
	local root = core.workspace_view.root_node
	local candidates = { self.editor_view, self.preview_view, core.last_active_view }

	for index = 1, #candidates do
		local candidate = candidates[index]

		if candidate and candidate ~= self and root:get_node_for_view(candidate) then
			self.editor_view = candidate
			core.set_active_view(candidate)

			return true
		end
	end

	return false
end

--- Return Escape focus to the remembered editor while the Browser is focused
-- @return boolean handled
function Browser:on_escape()
	if core.active_view ~= self then
		return false
	end

	return self:focus_editor()
end

--- Return key binding contexts for the Browser
-- Browser navigation should not intercept document keys globally
-- @return table contexts ordered from most specific to broadest
function Browser:get_keymap_contexts()
	return { "browser", "global" }
end

--- Return the best-effort current document filename for Browser synchronization
function Browser:get_current_doc_filename()
	local function from_view(v)
		local d = v and v.doc
		return get_doc_browser_filename(d)
	end
	return from_view(core.active_view) or from_view(core.last_active_view)
end

--- Return the normalized active document directory when available
function Browser:get_active_base_dir()
	local function doc_filename_from_view(v)
		local d = v and v.doc

		return get_doc_browser_filename(d)
	end

	local filename = doc_filename_from_view(core.active_view)
		or doc_filename_from_view(core.last_active_view)
		or doc_filename_from_view(
			core.workspace_view and core.workspace_view:get_active_node() and core.workspace_view:get_active_node().active_view
		)

	local ap = normalize_path(filename)
	root_debug("active filename=%s normalized=%s", tostring(filename), tostring(ap))
	if not ap then
		return nil
	end
	local base = path_dirname(ap)
	root_debug("base dirname=%s", tostring(base))
	return base
end

--- Scan one directory level with folders ordered before files
-- Children are deliberately not visited until their own folder expands
-- @param path string directory to read
-- @return table items immediate child metadata
local function scan_dir(path)
	local all = system.list_dir(path) or {}
	local dirs, files = {}, {}
	for i = 1, #all do
		local name = all[i]
		if not common.match_pattern(name, config.ignore_files) then
			local full = system.join_path(path, name)
			local info = system.get_file_info(full)

			if info then
				info.filename = full
				if info.type == "dir" then
					dirs[#dirs + 1] = info
				else
					files[#files + 1] = info
				end
			end
		end
	end

	table.sort(dirs, compare_entries)
	table.sort(files, compare_entries)

	local items = {}

	for index = 1, #dirs do
		items[#items + 1] = dirs[index]
	end

	for index = 1, #files do
		items[#items + 1] = files[index]
	end

	return items
end

--- Return cached metadata for one filesystem entry
-- @param item table filesystem metadata with filename and type
-- @return table cached tree item
function Browser:get_cached(item)
	local filename = normalize_path(item.filename) or item.filename
	local cache_key = path_key(filename) or filename
	local cached = self.cache[cache_key]

	if not cached then
		cached = {
			filename = filename,
			abs_filename = system.absolute_path(filename),
			name = filename:match("[^\\/]+$"),
			depth = get_depth(filename),
			type = item.type,
			children = nil,
			loaded = false,
			expanded = false,
		}
		self.cache[cache_key] = cached
	else
		-- Refresh cheap entry metadata without discarding cached descendants or expansion state
		cached.type = item.type
	end

	return cached
end

--- Load and cache one directory's immediate children
-- @param directory table cached directory item
-- @return table children cached immediate children
function Browser:load_directory(directory)
	if directory.loaded then
		return directory.children or {}
	end

	local raw_items = scan_dir(directory.filename)
	local children = {}

	for index = 1, #raw_items do
		children[index] = self:get_cached(raw_items[index])
	end

	directory.children = children
	directory.loaded = true

	return children
end

--- Rescan one directory while reusing cached items and expansion state
-- @param directory table cached directory item
-- @return boolean refreshed true when the directory was refreshed
function Browser:refresh_directory(directory)
	if not directory or directory.type ~= "dir" then
		return false
	end

	directory.loaded = false
	directory.children = nil
	local children = self:load_directory(directory)

	if directory == self.root_node then
		self.root_items = children
	end

	self.visible_items = nil
	local selected_item = self.selected_item

	if selected_item then
		local selected_is_visible = false

		for _, item in ipairs(self:get_item_list()) do
			if item == selected_item then
				selected_is_visible = true
				break
			end
		end

		if not selected_is_visible then
			self.selected_item = nil
		end
	end

	core.redraw = true

	return true
end

--- Change the stable tree root and optionally remember the previous root
-- @param path string directory to use as root
-- @param remember_previous boolean|nil push the current root into history when true
-- @return boolean changed
function Browser:set_root(path, remember_previous)
	local root = normalize_path(path)
	local root_info = root and system.get_file_info(root) or nil

	if not root_info or root_info.type ~= "dir" then
		return false
	end

	if self.doc_root and path_key(root) == path_key(self.doc_root) then
		return self:refresh_directory(self.root_node)
	end

	if remember_previous and self.doc_root then
		self.root_history[#self.root_history + 1] = self.doc_root
	end

	local root_item = self:get_cached({ filename = root, type = "dir" })
	self.doc_root = root
	self.root_node = root_item
	if root_item.loaded then
		self:refresh_directory(root_item)
	else
		self.root_items = self:load_directory(root_item)
	end
	self.visible_items = nil
	self.selected_item = nil
	self.scroll.x, self.scroll.y = 0, 0
	self.scroll.to.x, self.scroll.to.y = 0, 0
	root_debug("stable root=%s immediate_items=%d", root, #self.root_items)
	core.redraw = true

	return true
end

--- Restore the previous explicitly selected tree root
-- @return boolean changed
function Browser:go_back_root()
	local history_index = #self.root_history
	local previous_root = self.root_history[history_index]

	if not previous_root then
		return false
	end

	if not self:set_root(previous_root, false) then
		return false
	end

	table.remove(self.root_history, history_index)

	return true
end

--- Expand one directory and load only its immediate children
-- @param item table cached tree item
-- @return boolean changed
function Browser:expand_item(item)
	if not item or item.type ~= "dir" then
		return false
	end

	self:load_directory(item)
	item.expanded = true
	self.visible_items = nil
	core.redraw = true

	return true
end

--- Collapse one directory without discarding its cached children
-- @param item table cached tree item
-- @return boolean changed
function Browser:collapse_item(item)
	if not item or item.type ~= "dir" then
		return false
	end

	item.expanded = false
	self.visible_items = nil
	core.redraw = true

	return true
end

--- Initialize an empty tree root from the active document directory
-- Browser activation handles later source-document changes explicitly
-- @return boolean initialized
function Browser:refresh_doc_root_files()
	if not self.visible then
		return false
	end

	if self.doc_root then
		return true
	end

	local root = self:get_active_base_dir()

	return root and self:set_root(root, false) or false
end

--- Follow one source document without narrowing an existing containing root
-- Files outside the current tree rebase without entering the explicit root history
-- @param source_view table view that opened the Browser
-- @return boolean synchronized
function Browser:sync_to_document_view(source_view)
	local doc = source_view and source_view.doc
	local filename = normalize_path(get_doc_browser_filename(doc))
	local parent = filename and path_dirname(filename)

	if not filename or not parent then
		return false
	end

	if not self.doc_root or not path_is_within(filename, self.doc_root) then
		if not self:set_root(parent, false) then
			return false
		end
	end

	return self:sync_selection_to_filename(filename)
end

--- Build the visible flat rows from already-loaded directory children
-- @return table items visible cached items
-- @return number base_depth retained for existing callers
-- @return string|nil root stable tree root
function Browser:get_visible_items()
	if self.visible_items then
		return self.visible_items.items, self.visible_items.base_depth, self.doc_root
	end

	local visible_items = {}

	--- Append loaded descendants while preserving their visual indentation
	-- @param items table siblings to append
	-- @param relative_depth number visual depth below the stable root
	local function append_items(items, relative_depth)
		for index = 1, #items do
			local item = items[index]
			item.rel_depth = relative_depth
			visible_items[#visible_items + 1] = item

			if item.type == "dir" and item.expanded and item.loaded then
				append_items(item.children or {}, relative_depth + 1)
			end
		end
	end

	append_items(self.root_items or {}, 0)

	self.visible_items = {
		items = visible_items,
		base_depth = 0,
	}

	return visible_items, 0, self.doc_root
end

--- Return cached count of rows that are currently visible in the flattened tree
-- @return number visible item count
function Browser:get_visible_item_count()
	local visible_items = self:get_visible_items()

	return #visible_items
end

--- Return whether one document view can remain the reusable Browser preview
-- Dirty and untitled documents must become normal tabs so navigation cannot hide them
-- @param view table|nil document view
-- @return boolean reusable
local function can_reuse_preview(view)
	local doc = view and view.doc

	return doc
		and type(doc.filename) == "string"
		and doc.filename ~= ""
		and type(doc.is_dirty) == "function"
		and not doc:is_dirty()
end

--- Keep one Browser-owned preview first in its document tab strip
-- @param view table|nil preview view
-- @return boolean positioned
function Browser:move_preview_to_leftmost(view)
	local root = core.workspace_view.root_node
	local node = view and root:get_node_for_view(view)

	if not node then
		return false
	end

	local index = node:get_view_idx(view)
	if not index then
		return false
	end

	if index > 1 then
		-- Only Browser ownership may reorder this preview; ordinary tabs keep their relative order
		table.remove(node.views, index)
		table.insert(node.views, 1, view)
	end

	return true
end

--- Mark one document view as the Browser-owned reusable preview
-- @param view table|nil document view
-- @return table|nil view
function Browser:set_preview_view(view)
	if self.preview_view and self.preview_view ~= view then
		self.preview_view.is_browser_preview = false
	end

	self.preview_view = view

	if view then
		view.is_browser_preview = true
		self:move_preview_to_leftmost(view)
	end

	return view
end

--- Promote a dirty, untitled, or closed preview into an ordinary tab
-- Once promoted, a view is never silently reused by Browser navigation again
-- @return boolean promoted
function Browser:promote_preview_if_needed()
	local preview = self.preview_view

	if not preview then
		return false
	end

	local root = core.workspace_view.root_node
	if preview.is_browser_preview == true
		and root:get_node_for_view(preview)
		and can_reuse_preview(preview)
	then
		-- Restore the Browser-owned position if a general tab command moved the preview
		self:move_preview_to_leftmost(preview)

		return false
	end

	preview.is_browser_preview = false
	self.preview_view = nil

	return true
end

--- Return the explicitly marked reusable Browser preview
-- Ordinary clean tabs are never adopted or replaced by Browser navigation
-- @return table|nil view
function Browser:get_preview_target_view()
	self:promote_preview_if_needed()

	return self.preview_view
end

--- Focus an existing view for a document without changing the preview slot
-- @param doc table document instance
-- @return table|nil view
function Browser:focus_existing_doc_view(doc)
	local views = core.get_views_referencing_doc(doc)
	local existing = views[1]

	if not existing then
		return nil
	end

	local node = core.workspace_view.root_node:get_node_for_view(existing)
	if not node then
		return nil
	end

	node:set_active_view(existing)
	self.editor_view = existing

	return existing
end

--- Show one empty read-only preview for a file that could not be opened
-- The transient document stays outside 'core.docs' so selecting it later retries the real file
-- @param filename string requested file path
-- @param open_error any caught document-open failure
-- @return table|nil shown view
function Browser:show_unreadable_preview(filename, open_error)
	show_open_warning(filename, open_error)

	local doc = TextDocument()
	doc.filename = normalize_path(filename) or filename
	doc.is_browser_unreadable = true
	doc.save_disabled = true
	doc.save_disabled_reason = "file could not be read"
	doc:clean()

	local view_options = { open_mode = open_mode.view }

	if not config.browser_reuse_single_tab then
		self:set_preview_view(nil)
		self.editor_view = core.workspace_view:open_doc(doc, view_options)

		return self.editor_view
	end

	local target = self:get_preview_target_view()
	local replacement

	if target then
		replacement = core.workspace_view:replace_doc_view(target, doc, view_options)
	else
		replacement = core.workspace_view:open_doc(doc, view_options)
	end

	if replacement then
		self:set_preview_view(replacement)
		self.editor_view = replacement
	end

	return replacement
end

--- Preview a Browser file through its one reusable document tab
-- @param filename string file path
-- @return table|nil shown view
-- @return any|nil open_error
function Browser:preview_doc_from_browser(filename)
	local opened, doc, open_error = pcall(Doc.open_file, filename)

	if not opened then
		open_error = doc
		doc = nil
	end

	if not doc and open_error then
		local placeholder = self:show_unreadable_preview(filename, open_error)

		return placeholder, open_error
	end

	if not doc then
		return nil
	end

	if not config.browser_reuse_single_tab then
		self:set_preview_view(nil)
		self.editor_view = core.workspace_view:open_doc(doc)

		return self.editor_view
	end

	self:promote_preview_if_needed()

	local existing = self:focus_existing_doc_view(doc)
	if existing then
		return existing
	end

	local target = self:get_preview_target_view()

	if not target then
		target = core.workspace_view:open_doc(doc)
		if target and target.doc then
			self:set_preview_view(target)
			self.editor_view = target
		end

		return target
	end

	local replacement = core.workspace_view:replace_doc_view(target, doc)

	if replacement then
		self:set_preview_view(replacement)
		self.editor_view = replacement
	else
		target = core.workspace_view:open_doc(doc)
		if target and target.doc then
			self:set_preview_view(target)
			self.editor_view = target
		end
	end

	return replacement or target
end

--- Pin one Browser file so later previews cannot replace it
-- @param filename string file path
-- @return table|nil pinned view
function Browser:pin_doc_from_browser(filename)
	local view = self:preview_doc_from_browser(filename)

	if view and view == self.preview_view then
		self:set_preview_view(nil)
	end

	self.editor_view = view or self.editor_view

	return view
end

--- Return the Browser tab title
function Browser:get_name()
	if not self.visible then
		return "Project"
	end
	local _, _, base_dir = self:get_visible_items()
	if not base_dir then
		return "Project"
	end
	local name = base_dir:match("([^/\\]+)$") or base_dir
	if name == "" then
		return "Project"
	end
	return "Project: " .. name
end

--- Return the row height used for each Browser item
function Browser:get_item_height()
	return style.font:get_height() + style.padding.y
end

--- Return total scrollable content height for the current tree rows
-- @return number scrollable height
function Browser:get_scrollable_size()
	local row_height = self:get_item_height()
	local row_count = self:get_visible_item_count()

	return style.padding.y * 2 + row_count * row_height
end

--- Scroll the tree by one quarter of its visible height per wheel step
-- @param a number first wheel argument
-- @param b number|nil second wheel argument
-- @param c number|nil third wheel argument
-- @param d number|nil fourth wheel argument
function Browser:on_mouse_wheel(a, b, c, d)
	-- Preserve the shared horizontal Shift+Wheel behavior
	if keymap.modkeys["shift"] then
		return Browser.super.on_mouse_wheel(self, a, b, c, d)
	end

	-- Native windows include pointer coordinates before the vertical wheel delta
	local y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a
	local wheel_step = self:get_scroll_viewport_height() / 4
	self.scroll.to.y = self.scroll.to.y + y * -wheel_step
end

--- Iterate flattened tree rows and optionally cull them to the viewport
-- Skip collapsed descendants cheaply and stop once rows fall below the viewport during drawing
-- @param only_visible boolean|nil yield only viewport-visible rows when true
function Browser:each_item(only_visible)
	return coroutine.wrap(function()
		local items = self:get_visible_items()
		local ox, oy = self:get_content_offset()
		local y = oy + style.padding.y
		local w = self.size.x
		local h = self:get_item_height()
		local viewport_top
		local viewport_bottom

		if only_visible then
			local _, viewport_y, _, viewport_height = self:get_content_viewport_rect()
			viewport_top = viewport_y
			viewport_bottom = viewport_y + viewport_height
		end

		for index = 1, #items do
			local cached = items[index]
			if not only_visible or (y + h > viewport_top and y < viewport_bottom) then
				coroutine.yield(cached, ox, y, w, h)
			end
			y = y + h

			if only_visible and y >= viewport_bottom then
				break
			end
		end
	end)
end

--- Return currently visible Browser items as a flat list
function Browser:get_item_list()
	local list = {}
	for item in self:each_item() do
		list[#list + 1] = item
	end
	return list
end

--- Keep selection valid against current visible Browser items
function Browser:ensure_selection()
	local items = self:get_item_list()
	if #items == 0 then
		self.selected_item = nil
		return items, nil
	end
	local idx = 1
	if self.selected_item then
		for i = 1, #items do
			if items[i] == self.selected_item then
				idx = i
				break
			end
		end
	end
	self.selected_item = items[idx]
	return items, idx
end

--- Scroll enough to keep the selected row inside the tree viewport
-- @param items table visible tree rows
-- @param index number selected row index
function Browser:scroll_to_selection(items, index)
	local row_height = self:get_item_height()
	local row_top = style.padding.y + (index - 1) * row_height
	local row_bottom = row_top + row_height
	local viewport_height = self:get_scroll_viewport_height()
	local target = self.scroll.to.y

	if row_top < target then
		target = row_top
	elseif row_bottom > target + viewport_height then
		target = row_bottom - viewport_height
	end

	self.scroll.to.y = common.clamp(target, 0, self:get_scroll_max_y())
end

--- Center one selected row in the tree viewport
-- @param items table visible tree rows
-- @param index number selected row index
function Browser:center_selection(items, index)
	local row_height = self:get_item_height()
	local row_center = style.padding.y + (index - 0.5) * row_height
	local target = row_center - self:get_scroll_viewport_height() / 2

	self.scroll.to.y = common.clamp(target, 0, self:get_scroll_max_y())
	self.scroll.y = self.scroll.to.y
end

--- Move keyboard selection by 'delta' rows
function Browser:move_selection(delta)
	local items, idx = self:ensure_selection()
	if not idx then
		return false
	end
	idx = common.clamp(idx + delta, 1, #items)
	self.selected_item = items[idx]
	self:scroll_to_selection(items, idx)
	if self.selected_item and self.selected_item.type == "file" then
		self:preview_doc_from_browser(self.selected_item.filename)
		self:focus() -- Preview creation activates the editor, so keyboard navigation takes focus back
	end
	core.redraw = true
	return true
end

--- Collapse an open folder or select the nearest visible parent Browser row
-- @return boolean changed
function Browser:collapse_selected()
	local items, index = self:ensure_selection()

	if not index then
		return false
	end

	if self.selected_item.type == "dir" and self.selected_item.expanded then
		return self:collapse_item(self.selected_item)
	end

	local selected_depth = self.selected_item.rel_depth or 0
	for parent_index = index - 1, 1, -1 do
		local parent = items[parent_index]

		if (parent.rel_depth or 0) < selected_depth then
			self.selected_item = parent
			self:scroll_to_selection(items, parent_index)
			core.redraw = true

			return true
		end
	end

	-- The hidden Browser root is the parent of top-level rows, so there is no row to select
	return false
end

--- Move keyboard selection by one visible page
function Browser:move_selection_page(delta_pages)
	local step = math.max(1, math.floor(self.size.y / self:get_item_height()))
	return self:move_selection(step * delta_pages)
end

--- Jump keyboard selection to the first or last item
function Browser:move_selection_edge(to_end)
	local items = self:get_item_list()
	if #items == 0 then
		self.selected_item = nil
		return false
	end
	local index = to_end and #items or 1
	self.selected_item = items[index]
	self:scroll_to_selection(items, index)
	if self.selected_item and self.selected_item.type == "file" then
		self:preview_doc_from_browser(self.selected_item.filename)
		self:focus() -- Keep edge navigation in the tree after opening its preview
	end
	core.redraw = true
	return true
end

--- Select one document only when it is already in the loaded visible tree
-- Synchronization never expands folders because expansion owns on-demand loading
-- @param filename string document path to select
-- @return boolean synchronized
function Browser:sync_selection_to_filename(filename)
	local active_key = path_key(filename)
	if not active_key then
		return false
	end

	local items = self:get_item_list()

	for index = 1, #items do
		local item = items[index]

		if item.type == "file" and path_key(item.filename) == active_key then
			self.selected_item = item
			self:center_selection(items, index)

			return true
		end
	end

	return false
end

--- Select the active document only when it is already in the loaded visible tree
-- @return boolean synchronized
function Browser:sync_selection_to_active_doc()
	return self:sync_selection_to_filename(self:get_current_doc_filename())
end

--- Reveal the active document, changing roots only when it lies outside the current tree
-- @return boolean revealed
function Browser:reveal_active_document()
	local filename = normalize_path(self:get_current_doc_filename())
	local parent = path_dirname(filename)

	if not filename or not parent then
		return false
	end

	if not self.doc_root or not path_is_within(filename, self.doc_root) then
		if not self:set_root(parent, self.doc_root ~= nil) then
			return false
		end
	else
		local directory = self.root_node

		while directory and path_key(directory.filename) ~= path_key(parent) do
			local children = self:load_directory(directory)
			local next_directory

			for index = 1, #children do
				local child = children[index]

				if child.type == "dir" and path_is_within(parent, child.filename) then
					next_directory = child
					break
				end
			end

			if not next_directory then
				return false
			end

			self:expand_item(next_directory)
			directory = next_directory
		end
	end

	self.visible_items = nil

	return self:sync_selection_to_active_doc()
end

--- Update the hovered Browser item from current mouse coordinates
function Browser:on_mouse_move(px, py)
	self.hovered_item = nil
	for item, x, y, w, h in self:each_item(true) do
		if px > x and py > y and px <= x + w and py <= y + h then
			self.hovered_item = item
			break
		end
	end
end

--- Toggle directories, preview files, or pin files on mouse press
-- @param button string mouse button
-- @param x number mouse x
-- @param y number mouse y
-- @param clicks number|nil click count
function Browser:on_mouse_down(button, x, y, clicks)
	if not self.hovered_item then
		self.last_clicked_item = nil
		return
	end

	local item = self.hovered_item
	local repeated_left_click = button == "left"
		and item.type == "file"
		and self.last_clicked_item == item
		and (clicks or 0) > 1
	self.last_clicked_item = button == "left" and item.type == "file" and item or nil
	self.selected_item = item

	if item.type == "dir" then
		if item.expanded then
			self:collapse_item(item)
		else
			self:expand_item(item)
		end
	else
		-- Only consecutive left clicks on this same file can pin it
		core.try(function()
			if repeated_left_click then
				self.last_clicked_item = nil
				self:pin_doc_from_browser(item.filename)
				self:focus_editor()
			else
				self:preview_doc_from_browser(item.filename)
				self:focus() -- Clicking a preview keeps subsequent keyboard input in the tree
			end
		end)
	end
end

--- Apply startup visibility once, after every settings layer has loaded
function Browser:apply_startup_visibility()
	if not self.init_size then
		return
	end

	self.visible = config.browser_startup_visible == true
	self.size.x = self.visible and config.browser_width or 0
	self.init_size = false
end

--- Animate tree pane width and maintain its stable lazy tree contents
function Browser:update()
	self:apply_startup_visibility()
	self:promote_preview_if_needed()

	if self.visible then
		self:refresh_doc_root_files()
		if not self.selected_item then
			if not self:sync_selection_to_active_doc() then
				self:ensure_selection()
			end
		end
	end
	-- update width
	local dest = self.visible and config.browser_width or 0
	self:move_towards(self.size, "x", dest)

	Browser.super.update(self)
end

--- Draw the Browser background, row highlights, icons, and filenames
function Browser:draw()
	self:draw_background(style.background2)

	-- Skip row rendering entirely while the pane is hidden so close animation does not keep repainting the tree contents
	if not self.visible then
		return
	end

	if self.size.x <= 0 then
		return
	end

	local icon_width = style.icon_font:get_width("D")
	local spacing = style.font:get_width(" ") * 2

	local av = core.active_view
	local doc = av and av.doc
	local active_key = path_key(get_doc_browser_filename(doc))

	for item, x, y, w, h in self:each_item(true) do
		local color = style.text

		-- highlight active_view doc
		if path_key(item.abs_filename) == active_key then
			color = style.accent
		end

		if item == self.selected_item then
			renderer.draw_rect(x, y, w, h, style.selection)
			color = style.accent
		end

		-- hovered item background
		if item == self.hovered_item then
			renderer.draw_rect(x, y, w, h, style.line_highlight)
			color = style.accent
		end

		-- icons
		x = x + (item.rel_depth or item.depth) * style.padding.x + style.padding.x
		if item.type == "dir" then
			local icon1 = item.expanded and "-" or "+"
			local icon2 = item.expanded and "D" or "d"
			common.draw_text(style.icon_font, color, icon1, nil, x, y, 0, h)
			x = x + style.padding.x
			common.draw_text(style.icon_font, color, icon2, nil, x, y, 0, h)
			x = x + icon_width
		else
			x = x + style.padding.x
			common.draw_text(style.icon_font, color, "f", nil, x, y, 0, h)
			x = x + icon_width
		end

		-- text
		x = x + spacing
		x = common.draw_text(style.font, color, item.name, nil, x, y, 0, h)
	end

	if core.active_view == self then
		-- Draw the focus border last so row highlights cannot cover it
		local x, y = self.position.x, self.position.y
		local width, height = self.size.x, self.size.y
		local border = 1

		renderer.draw_rect(x, y, width, border, style.focus)
		renderer.draw_rect(x, y + height - border, width, border, style.focus)
		renderer.draw_rect(x, y, border, height, style.focus)
		renderer.draw_rect(x + width - border, y, border, height, style.focus)
	end
end

return Browser
