---@diagnostic disable: duplicate-set-field
-- lx-bundle:true

-- Bookmarks plugin
-- Tracks per-document bookmarked lines and keeps marks aligned with edits

-- Core modules used for command registration, rendering, and active views
local core = require("core")
local config = require("core.config")
local command = require("core.command")
local keymap = require("core.keymap")
local style = require("core.style")
local DocView = require("core.text.editor")
local TextDocument = require("core.text.document")

-- Public plugin table
local bookmarks = {}

-- Wrap bookmark navigation when crossing end/start of the document bookmark list
config.bookmarks_wrap_around = config.bookmarks_wrap_around ~= false

-- Weak-key cache:
-- key   = document object
-- value = sparse set of bookmarked line numbers where value[line] = true
-- Weak keys ensure closed documents are released automatically
local cache = {}
setmetatable(cache, {
	__mode = "k",
	__index = function(t, k)
		t[k] = {}
		return t[k]
	end,
})

-- Counts how many newline bytes exist in inserted text
-- Returns the number of newly introduced lines for shift logic
local function count_inserted_lines(text)
	local s = tostring(text or "")

	-- Pattern breakdown:
	-- '\n' matches a newline byte
	-- 'plain = true' means literal matching with no Lua pattern parsing
	local p = s:find("\n", 1, true)
	if not p then
		return 0
	end

	local n = 1
	local i = p + 1
	while true do
		p = s:find("\n", i, true)
		if not p then
			break
		end
		n = n + 1
		i = p + 1
	end
	return n
end

