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

-- Tracks unsaved line changes and renders gutter indicators
-- Lightweight, avoids heavy diffing

local core = require("core")
local config = require("core.config")
local style = require("core.style")
local command = require("core.command")
local keymap = require("core.keymap")
local hook = require("core.hook")
local DocView = require("core.text.editor")
local TextDocument = require("core.text.document")

config.changetracker_enabled = config.changetracker_enabled ~= false

-- Thin marker width to match bookmark-style gutter accents
config.changetracker_marker_width = config.changetracker_marker_width or 1

-- Change jump navigation options
config.changetracker_wrap_around = config.changetracker_wrap_around == true
config.changetracker_jump_flash = config.changetracker_jump_flash ~= false
config.changetracker_jump_flash_only_error = config.changetracker_jump_flash_only_error == true
config.changetracker_jump_animated_scroll = config.changetracker_jump_animated_scroll ~= false

-- Large edits use compact span tracking instead of per-line mark expansion
config.changetracker_large_edit_threshold = config.changetracker_large_edit_threshold or 2000

-- Undo/redo refresh uses snapshot diff up to this line-count limit
config.changetracker_undo_refresh_threshold = config.changetracker_undo_refresh_threshold or 12000

local state = setmetatable({}, { __mode = "k" })
local changetracker = {}

-- Clears all marker sets
local function clear_marks(marks)
	marks.added = {}
	marks.changed = {}
	marks.deleted = {}
end

-- Clears all compact span lists
local function clear_spans(spans)
	spans.added = {}
	spans.changed = {}
	spans.deleted = {}
end

-- Clears all tracker state for one entry
local function clear_entry(e)
	clear_marks(e.marks)
	clear_spans(e.spans)
end

-- Returns a shallow copy of document lines
local function copy_lines(lines)
	local out = {}
	for i = 1, #lines do
		out[i] = lines[i]
	end
	return out
end

-- Returns internal tracker entry for a document
local function get_entry(doc)
	local e = state[doc]
	if not e then
		e = {
			marks = { added = {}, changed = {}, deleted = {} },
			spans = { added = {}, changed = {}, deleted = {} },
			clean_lines = nil,
		}
		state[doc] = e
	else
		e.marks = e.marks or { added = {}, changed = {}, deleted = {} }
		e.spans = e.spans or { added = {}, changed = {}, deleted = {} }
		e.marks.added = e.marks.added or {}
		e.marks.changed = e.marks.changed or {}
		e.marks.deleted = e.marks.deleted or {}
			e.spans.added = e.spans.added or {}
			e.spans.changed = e.spans.changed or {}
			e.spans.deleted = e.spans.deleted or {}
			if e.clean_lines ~= nil and type(e.clean_lines) ~= "table" then
				e.clean_lines = nil
			end
		end
	return e
end

-- Counts newline bytes quickly with plain find loop
local function count_newlines(text)
	local s = tostring(text or "")
	local n = 0
	local i = 1
	while true do
		local p = s:find("\n", i, true)
		if not p then
			break
		end
		n = n + 1
		i = p + 1
	end
	return n
end

-- Adds one span and merges overlaps/adjacent ranges
local function add_span(spans, kind, line1, line2)
	local t = spans[kind]
	if not t then
		return
	end
	line1 = math.max(1, line1 or 1)
	line2 = math.max(line1, line2 or line1)
	local a, b = line1, line2
	local i = 1
	while i <= #t do
		local r = t[i]
		if b + 1 < r[1] then
			break
		end
		if a > r[2] + 1 then
			i = i + 1
		else
			a = math.min(a, r[1])
			b = math.max(b, r[2])
			table.remove(t, i)
		end
	end
	table.insert(t, i, { a, b })
end

-- Returns true when a line falls in any span of this kind
local function span_contains(spans, kind, line)
	local t = spans and spans[kind]
	if not t then
		return false
	end
	for i = 1, #t do
		local r = t[i]
		if line < r[1] then
			return false
		end
		if line <= r[2] then
			return true
		end
	end
	return false
end

