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

local config = require("core.config")
local common = require("core.common")
local hook = require("core.hook")
local Object = require("core.object")

local Outliner = Object:extend()
local outliner_hook_installed = false
local OUTLINE_SCAN_MAX_LINES = 256
local OUTLINE_SCAN_BUDGET_SECONDS = 0.001

local function get_core()
	return package.loaded["core"]
end

--- Return the first useful text capture from one outline match
-- Position captures are numbers and do not describe a readable outline label
-- @param match table packed string.find result
-- @return string|nil label first non-empty text capture
local function get_outline_capture(match)
	for index = 3, match.n do
		local capture = match[index]

		if type(capture) == "string" and capture ~= "" then
			return capture
		end
	end

	return nil
end

--- Match a syntax pattern against a full line for outline navigation
-- @param p table outline pattern definition
-- @param text string current line text
-- @param next_text string|nil following line text
-- @return boolean matched true when the pattern matches
-- @return string|nil label first readable text capture
local function outline_match_line(p, text, next_text)
	if p.next_line and (not next_text or not next_text:match(p.next_line)) then
		return false, nil
	end
	if p.line_not_prefix then
		local trimmed = text:match("^%s*(.*)$")
		if type(p.line_not_prefix) == "string" then
			if trimmed:match("^" .. p.line_not_prefix) then
				return false
			end
		elseif type(p.line_not_prefix) == "table" then
			for _, blocked in ipairs(p.line_not_prefix) do
				if trimmed:match("^" .. blocked) then
					return false
				end
			end
		end
	end

	local pat = (type(p.pattern) == "table") and p.pattern[1] or p.pattern
	if not pat then
		return false, nil
	end

	local match
	if p.line_start then
		match = table.pack(text:find("^" .. pat))
	else
		match = table.pack(text:find(pat))
	end
	local s, e = match[1], match[2]
	if not s then
		return false, nil
	end

	if p.line_prefix then
		local prefix = text:sub(1, s - 1)
		if not prefix:match("^" .. p.line_prefix .. "$") then
			return false
		end
	end

	if p.preceded_by then
		local prefix = text:sub(1, s - 1)
		if not prefix:match(p.preceded_by .. "$") then
			return false
		end
	end

	if p.not_followed_by then
		local suffix = text:sub(e + 1)
		if suffix:match("^" .. p.not_followed_by) then
			return false
		end
	end

	if p.followed_by then
		local suffix = text:sub(e + 1)
		if not suffix:match("^" .. p.followed_by) then
			return false
		end
	end

	return true, get_outline_capture(match)
end

-- Filter outline patterns by consumer mode
local function pattern_enabled_for_mode(p, mode)
	if mode == "jump" then
		return p.jumps ~= false
	end
	if mode == "divider" then
		return p.dividers ~= false
	end
	return true
end

-- Normalize optional outline level metadata per consumer mode
local function normalize_outline_level(p, mode)
	if not p or p.outline_level == nil then
		return nil
	end
	local level = tonumber(p.outline_level)
	if not level then
		return nil
	end
	level = math.floor(level)
	if mode == "divider" then
		if level <= 0 then
			return 0
		end
		return level
	end
	if level < 1 then
		return 1
	end
	return level
end

--- Return a validated syntax outline pattern list
-- @param outliner table document outliner
-- @param doc table document model
-- @return table patterns outline definitions
local function get_outline_patterns(outliner, doc)
	local format_definition = doc.format or {}
	local patterns = format_definition.outline_patterns or {}

	if type(patterns) ~= "table" then
		outliner:disable_outline_definition("pattern", "outline_patterns", "outline_patterns must be a table")

		return {}
	end

	return patterns
end

--- Match one document line against one outline pattern
-- @param doc table document model
-- @param pattern table outline definition
-- @param mode string consumer mode
-- @param line number line index
-- @param text string current line text
-- @param next_text string|nil following line text
-- @return boolean matched true when the line matches
-- @return number|nil level normalized outline level
-- @return string|nil label captured outline label
local function match_outline_pattern(doc, pattern, mode, line, text, next_text)
	if not pattern_enabled_for_mode(pattern, mode) then
		return false, nil, nil
	end

	local matched, label = outline_match_line(pattern, text, next_text)
	if not matched then
		return false, nil, nil
	end

	if pattern.block_start_only and line > 1 then
		local prev_text = doc.lines[line - 1] or ""

		if outline_match_line(pattern, prev_text, text) then
			return false, nil, nil
		end
	end

	return true, normalize_outline_level(pattern, mode), label