-- Shifts bookmark lines at and after 'at' by 'diff'
-- Positive 'diff' moves bookmarks down, negative moves bookmarks up
local function shift_lines(doc, at, diff)
	if diff == 0 then
		return
	end
	local marks = cache[doc]
	if not marks or not next(marks) then
		return
	end

	-- Collect affected lines first to avoid mutating while iterating
	local moved = {}
	for line in pairs(marks) do
		if line >= at then
			moved[#moved + 1] = line
		end
	end
	if #moved == 0 then
		return
	end

	-- Remove old keys
	for i = 1, #moved do
		marks[moved[i]] = nil
	end

	-- Reinsert shifted keys and drop invalid results
	for i = 1, #moved do
		local shifted = moved[i] + diff
		if shifted >= 1 then
			marks[shifted] = true
		end
	end
end

-- Keep a reference to original insert implementation before monkey patching
local raw_insert = TextDocument.raw_insert

--- Insert raw text while keeping bookmarks attached to their original line content
-- @param line integer insertion line
-- @param col integer insertion column
-- @param text string inserted text
-- @param ... any remaining raw insertion values
function TextDocument:raw_insert(line, col, text, ...)
	local original_line = self.lines[line] or "\n"
	raw_insert(self, line, col, text, ...)

	local line_count = count_inserted_lines(text)
	if line_count > 0 then
		local moves_original_content = col <= 1 and original_line ~= "\n"
		shift_lines(self, moves_original_content and line or line + 1, line_count)
	end
end

-- Keep a reference to original remove implementation before monkey patching
local raw_remove = TextDocument.raw_remove

-- Hooks raw remove so bookmarks after removed ranges shift upward
function TextDocument:raw_remove(line1, col1, line2, col2, ...)
	raw_remove(self, line1, col1, line2, col2, ...)
	shift_lines(self, line2, line1 - line2)
end

-- Keep original gutter renderer so bookmark accents can be layered in
local draw_line_gutter = DocView.draw_line_gutter

-- Draws bookmark accent in gutter for bookmarked lines
function DocView:draw_line_gutter(idx, x, y)
	if cache[self.doc] and cache[self.doc][idx] then
		local bookmark_color = style.bookmark or style.candidate
		local h = self:get_line_height()
		renderer.draw_rect(x, y, style.padding.x * 0.4, h, bookmark_color)
	end
	draw_line_gutter(self, idx, x, y)
end

-- Keep original scrollbar renderer so bookmark ticks can be layered in
local draw_scrollbar = DocView.draw_scrollbar

-- Draws bookmark ticks in the scrollbar lane
-- Tick positions are normalized by document line count
function DocView:draw_scrollbar()
	draw_scrollbar(self)

	local marks = cache[self.doc]
	if not marks then
		return
	end

	local line_count = #self.doc.lines
	if line_count < 2 then
		return
	end

	local sx = self.position.x + self.size.x - style.scrollbar_size
	local sy = self.position.y
	local sw = style.scrollbar_size
	local sh = self.size.y
	local tick_h = math.max(2, math.floor(SCALE))
	local bookmark_color = style.bookmark or style.candidate

	for line in pairs(marks) do
		if line >= 1 and line <= line_count then
			local t = (line - 1) / (line_count - 1)
			local y = sy + math.floor(t * math.max(0, sh - tick_h))
			renderer.draw_rect(sx, y, sw, tick_h, bookmark_color)
		end
	end
end

-- Jumps to next/previous bookmark with wrap-around
-- direction: 1 for next, -1 for previous
local function jump_bookmark(direction)
	local doc = core.active_view.doc
	local line = doc:get_selection()
	local marks = cache[doc]

	local first_line = math.huge
	local last_line = 0
	local candidate = (direction > 0) and math.huge or 0

	for bookmarked_line in pairs(marks) do
		if bookmarked_line < first_line then
			first_line = bookmarked_line
		end
		if bookmarked_line > last_line then
			last_line = bookmarked_line
		end
		if direction > 0 then
			if bookmarked_line > line and bookmarked_line < candidate then
				candidate = bookmarked_line
			end
		else
			if bookmarked_line < line and bookmarked_line > candidate then
				candidate = bookmarked_line
			end
		end
	end

	if direction > 0 and candidate == math.huge then
		if config.bookmarks_wrap_around == true then
			candidate = first_line
		end
	elseif direction < 0 and candidate == 0 then
		if config.bookmarks_wrap_around == true then
			candidate = last_line
		end
	end

	local has_target = (direction > 0 and candidate ~= math.huge) or (direction < 0 and candidate > 0)
	if has_target then
		doc:set_selection(candidate, 1)
		core.active_view:scroll_to_line(candidate, true)
	else
		local has_any_bookmark = first_line ~= math.huge
		if has_any_bookmark then
			if doc.flash_line then
				doc:flash_line(line, { kind = "error", color = style.removed or style.error })
			end
		else
			core.log("No bookmarks found. Try to set some first.")
		end
	end
end

-- Registers bookmark commands for document views
command.register({
	-- Toggles bookmark state on current cursor line
	["bookmark:toggle"] = {
		requires = { "doc", "cursor" },
		perform = function()
			local doc = core.active_view.doc
			local line = doc:get_selection()
			local marks = cache[doc]

			if marks[line] then
				marks[line] = nil
			else
				marks[line] = true
			end
		end,
	},

	-- Jumps to next bookmark after cursor line with wrap-around
	["bookmark:go-to-next"] = {
		requires = { "doc", "cursor", "scroll" },
		perform = function()
			jump_bookmark(1)
		end,
	},

	-- Jumps to previous bookmark before cursor line with wrap-around
	["bookmark:go-to-previous"] = {
		requires = { "doc", "cursor", "scroll" },
		perform = function()
			jump_bookmark(-1)
		end,
	},
})

-- Default keybindings for bookmark toggling and navigation
keymap.bind("plugin", {
	text_editor = {
		["ctrl+f2"] = "bookmark:toggle",
		["f2"] = "bookmark:go-to-next",
		["shift+f2"] = "bookmark:go-to-previous",
	},
})

-- Exposes raw bookmark set for optional plugin integrations
function bookmarks.get_doc_marks(doc)
	return rawget(cache, doc)
end

return bookmarks