-- Applies compact span mode for very large edits
local function mark_large_edit(e, line1, line2, kind)
	clear_entry(e)
	add_span(e.spans, kind or "changed", line1, line2)
end

-- Captures one clean snapshot right before the first dirtying edit
local function capture_pre_edit_snapshot(doc, e)
	if e.clean_lines then
		return
	end
	if not doc:is_dirty() then
		e.clean_lines = copy_lines(doc.lines)
	end
end

-- Marks all lines in [line1..line2] in the given sparse set
local function mark_range(set, line1, line2)
	line1 = math.max(1, line1)
	line2 = math.max(line1, line2)
	for line = line1, line2 do
		set[line] = true
	end
end

-- Rebuilds marks from clean snapshot after undo/redo operations
local function rebuild_marks_from_snapshot(doc, e)
	clear_entry(e)
	if not doc:is_dirty() then
		return
	end
	local clean = e.clean_lines
	if not clean then
		mark_large_edit(e, 1, #doc.lines, "changed")
		return
	end

	local curr = doc.lines
	local clean_count = #clean
	local curr_count = #curr
	local threshold = math.max(1, tonumber(config.changetracker_undo_refresh_threshold) or 12000)
	if math.max(clean_count, curr_count) > threshold then
		mark_large_edit(e, 1, #doc.lines, "changed")
		return
	end

	if clean_count == curr_count then
		for line = 1, curr_count do
			if clean[line] ~= curr[line] then
				e.marks.changed[line] = true
			end
		end
		return
	end

	local prefix = 0
	local min_count = math.min(clean_count, curr_count)
	while prefix < min_count and clean[prefix + 1] == curr[prefix + 1] do
		prefix = prefix + 1
	end
	local suffix = 0
	while suffix < (min_count - prefix)
		and clean[clean_count - suffix] == curr[curr_count - suffix]
	do
		suffix = suffix + 1
	end

	local curr_start = prefix + 1
	local curr_end = curr_count - suffix
	local clean_mid_count = math.max(0, clean_count - prefix - suffix)
	local curr_mid_count = math.max(0, curr_count - prefix - suffix)

	if curr_mid_count > 0 then
		mark_range(e.marks.changed, curr_start, curr_end)
	end
	if curr_mid_count > clean_mid_count then
		local add_start = curr_start + clean_mid_count
		local add_end = curr_end
		if add_start <= add_end then
			mark_range(e.marks.added, add_start, add_end)
		end
	elseif clean_mid_count > curr_mid_count then
		e.marks.deleted[curr_start] = true
	end
end

-- Shifts sparse-set line indexes starting from 'at' by 'diff'
local function shift_set(set, at, diff)
	if diff == 0 or not next(set) then
		return
	end
	local moved = {}
	for line in pairs(set) do
		if line >= at then
			moved[#moved + 1] = line
		end
	end
	if #moved == 0 then
		return
	end
	for i = 1, #moved do
		set[moved[i]] = nil
	end
	for i = 1, #moved do
		local shifted = moved[i] + diff
		if shifted >= 1 then
			set[shifted] = true
		end
	end
end

-- Removes sparse-set entries in [line1..line2] and shifts following lines by 'diff'
local function remove_range_and_shift_set(set, line1, line2, diff)
	if not next(set) then
		return
	end
	local removed = {}
	local moved = {}
	for line in pairs(set) do
		if line >= line1 and line <= line2 then
			removed[#removed + 1] = line
		elseif line > line2 then
			removed[#removed + 1] = line
			local shifted = line + diff
			if shifted >= 1 then
				moved[#moved + 1] = shifted
			end
		end
	end
	if #removed == 0 then
		return
	end
	for i = 1, #removed do
		set[removed[i]] = nil
	end
	for i = 1, #moved do
		set[moved[i]] = true
	end
end

-- Shifts all per-line marker sets together
local function shift_marks(marks, at, diff)
	shift_set(marks.added, at, diff)
	shift_set(marks.changed, at, diff)
	shift_set(marks.deleted, at, diff)
end

-- Removes a range and shifts all marker sets together
local function remove_range_and_shift_marks(marks, line1, line2, diff)
	remove_range_and_shift_set(marks.added, line1, line2, diff)
	remove_range_and_shift_set(marks.changed, line1, line2, diff)
	remove_range_and_shift_set(marks.deleted, line1, line2, diff)
end

-- Tracks incremental insert effects
local function on_insert(doc, line, text)
	local e = get_entry(doc)
	local marks = e.marks
	local added = count_newlines(text)
	local threshold = math.max(1, tonumber(config.changetracker_large_edit_threshold) or 2000)

	if added >= threshold then
		mark_large_edit(e, line, line + added, "added")
		-- Keep source line context visible
		marks.changed[line] = true
		marks.deleted[line] = nil
		return
	end

	if added > 0 then
		shift_marks(marks, line + 1, added)
		mark_range(marks.added, line + 1, line + added)
		-- Newly inserted lines should not retain prior deletion anchors
		for l = line + 1, line + added do
			marks.deleted[l] = nil
		end
	end

	-- The split/edited source line itself changed
	marks.changed[line] = true
	marks.deleted[line] = nil
end

-- Tracks incremental remove effects
local function on_remove(doc, line1, line2)
	local e = get_entry(doc)
	local marks = e.marks
	local removed_extra = math.max(0, line2 - line1)
	local threshold = math.max(1, tonumber(config.changetracker_large_edit_threshold) or 2000)

	if removed_extra >= threshold then
		mark_large_edit(e, line1, line1, "deleted")
		return
	end

	if removed_extra > 0 then
		local diff = -removed_extra
		remove_range_and_shift_marks(marks, line1 + 1, line2, diff)
		marks.deleted[line1] = true
		-- Keep deletion anchor visible (do not let changed/added mask it)
		marks.changed[line1] = nil
		marks.added[line1] = nil
		return
	end

	marks.changed[line1] = true
	marks.deleted[line1] = nil
end

-- Tracks incremental replace effects
local function on_replace(doc, line1, line2, new_text)
	local e = get_entry(doc)
	local marks = e.marks
	local old_count = math.max(1, line2 - line1 + 1)
	local new_count = 1 + count_newlines(new_text)
	local threshold = math.max(1, tonumber(config.changetracker_large_edit_threshold) or 2000)

	if old_count >= threshold or new_count >= threshold then
		mark_large_edit(e, line1, line1 + math.max(0, new_count - 1), "changed")
		if new_count < old_count then
			add_span(e.spans, "deleted", line1, line1)
		end
		return
	end

	local delta = new_count - old_count
	if delta > 0 then
		shift_marks(marks, line1 + 1, delta)
		mark_range(marks.added, line1 + 1, line1 + delta)
		for l = line1 + 1, line1 + delta do
			marks.deleted[l] = nil
		end
	elseif delta < 0 then
		local removed = -delta
		remove_range_and_shift_marks(marks, line1 + 1, line1 + removed, delta)
		marks.deleted[line1] = true
		-- Prefer removed marker when lines were actually deleted
		marks.changed[line1] = nil
		marks.added[line1] = nil
	end

	mark_range(marks.changed, line1, line1 + math.max(0, new_count - 1))
	for l = line1, line1 + math.max(0, new_count - 1) do
		if l ~= line1 or delta >= 0 then
			marks.deleted[l] = nil
		end
		-- Specific replacement marks take priority over broad changed range
		if marks.added[l] or marks.deleted[l] then
			marks.changed[l] = nil
		end
	end
end

-- Resets markers after successful saves
hook.add("doc.save", {
	id = "changetracker-clear-on-save",
	after = function(event)
		if not (event and event.ok and event.doc) then
			return
		end
		local e = get_entry(event.doc)
		clear_entry(e)
		e.clean_lines = copy_lines(event.doc.lines)
	end,
})

-- Tracks insert operations incrementally
local raw_insert = TextDocument.raw_insert
function TextDocument:raw_insert(line, col, text, ...)
	local e = get_entry(self)
	capture_pre_edit_snapshot(self, e)
	local before = self:get_change_id()
	raw_insert(self, line, col, text, ...)
	if self:get_change_id() == before then
		return
	end
	on_insert(self, line, text)
end

-- Tracks remove operations incrementally
local raw_remove = TextDocument.raw_remove
function TextDocument:raw_remove(line1, col1, line2, col2, ...)
	local e = get_entry(self)
	capture_pre_edit_snapshot(self, e)
	local before = self:get_change_id()
	raw_remove(self, line1, col1, line2, col2, ...)
	if self:get_change_id() == before then
		return
	end
	on_remove(self, line1, line2)
end

--- Track replace operations and preserve a rejected edit result
-- @return boolean|nil applied result from the text model
local raw_replace = TextDocument.raw_replace
function TextDocument:raw_replace(line1, col1, line2, col2, new_text, ...)
	local e = get_entry(self)
	capture_pre_edit_snapshot(self, e)
	local before = self:get_change_id()
	local applied = raw_replace(self, line1, col1, line2, col2, new_text, ...)
	if applied == false then
		return false
	end
	if self:get_change_id() == before then
		return applied
	end
	on_replace(self, line1, line2, new_text)

	return applied
end

-- Refreshes marks after undo to avoid stale incremental state
local raw_undo = TextDocument.undo
function TextDocument:undo(...)
	local ok, a, b, c, d = pcall(raw_undo, self, ...)
	if not ok then
		error(a, 0)
	end
	local e = state[self]
	if e then
		rebuild_marks_from_snapshot(self, e)
	end
	return a, b, c, d
end

-- Refreshes marks after redo to avoid stale incremental state
local raw_redo = TextDocument.redo
function TextDocument:redo(...)
	local ok, a, b, c, d = pcall(raw_redo, self, ...)
	if not ok then
		error(a, 0)
	end
	local e = state[self]
	if e then
		rebuild_marks_from_snapshot(self, e)
	end
	return a, b, c, d
end

-- Clears tracker data when the last view of a doc is closed
local raw_try_close = DocView.try_close
function DocView:try_close(do_close)
	local doc = self.doc
	raw_try_close(self, function(...)
		do_close(...)
		if #core.get_views_referencing_doc(doc) == 0 then
			state[doc] = nil
		end
	end)
end

local draw_line_gutter = DocView.draw_line_gutter

-- Draws gutter dirty tint and right-side change markers
function DocView:draw_line_gutter(idx, x, y)
	if not config.changetracker_enabled then
		return draw_line_gutter(self, idx, x, y)
	end

	local doc = self.doc
	local entry = get_entry(doc)
	local line_h = self:get_line_height()

	if not doc:is_dirty() then
		-- No unsaved changes: hide all markers
		return draw_line_gutter(self, idx, x, y)
	end

	draw_line_gutter(self, idx, x, y)

	local kind
	if entry.marks.changed[idx] then
		kind = "changed"
	elseif entry.marks.added[idx] then
		kind = "added"
	elseif entry.marks.deleted[idx] then
		kind = "deleted"
	elseif span_contains(entry.spans, "changed", idx) then
		kind = "changed"
	elseif span_contains(entry.spans, "added", idx) then
		kind = "added"
	elseif span_contains(entry.spans, "deleted", idx) then
		kind = "deleted"
	end
	if not kind then
		return
	end

	local marker_color = (kind == "added" and (style.added or style.changed))
		or (kind == "deleted" and (style.removed or style.changed))
		or style.changed
		or style.bookmark
		or style.candidate
		or style.notice
		or style.text
	local marker_w = math.max(1, math.floor((config.changetracker_marker_width or 1) * SCALE + 0.5))
	local bookmark_w = style.padding.x * 0.4
	local gap = math.max(1, math.floor(SCALE + 0.5)) + 1
	-- Place changetracker marker directly to the right of bookmark marker lane
	local marker_x = x + bookmark_w + gap

	-- Use one contiguous full-height marker shape for all change kinds
	renderer.draw_rect(marker_x, y, marker_w, line_h, marker_color)
end

-- Exposes current change marks for optional plugin-to-plugin integrations
function changetracker.get_doc_marks(doc)
	local e = state[doc]
	if not (e and doc and doc.is_dirty and doc:is_dirty()) then
		return nil
	end
	return e.marks
end

-- Exposes compact span marks for large edits
function changetracker.get_doc_mark_spans(doc)
	local e = state[doc]
	if not (e and doc and doc.is_dirty and doc:is_dirty()) then
		return nil
	end
	return e.spans
end

-- Returns sorted unique line numbers for any tracked change kind
local function collect_change_lines(doc)
	local marks = changetracker.get_doc_marks(doc)
	if not marks then
		return {}
	end
	local set = {}
	for line in pairs(marks.added or {}) do
		set[line] = true
	end
	for line in pairs(marks.changed or {}) do
		set[line] = true
	end
	for line in pairs(marks.deleted or {}) do
		set[line] = true
	end
	local spans = changetracker.get_doc_mark_spans(doc)
	if spans then
		for _, r in ipairs(spans.added or {}) do
			set[r[1]] = true
			set[r[2]] = true
		end
		for _, r in ipairs(spans.changed or {}) do
			set[r[1]] = true
			set[r[2]] = true
		end
		for _, r in ipairs(spans.deleted or {}) do
			set[r[1]] = true
			set[r[2]] = true
		end
	end
	local out = {}
	for line in pairs(set) do
		if line >= 1 and line <= #doc.lines then
			out[#out + 1] = line
		end
	end
	table.sort(out)
	return out
end

-- Returns effective marker kind for a line using draw priority
local function kind_for_line(doc, line)
	local marks = changetracker.get_doc_marks(doc)
	if not marks then
		return nil
	end
	if marks.changed[line] then
		return "changed"
	elseif marks.added[line] then
		return "added"
	elseif marks.deleted[line] then
		return "deleted"
	end
	local spans = changetracker.get_doc_mark_spans(doc)
	if spans then
		if span_contains(spans, "changed", line) then
			return "changed"
		elseif span_contains(spans, "added", line) then
			return "added"
		elseif span_contains(spans, "deleted", line) then
			return "deleted"
		end
	end
	return nil
end

-- Returns marker color for a given change kind
local function color_for_kind(kind)
	if kind == "added" then
		return style.added or style.changed or style.notice
	elseif kind == "deleted" then
		return style.removed or style.changed or style.error
	end
	return style.changed or style.notice
end

-- Binary-searches next/previous line from sorted line list
local function find_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

	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

-- Jumps cursor to next/previous tracked change line
local function jump_change(step)
	local view = core.active_view
	if not (view and view.doc and core.view_can_select(view)) then
		return
	end
	local doc = view.doc
	local line, col = doc:get_selection()
	local points = collect_change_lines(doc)
	if #points == 0 then
		if config.changetracker_jump_flash and doc.flash_line then
			doc:flash_line(line, { kind = "error", color = style.removed or style.error })
		end
		return
	end

	local target = find_target(points, line, step, config.changetracker_wrap_around == true)
	if not target then
		if config.changetracker_jump_flash and doc.flash_line then
			doc:flash_line(line, { kind = "error", color = style.removed or style.error })
		end
		return
	end

	if config.changetracker_jump_flash and not config.changetracker_jump_flash_only_error and doc.flash_line then
		local kind = kind_for_line(doc, target)
		doc:flash_line(target, { kind = "success", color = color_for_kind(kind) })
	end

	doc:set_selection(target, col)
	local instant = not (config.changetracker_jump_animated_scroll == true)
	view:scroll_to_line(target, false, instant)
end

command.register({
	-- Jump to next changed line (added/changed/deleted)
	["changetracker:next-change"] = {
		requires = { "doc", "select", "scroll" },
		perform = function()
		jump_change(1)
		end,
	},
	-- Jump to previous changed line (added/changed/deleted)
	["changetracker:previous-change"] = {
		requires = { "doc", "select", "scroll" },
		perform = function()
		jump_change(-1)
		end,
	},
})

keymap.bind("plugin", {
	text_editor = {
		["alt+pageup"] = "changetracker:previous-change",
		["alt+pagedown"] = "changetracker:next-change",
	},
})

return changetracker
