-- lx-bundle:true

---@diagnostic disable: need-check-nil,undefined-field
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 function get_core()
	return package.loaded["core"]
end

-- Match a syntax pattern against a full line for outline navigation
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
	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
	end

	local s, e
	if p.line_start then
		s, e = text:find("^" .. pat)
	else
		s, e = text:find(pat)
	end
	if not s then
		return false
	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
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

-- Returns the syntax outline pattern list for the bound document
local function get_outline_patterns(doc)
	local syn = doc.syntax or {}
	return syn.outline_patterns or {}
end

-- Match one document line against outline patterns for a specific consumer mode
local function match_outline_line(doc, patterns, mode, line)
	local text = doc.lines[line]
	if not text then
		return false, nil
	end

	local next_text = doc.lines[line + 1]
	for _, p in ipairs(patterns) do
		if pattern_enabled_for_mode(p, mode) and outline_match_line(p, text, next_text) then
			if p.block_start_only and line > 1 then
				local prev_text = doc.lines[line - 1] or ""
				if outline_match_line(p, prev_text, text) then
					goto continue_pattern
				end
			end

			return true, normalize_outline_level(p, mode)
		end

		::continue_pattern::
	end

	return false, nil
end

-- Build an outliner bound to a specific document
function Outliner:new(doc)
	self.doc = doc
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 syntax changes
function Outliner:get_mode_cache(mode)
	mode = mode or "jump"

	local doc = self.doc
	local syn = doc.syntax
	doc._outliner_cache = doc._outliner_cache or {}

	local cache = doc._outliner_cache[mode]
	if not cache or cache.syntax ~= syn then
		cache = {
			syntax = syn,
			lines = {},
			points = {},
			point_set = {},
			levels = {},
			line_count = #doc.lines,
			change_id = (doc.get_change_id and doc:get_change_id()) or 0,
			initialized = false,
		}
		doc._outliner_cache[mode] = cache
	end

	return cache
end

-- Store one line-local outline result in the mode cache
function Outliner:set_cached_line(cache, mode, line)
	local doc = self.doc
	local patterns = get_outline_patterns(doc)
	local text = doc.lines[line]
	if not text then
		cache.lines[line] = nil
		return false, nil
	end

	local matched, level = match_outline_line(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,
	}

	return matched, level
end

-- Build a full sorted point cache for one mode from current document text
function Outliner:build_mode_cache(mode, cache)
	cache = cache or self:get_mode_cache(mode)
	cache.lines = {}
	cache.points = {}
	cache.point_set = {}
	cache.levels = {}

	for line = 1, #self.doc.lines do
		local matched, level = self:set_cached_line(cache, mode, line)
		if matched then
			cache.points[#cache.points + 1] = line
			cache.point_set[line] = true
			cache.levels[line] = level
		end
	end

	cache.line_count = #self.doc.lines
	cache.change_id = (self.doc.get_change_id and self.doc:get_change_id()) or 0
	cache.initialized = true
	return 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.syntax ~= doc.syntax 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
			cache.lines = {}
			cache.line_count = #doc.lines
			cache.change_id = (doc.get_change_id and doc:get_change_id()) or 0
		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 match state keyed by line and neighbor text
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
	end

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

-- Collect outline points and optional levels from syntax-defined patterns
function Outliner:collect_outline(mode)
	local doc = self.doc
	local patterns = get_outline_patterns(doc)
	local points = {}
	local levels = {}
	if #patterns == 0 then
		return points, levels
	end

	for line = 1, #doc.lines do
		local matched, level = match_outline_line(doc, patterns, mode, line)
		if matched then
			points[#points + 1] = line
			levels[line] = level
		end
	end

	return points, levels
end

-- Cache mode-specific outline data per change-id and syntax identity
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:build_mode_cache(mode, cache)
		end
	end
	if cache.initialized then
		return cache
	end

	cache = self:build_mode_cache(mode, cache)
	return cache
end

-- Returns cached outline line list for compatibility with jump callers
function Outliner:get_outline_lines()
	return self:get_cache("jump").points
end

-- Returns cached points and level map for a given mode
function Outliner:get_points(mode)
	local cache = self:get_cache(mode)
	return cache.points, cache.levels
end

-- Returns cached points plus point-set lookup for O(1) membership checks
function Outliner:get_points_with_set(mode)
	local cache = self:get_cache(mode)
	return cache.points, cache.point_set, cache.levels
end

-- Normalize one outline row text with optional syntax hook post-processing
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 syntax = self.doc and self.doc.syntax or nil
	local outline_hook = type(syntax and syntax.outline_hook) == "function" and syntax.outline_hook or nil
	if outline_hook then
		local hooked = outline_hook("format", text, level)
		if type(hooked) == "string" then
			return hooked
		end
	end
	return text
end

-- Build outline row items for UI consumers from cached points and levels
function Outliner:build_items(mode)
	local points, levels = self:get_points(mode or "all")
	local items = {}
	for i = 1, #points do
		local line = points[i]
		local level = levels and levels[line] or nil
		items[#items + 1] = {
			line = line,
			level = level,
			text = self:format_row_text(self.doc.lines[line], level),
		}
	end
	return items
end

-- Filter outline row items by fuzzy or literal query
function Outliner:filter_items(items, filter)
	local out = {}
	filter = filter or ""
	-- Leading literal space switches from fuzzy mode to plain substring mode
	local use_literal_substring = filter:sub(1, 1) == " "
	local literal_filter = use_literal_substring and filter:sub(2) or filter

	if filter == "" or (use_literal_substring and literal_filter == "") 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_literal_substring then
			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 literal_filter:match("^#+%s+$") then
				matched = item_text:sub(1, #literal_filter) == literal_filter
			else
				matched = haystack:find(literal_filter, 1, true) ~= nil
			end
		else
			matched = common.fuzzy_match(haystack, filter) ~= nil
		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 next/previous outline point (optionally wrapping at boundaries)
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 points = self:get_cache("jump").points
	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 first/last outline point
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 points = self:get_cache("jump").points
	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