end

--- Match one document line while isolating broken outline definitions
-- @param outliner table document outliner
-- @param doc table document model
-- @param patterns table outline definitions
-- @param mode string consumer mode
-- @param line number line index
-- @return boolean matched true when one valid pattern matches
-- @return number|nil level normalized outline level
-- @return string|nil label captured outline label
local function match_outline_line(outliner, doc, patterns, mode, line)
	local text = doc.lines[line]
	if not text then
		return false, nil, nil
	end

	local next_text = doc.lines[line + 1]
	for index, pattern in ipairs(patterns) do
		local definition = type(pattern) == "table" and pattern or tostring(index)

		if not outliner:is_outline_definition_disabled("pattern", definition) then
			local ok, matched, level, label = pcall(
				match_outline_pattern,
				doc,
				pattern,
				mode,
				line,
				text,
				next_text
			)

			if not ok then
				outliner:disable_outline_definition("pattern", definition, matched)
			elseif matched then
				return true, level, label
			end
		end
	end

	return false, nil, nil
end

--- Build an outliner bound to a specific document
-- @param doc table document model
function Outliner:new(doc)
	self.doc = doc
	self._outline_error_format = nil
	self._outline_error_patterns = nil
	self._disabled_outline_definitions = {}
end

--- Reset quarantined definitions when the document format changes
-- @return table disabled definition groups
function Outliner:get_disabled_outline_definitions()
	local format_definition = self.doc and self.doc.format or nil
	local patterns = format_definition and format_definition.outline_patterns or nil

	if self._outline_error_format ~= format_definition or self._outline_error_patterns ~= patterns then
		self._outline_error_format = format_definition
		self._outline_error_patterns = patterns
		self._disabled_outline_definitions = {}
	end

	return self._disabled_outline_definitions
end

--- Return whether one outline definition was quarantined
-- @param kind string definition kind
-- @param definition any stable definition identity
-- @return boolean disabled true when the definition previously failed
function Outliner:is_outline_definition_disabled(kind, definition)
	local disabled = self:get_disabled_outline_definitions()
	local group = disabled[kind]

	return group and group[definition] == true or false
end

--- Report and quarantine one faulty outline definition
-- @param kind string definition kind
-- @param definition any stable definition identity
-- @param err any failure detail
-- @return nil
function Outliner:disable_outline_definition(kind, definition, err)
	local disabled = self:get_disabled_outline_definitions()
	local group = disabled[kind]

	if not group then
		group = {}
		disabled[kind] = group
	end

	if group[definition] then
		return
	end

	group[definition] = true
	local core = get_core()

	if core and type(core.error) == "function" then
		core.error("Broken outline %s disabled: %s", kind, tostring(err))
	else
		print(string.format("[outline] Broken %s disabled: %s", kind, tostring(err)))
	end
end

-- Return per-document outliner instance
function Outliner.for_doc(doc)
	if not doc then
		return nil
	end

	if not outliner_hook_installed then
		outliner_hook_installed = true

		hook.add("core.line_change", {
			id = "outliner-cache",
			-- Keep outliner caches synchronized with document edits without full-file rescans
			dispatch = function(event)
				local changed_doc = event and event.doc
				local outliner = changed_doc and changed_doc._outliner or nil
				if not outliner then
					return
				end

				outliner:handle_line_change(event)
			end,
		})
	end

	local outliner = doc._outliner
	if not outliner or outliner.doc ~= doc then
		outliner = Outliner(doc)
		doc._outliner = outliner
	end
	return outliner
end

--- Return one mode cache, resetting it when the document format changes
-- @param mode string|nil consumer mode
-- @return table cache mode cache
function Outliner:get_mode_cache(mode)
	mode = mode or "jump"

	local doc = self.doc
	local format_definition = doc.format
	doc._outliner_cache = doc._outliner_cache or {}

	local cache = doc._outliner_cache[mode]
	if not cache or cache.format ~= format_definition then
		cache = {
			format = format_definition,
			lines = {},
			points = {},
			point_set = {},
			levels = {},
			line_count = #doc.lines,
			change_id = (doc.get_change_id and doc:get_change_id()) or 0,
			initialized = false,
			scan_line = 1,
			worker_started = false,
			pending_action = nil,
		}
		doc._outliner_cache[mode] = cache
	end

	return cache
end

--- Store one line-local outline result in the mode cache
-- @param cache table mode cache
-- @param mode string consumer mode
-- @param line number line index
-- @param patterns table|nil already resolved outline patterns
-- @return boolean matched cached match state
-- @return number|nil level cached outline level
-- @return string|nil label cached outline label
function Outliner:set_cached_line(cache, mode, line, patterns)
	local doc = self.doc
	patterns = patterns or get_outline_patterns(self, doc)
	local text = doc.lines[line]
	if not text then
		cache.lines[line] = nil
		return false, nil, nil
	end

	local matched, level, label = match_outline_line(self, doc, patterns, mode, line)
	cache.lines[line] = {
		text = text,
		prev_text = doc.lines[line - 1],
		next_text = doc.lines[line + 1],
		matched = matched,
		level = level,
		label = label,
	}

	return matched, level, label
end

--- Reset one mode cache before an incremental full-document scan
-- @param mode string consumer mode
-- @param cache table mode cache
-- @return table cache reset cache
function Outliner:reset_mode_cache(mode, cache)
	cache = cache or self:get_mode_cache(mode)
	cache.lines = {}
	cache.points = {}
	cache.point_set = {}
	cache.levels = {}
	cache.line_count = #self.doc.lines
	cache.change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
	cache.initialized = false
	cache.scan_line = 1

	return cache
end

--- Advance one initial outline scan within fixed work and time bounds
-- @param mode string consumer mode
-- @param cache table mode cache
-- @return table cache updated cache
function Outliner:scan_mode_cache(mode, cache)
	cache = cache or self:get_mode_cache(mode)
	local patterns = get_outline_patterns(self, self.doc)

	if #patterns == 0 then
		cache.initialized = true
		cache.scan_line = #self.doc.lines + 1

		return cache
	end

	local start_time = system.get_time()
	local last_line = math.min(#self.doc.lines, cache.scan_line + OUTLINE_SCAN_MAX_LINES - 1)
	local line = cache.scan_line

	while line <= last_line do
		local matched, level = self:set_cached_line(cache, mode, line, patterns)
		if matched then
			cache.points[#cache.points + 1] = line
			cache.point_set[line] = true
			cache.levels[line] = level
		end

		line = line + 1

		if line % 16 == 0 and system.get_time() - start_time >= OUTLINE_SCAN_BUDGET_SECONDS then
			break
		end
	end

	cache.scan_line = line
	cache.line_count = #self.doc.lines
	cache.change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
	cache.initialized = cache.scan_line > cache.line_count

	return cache
end

--- Continue one initial outline scan through the core background scheduler
-- @param mode string consumer mode
-- @param cache table mode cache
-- @return nil
function Outliner:start_mode_cache_worker(mode, cache)
	local core = get_core()

	if cache.worker_started or not (core and type(core.add_coroutine) == "function") then
		return
	end

	cache.worker_started = true
	core.add_coroutine(function()
		while self.doc._outliner_cache
			and self.doc._outliner_cache[mode] == cache
			and not cache.initialized
		do
			self:scan_mode_cache(mode, cache)
			core.redraw = true

			if not cache.initialized then
				coroutine.yield()
			end
		end

		if cache.initialized and cache.pending_action then
			local action = cache.pending_action
			cache.pending_action = nil
			action()
		end

		cache.worker_started = false
	end, cache)
end

-- Shift unaffected cached line entries after an edited window
local function shift_line_entries(lines, old_recompute_start, old_recompute_end, delta, current_count)
	local shifted = {}
	for line, entry in pairs(lines or {}) do
		if line < old_recompute_start then
			shifted[line] = entry
		elseif line > old_recompute_end then
			local next_line = line + delta
			if next_line >= 1 and next_line <= current_count then
				shifted[next_line] = entry
			end
		end
	end
	return shifted
end

-- Update one initialized mode cache using only the edited line window and suffix shifts
function Outliner:update_mode_cache(cache, mode, event)
	local doc = self.doc
	local current_count = #doc.lines
	local old_count = math.max(0, math.floor(tonumber(cache.line_count) or current_count))
	local line1 = math.max(1, math.floor(tonumber(event.line1) or 1))
	local line2 = math.max(1, math.floor(tonumber(event.line2) or line1))
	local delta = current_count - old_count
	local meta = type(event.meta) == "table" and event.meta or {}
	local added_lines = math.max(0, math.floor(tonumber(meta.added_lines) or math.max(0, delta)))
	local removed_lines = math.max(0, math.floor(tonumber(meta.removed_lines) or math.max(0, -delta)))
	local old_end
	local new_end

	if delta >= 0 then
		new_end = math.min(current_count, line2)
		old_end = math.max(line1 - 1, math.min(old_count, line2 - added_lines))
	else
		old_end = math.min(old_count, line2)
		new_end = math.max(line1 - 1, math.min(current_count, line2 - removed_lines))
	end

	local old_recompute_start = math.max(1, math.min(old_count + 1, line1 - 1))
	local old_recompute_end = math.max(0, math.min(old_count, old_end + 1))
	local new_recompute_start = math.max(1, math.min(current_count + 1, line1 - 1))
	local new_recompute_end = math.max(0, math.min(current_count, new_end + 1))
	local prefix_points = {}
	local suffix_points = {}
	local next_levels = {}

	for i = 1, #cache.points do
		local point = cache.points[i]
		local level = cache.levels[point]
		if point < old_recompute_start then
			prefix_points[#prefix_points + 1] = point
			next_levels[point] = level
		elseif point > old_recompute_end then
			local shifted_point = point + delta
			if shifted_point >= 1 and shifted_point <= current_count then
				suffix_points[#suffix_points + 1] = shifted_point
				next_levels[shifted_point] = level
			end
		end
	end

	cache.lines = shift_line_entries(cache.lines, old_recompute_start, old_recompute_end, delta, current_count)

	local recomputed_points = {}
	for line = new_recompute_start, new_recompute_end do
		local matched, level = self:set_cached_line(cache, mode, line)
		if matched then
			recomputed_points[#recomputed_points + 1] = line
			next_levels[line] = level
		end
	end

	cache.points = {}
	for i = 1, #prefix_points do
		cache.points[#cache.points + 1] = prefix_points[i]
	end
	for i = 1, #recomputed_points do
		cache.points[#cache.points + 1] = recomputed_points[i]
	end
	for i = 1, #suffix_points do
		cache.points[#cache.points + 1] = suffix_points[i]
	end

	cache.point_set = {}
	cache.levels = {}
	for i = 1, #cache.points do
		local point = cache.points[i]
		cache.point_set[point] = true
		cache.levels[point] = next_levels[point]
	end

	cache.line_count = current_count
	cache.change_id = (doc.get_change_id and doc:get_change_id()) or 0
	cache.initialized = true
end

-- Update all initialized mode caches after one document line-change event
function Outliner:handle_line_change(event)
	local doc = self.doc
	if not event or event.doc ~= doc then
		return
	end

	doc._outliner_cache = doc._outliner_cache or {}
	for mode, cache in pairs(doc._outliner_cache) do
		if cache.format ~= doc.format then
			doc._outliner_cache[mode] = nil
		elseif cache.change_id == ((doc.get_change_id and doc:get_change_id()) or 0) then
			-- Skip duplicate work when a pending change was already synchronized on demand
			cache.line_count = #doc.lines
		elseif cache.initialized then
			self:update_mode_cache(cache, mode, event)
		else
			self:reset_mode_cache(mode, cache)
		end
	end
end

-- Apply one pending debounced line change immediately when a caller needs exact outline points now
function Outliner:sync_pending_change(cache, mode)
	local core = get_core()
	local pending = core and core.line_change_pending and core.line_change_pending[self.doc] or nil
	if not pending then
		return false
	end

	local pending_change_id = pending.change_id or 0
	if cache.change_id == pending_change_id then
		return false
	end

	if not cache.initialized then
		return false
	end

	self:update_mode_cache(cache, mode, pending)
	return true
end

--- Return cached line-local outline state keyed by line and neighbor text
-- @param mode string|nil consumer mode
-- @param line number line index
-- @return boolean matched cached match state
-- @return number|nil level cached outline level
-- @return string|nil label cached outline label
function Outliner:match_line(mode, line)
	mode = mode or "jump"
	line = math.max(1, math.floor(tonumber(line) or 1))

	local doc = self.doc
	local cache = self:get_mode_cache(mode)

	local text = doc.lines[line]
	if not text then
		return false, nil
	end

	local prev_text = doc.lines[line - 1]
	local next_text = doc.lines[line + 1]
	local line_cache = cache.lines[line]
	if
		line_cache
		and line_cache.text == text
		and line_cache.prev_text == prev_text
		and line_cache.next_text == next_text
	then
		return line_cache.matched, line_cache.level, line_cache.label
	end

	return self:set_cached_line(cache, mode, line)
end

--- Return incrementally collected outline points and levels
-- @param mode string consumer mode
-- @return table points sorted outline lines
-- @return table levels outline levels keyed by line
-- @return boolean initialized true when the full document is cached
function Outliner:collect_outline(mode)
	local cache = self:get_cache(mode)

	return cache.points, cache.levels, cache.initialized
end

--- Return mode-specific outline data, starting an incremental scan when needed
-- @param mode string|nil consumer mode
-- @return table cache mode cache
function Outliner:get_cache(mode)
	mode = mode or "jump"
	local cache = self:get_mode_cache(mode)
	local current_change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
	if cache.initialized and cache.change_id ~= current_change_id then
		if not self:sync_pending_change(cache, mode) then
			cache = self:reset_mode_cache(mode, cache)
		end
	end
	if cache.initialized then
		return cache
	end

	if cache.change_id ~= current_change_id then
		cache = self:reset_mode_cache(mode, cache)
	end

	cache = self:scan_mode_cache(mode, cache)

	if not cache.initialized then
		self:start_mode_cache_worker(mode, cache)
	end

	return cache
end

--- Return cached jump lines and completion state
-- @return table points sorted outline lines
-- @return boolean initialized true when the full document is cached
function Outliner:get_outline_lines()
	local cache = self:get_cache("jump")

	return cache.points, cache.initialized
end

--- Return cached points, levels, and completion state for one mode
-- @param mode string consumer mode
-- @return table points sorted outline lines
-- @return table levels outline levels keyed by line
-- @return boolean initialized true when the full document is cached
function Outliner:get_points(mode)
	local cache = self:get_cache(mode)
	return cache.points, cache.levels, cache.initialized
end

--- Return cached points, lookup set, levels, and completion state
-- @param mode string consumer mode
-- @return table points sorted outline lines
-- @return table point_set outline membership lookup
-- @return table levels outline levels keyed by line
-- @return boolean initialized true when the full document is cached
function Outliner:get_points_with_set(mode)
	local cache = self:get_cache(mode)
	return cache.points, cache.point_set, cache.levels, cache.initialized
end

--- Normalize one outline row text with optional syntax hook post-processing
-- @param text string|nil source line text
-- @param level number|nil outline level
-- @return string text formatted row text
function Outliner:format_row_text(text, level)
	-- Pattern breakdown:
	-- [\r\n]+ consumes one or more trailing newline bytes
	-- $ anchors trim to end-of-string only
	text = (text or ""):gsub("[\r\n]+$", "")
	text = text:gsub("\t", string.rep(" ", self.doc:get_indent_size()))
	local format_definition = self.doc and self.doc.format or nil
	local outline_hook = type(format_definition and format_definition.outline_hook) == "function"
		and format_definition.outline_hook or nil
	if outline_hook and not self:is_outline_definition_disabled("hook", outline_hook) then
		local ok, hooked = pcall(outline_hook, "format", text, level)

		if not ok then
			self:disable_outline_definition("hook", outline_hook, hooked)

			return text
		end

		if type(hooked) == "string" then
			return hooked
		end
	end
	return text
end

--- Build outline row items after the initial scan is complete
-- @param mode string|nil consumer mode
-- @return table items formatted outline items
-- @return boolean initialized true when items are complete
function Outliner:build_items(mode)
	local cache = self:get_cache(mode or "all")
	local points, levels, initialized = cache.points, cache.levels, cache.initialized
	local items = {}

	if not initialized then
		return items, false
	end

	for i = 1, #points do
		local line = points[i]
		local level = levels and levels[line] or nil
		local line_cache = cache.lines[line]
		-- Captured labels stay concise; patterns without captures keep the complete source line
		local row_text = line_cache and line_cache.label or self.doc.lines[line]
		items[#items + 1] = {
			line = line,
			level = level,
			text = self:format_row_text(row_text, level),
		}
	end
	return items, true
end

--- Filter outline row items by literal or opt-in fuzzy query
-- @param items table outline rows
-- @param filter string|nil search text; one leading space enables fuzzy matching
-- @return table filtered_items
function Outliner:filter_items(items, filter)
	local out = {}
	filter = filter or ""
	-- One leading space switches from plain substring mode to fuzzy matching
	local use_fuzzy_match = filter:sub(1, 1) == " "
	local search_text = use_fuzzy_match and filter:sub(2) or filter

	if search_text == "" then
		for i = 1, #items do
			out[#out + 1] = items[i]
		end
		return out
	end

	for i = 1, #items do
		local item = items[i]
		-- Match filter against both outline text and source line number
		local haystack = string.format("%s %s", tostring(item.line or ""), item.text or "")
		local matched = false
		if use_fuzzy_match then
			matched = common.fuzzy_match(haystack, search_text) ~= nil
		else
			local item_text = item.text or ""
			-- Treat markdown heading markers (#...<space>) as start-of-line prefix in literal mode
			-- Pattern breakdown:
			-- ^ anchors at start
			-- #+ matches one or more heading markers
			-- %s+ requires at least one trailing space after hashes
			-- $ anchors at end so only heading-prefix strings trigger this branch
			if search_text:match("^#+%s+$") then
				matched = item_text:sub(1, #search_text) == search_text
			else
				matched = haystack:find(search_text, 1, true) ~= nil
			end
		end
		if matched then
			out[#out + 1] = item
		end
	end

	return out
end

-- Binary-search next/previous outline target; optional wrap at boundaries
local function find_outline_target(points, line, step, wrap_around)
	if #points == 0 then
		return nil
	end

	if step > 0 then
		local lo, hi = 1, #points
		local first_gt = #points + 1
		while lo <= hi do
			local mid = math.floor((lo + hi) / 2)
			if points[mid] > line then
				first_gt = mid
				hi = mid - 1
			else
				lo = mid + 1
			end
		end
		if first_gt <= #points then
			return points[first_gt]
		end
		return wrap_around and points[1] or nil
	end

	-- previous is strictly lower than current line (<), never same-line
	local lo, hi = 1, #points
	local first_ge = #points + 1
	while lo <= hi do
		local mid = math.floor((lo + hi) / 2)
		if points[mid] >= line then
			first_ge = mid
			hi = mid - 1
		else
			lo = mid + 1
		end
	end
	local prev_idx = first_ge - 1
	if prev_idx >= 1 then
		return points[prev_idx]
	end
	return wrap_around and points[#points] or nil
end

--- Jump to the next or previous outline point when the cache is ready
-- @param step number positive for next, negative for previous
-- @param view table document view
-- @return nil
function Outliner:jump(step, view)
	if not (view and view.doc and view.doc == self.doc) then
		return
	end
	local doc = self.doc
	local line, col = doc:get_selection()
	local cache = self:get_cache("jump")
	local points = cache.points

	if not cache.initialized then
		cache.pending_action = function()
			self:jump(step, view)
		end

		return
	end

	if #points == 0 then
		-- Feedback when syntax has no outline points
		if config.outline_jump_flash then
			doc:flash_line(line, { kind = "error" })
		end
		return
	end

	local wrap_around = config.outline_wrap_around == true
	local target = find_outline_target(points, line, step, wrap_around)
	if not target then
		-- Boundary feedback when wrap is disabled and no outline target exists
		if config.outline_jump_flash then
			doc:flash_line(line, { kind = "error" })
		end
		return
	end

	-- Trigger a short flash on success unless configured for error-only flashes
	if config.outline_jump_flash and not config.outline_jump_flash_only_error then
		doc:flash_line(target, { kind = "success" })
	end
	doc:set_selection(target, col)
	local instant = not (config.outline_jump_animated_scroll == true)
	view:scroll_to_line(target, false, instant)
end

--- Jump to the first or last outline point when the cache is ready
-- @param which string 'first' or 'last'
-- @param view table document view
-- @return nil
function Outliner:jump_edge(which, view)
	if not (view and view.doc and view.doc == self.doc) then
		return
	end
	local doc = self.doc
	local line, col = doc:get_selection()
	local cache = self:get_cache("jump")
	local points = cache.points

	if not cache.initialized then
		cache.pending_action = function()
			self:jump_edge(which, view)
		end

		return
	end

	if #points == 0 then
		if config.outline_jump_flash then
			doc:flash_line(line, { kind = "error" })
		end
		return
	end

	local target = (which == "first") and points[1] or points[#points]
	if config.outline_jump_flash and not config.outline_jump_flash_only_error then
		doc:flash_line(target, { kind = "success" })
	end
	doc:set_selection(target, col)
	local instant = not (config.outline_jump_animated_scroll == true)
	view:scroll_to_line(target, false, instant)
end

return Outliner
