---@diagnostic disable: duplicate-set-field,cast-local-type,param-type-mismatch
-- lx-bundle:true

local core = require("core")
local common = require("core.common")
local config = require("core.config")
local command = require("core.command")
local style = require("core.style")
local keymap = require("core.keymap")
-- Text cursor translation helpers are scoped under core.text
local translate = require("core.text.translate")
local WorkspaceView = require("core.workspace.view")
local DocView = require("core.text.editor")
local TextDocument = require("core.text.document")

config.autocomplete_max_suggestions = 8
config.autocomplete_max_item_length = 64

local autocomplete = {}
autocomplete.map = {}

local mt = {
	__tostring = function(t)
		return t.text
	end,
}

function autocomplete.add(t)
	local items = {}
	for text, info in pairs(t.items) do
		if type(text) == "string" and #text <= (config.autocomplete_max_item_length or 256) then
			info = (type(info) == "string") and info
			table.insert(items, setmetatable({ text = text, info = info }, mt))
		end
	end
	autocomplete.map[t.name] = { files = t.files or ".*", items = items }
end

core.add_thread(function()
	local cache = setmetatable({}, { __mode = "k" })

	local function get_symbols(doc)
		local max_len = config.autocomplete_max_item_length or 256
		local i = 1
		local s = {}
		while i < #doc.lines do
			for sym in doc.lines[i]:gmatch(config.symbol_pattern) do
				if #sym <= max_len then
					s[sym] = true
				end
			end
			i = i + 1
			if i % 100 == 0 then
				coroutine.yield()
			end
		end
		return s
	end

	local function cache_is_valid(doc)
		local c = cache[doc]
		return c and c.last_change_id == doc:get_change_id()
	end

	while true do
		local symbols = {}

		-- lift all symbols from all docs
		for _, doc in ipairs(core.docs) do
			-- update the cache if the doc has changed since the last iteration
			if not cache_is_valid(doc) then
				cache[doc] = {
					last_change_id = doc:get_change_id(),
					symbols = get_symbols(doc),
				}
			end
			-- update symbol set with doc's symbol set
			for sym in pairs(cache[doc].symbols) do
				symbols[sym] = true
			end
			coroutine.yield()
		end

		-- update symbols list
		autocomplete.add({ name = "open-docs", items = symbols })

		-- wait for next scan
		local valid = true
		while valid do
			coroutine.yield(1)
			for _, doc in ipairs(core.docs) do
				if not cache_is_valid(doc) then
					valid = false
				end
			end
		end
	end
end)

local partial = ""
local suggestions_idx = 1
local suggestions = {}
local last_line, last_col

local function reset_suggestions()
	suggestions_idx = 1
	suggestions = {}
end

local function get_active_view()
	local av = (core.get_active_view and core.get_active_view())
		or (core.get_view and core.get_view())
		or core.active_view
	if getmetatable(av) == DocView then
		return av
	end
end

-- Autocomplete should only intercept keys while its popup is truly active
local function is_popup_active(av)
	av = av or get_active_view()
	if not av then
		return false
	end
	local line = av.doc:get_selection()
	return #suggestions > 0 and #partial >= 3 and line == last_line
end

-- Close autocomplete immediately on selection changes
do
	local _original_set_selection = TextDocument.set_selection

	TextDocument.set_selection = function(self, line1, col1, line2, col2, swap)
		_original_set_selection(self, line1, col1, line2, col2, swap)
		if #suggestions > 0 then
			reset_suggestions()
			partial = ""
		end
	end
end

local function update_suggestions()
	local max_len = config.autocomplete_max_item_length or 256
	local doc = core.active_view.doc
	local filename = doc and doc.filename or ""

	-- get all relevant suggestions for given filename
	local items = {}
	for _, v in pairs(autocomplete.map) do
		if common.match_pattern(filename, v.files) then
			for _, item in pairs(v.items) do
				table.insert(items, item)
			end
		end
	end

	-- fuzzy match, remove duplicates and store
	items = common.fuzzy_match(items, partial)
	-- Drop exact matches so fully-typed words don't suggest themselves
	local filtered = {}
	for _, item in ipairs(items) do
		if item.text ~= partial and #item.text <= max_len then
			table.insert(filtered, item)
		end
	end
	items = filtered
	local j = 1
	for i = 1, config.autocomplete_max_suggestions do
		suggestions[i] = items[j]
		while items[j] and items[i].text == items[j].text do
			items[i].info = items[i].info or items[j].info
			j = j + 1
		end
	end
end

local function get_partial_symbol()
	local av = get_active_view()
	if not av then
		return ""
	end
	local doc = av.doc
	local line2, col2 = doc:get_selection()
	local line1, col1 = doc:position_offset(line2, col2, translate.start_of_word)
	return doc:get_text(line1, col1, line2, col2)
end

local function get_suggestions_rect(av)
	if #suggestions == 0 then
		return 0, 0, 0, 0
	end

	local line, col = av.doc:get_selection()
	local x, y = av:get_line_screen_position(line)
	x = x + av:get_col_x_offset(line, col - #partial)
	y = y + av:get_line_height() + style.padding.y
	local font = av:get_font()
	local th = font:get_height()

	local max_width = 0
	for _, s in ipairs(suggestions) do
		local w = font:get_width(s.text)
		if s.info then
			w = w + style.font:get_width(s.info) + style.padding.x
		end
		max_width = math.max(max_width, w)
	end

	return x - style.padding.x,
		y - style.padding.y,
		max_width + style.padding.x * 2,
		#suggestions * (th + style.padding.y) + style.padding.y
end

local function draw_suggestions_box(av)
	-- draw background rect
	local rx, ry, rw, rh = get_suggestions_rect(av)
	renderer.draw_rect(rx, ry, rw, rh, style.background3)

	-- draw text
	local font = av:get_font()
	local lh = font:get_height() + style.padding.y
	local y = ry + style.padding.y / 2
	for i, s in ipairs(suggestions) do
		local color = (i == suggestions_idx) and style.accent or style.text
		common.draw_text(font, color, s.text, "left", rx + style.padding.x, y, rw, lh)
		if s.info then
			color = (i == suggestions_idx) and style.text or style.dim
			common.draw_text(style.font, color, s.info, "right", rx, y, rw - style.padding.x, lh)
		end
		y = y + lh
	end
end

-- patch event logic into WorkspaceView
local on_text_input = WorkspaceView.on_text_input
local on_mouse_down = WorkspaceView.on_mouse_down
local update = WorkspaceView.update
local draw = WorkspaceView.draw

WorkspaceView.on_text_input = function(...)
	on_text_input(...)

	local av = get_active_view()
	if av then
		-- update partial symbol and suggestions
		partial = get_partial_symbol()
		if #partial >= 3 then
			update_suggestions()
			last_line, last_col = av.doc:get_selection()
		else
			reset_suggestions()
		end

		-- scroll if rect is out of bounds of view
		local _, y, _, h = get_suggestions_rect(av)
		local limit = av.position.y + av.size.y
		if y + h > limit then
			av.scroll.to.y = av.scroll.y + y + h - limit
		end
	end
end

-- Update autocomplete on backspace/delete as well
do
	local _original_backspace = command.map["doc:backspace"] and command.map["doc:backspace"].perform
	local _original_delete = command.map["doc:delete"] and command.map["doc:delete"].perform

	if _original_backspace then
		command.map["doc:backspace"].perform = function(...)
			_original_backspace(...)
			local av = get_active_view()
			if av then
				partial = get_partial_symbol()
				if #partial >= 3 then
					update_suggestions()
					last_line, last_col = av.doc:get_selection()
				else
					reset_suggestions()
				end
			end
		end
	end

	if _original_delete then
		command.map["doc:delete"].perform = function(...)
			_original_delete(...)
			local av = get_active_view()
			if av then
				partial = get_partial_symbol()
				if #partial >= 3 then
					update_suggestions()
					last_line, last_col = av.doc:get_selection()
				else
					reset_suggestions()
				end
			end
		end
	end
end

WorkspaceView.on_mouse_down = function(...)
	-- Close autocomplete immediately on mouse interactions
	if #suggestions > 0 then
		reset_suggestions()
		partial = ""
	end
	return on_mouse_down(...)
end

WorkspaceView.update = function(...)
	update(...)

	local av = get_active_view()

	if av then
		-- Reset suggestions when switching lines or when partial gets too short
		local line, col = av.doc:get_selection()

		if line ~= last_line then
			reset_suggestions()
			-- Clear partial and sync cursor position after line change
			partial = ""
			last_line, last_col = line, col
			return
		end
		-- Hide autocomplete when the current partial is too short
		local current_partial = get_partial_symbol()

		if #current_partial < 3 then
			reset_suggestions()
			partial = current_partial
			last_line, last_col = line, col
		end
	end
end

WorkspaceView.draw = function(...)
	draw(...)

	local av = get_active_view()

	if is_popup_active(av) then
		-- draw suggestions box after everything else
		core.workspace_view:defer_draw(draw_suggestions_box, av)
	end
end

local function is_autocomplete_popup_active()
	-- Popup visibility depends on live suggestion state, not only static view capabilities
	return is_popup_active()
end

local function can_complete_with_tab()
	-- Tab completion also depends on syntax runtime flags, so predicate is still required
	local active_view = get_active_view()
	if not is_popup_active(active_view) then
		return false
	end
	local syntax = active_view.doc and active_view.doc.syntax
	return not (syntax and syntax.disable_tab_autocomplete)
end

command.add({
	["autocomplete:complete"] = {
		requires = { "doc", "select", "edit" },
		predicate = is_autocomplete_popup_active,
		perform = function()
			local doc = core.active_view.doc
			local line, col = doc:get_selection()
			local text = suggestions[suggestions_idx].text
			doc:remove(line, col - #partial, line, col)
			doc:insert(line, col - #partial, text)
			doc:set_selection(line, col - #partial + #text)
			reset_suggestions()
		end,
	},

	["autocomplete:previous"] = {
		requires = { "doc", "select" },
		predicate = is_autocomplete_popup_active,
		perform = function()
			suggestions_idx = math.max(suggestions_idx - 1, 1)
		end,
	},

	["autocomplete:next"] = {
		requires = { "doc", "select" },
		predicate = is_autocomplete_popup_active,
		perform = function()
			suggestions_idx = math.min(suggestions_idx + 1, #suggestions)
		end,
	},

	["autocomplete:cancel"] = {
		requires = { "doc", "select" },
		predicate = is_autocomplete_popup_active,
		perform = function()
			reset_suggestions()
		end,
	},
	
	["autocomplete:force-newline"] = {
		requires = { "doc", "select", "edit" },
		predicate = is_autocomplete_popup_active,
		perform = function()
			-- Allow newline while autocomplete is open
			command.perform("doc:newline")
		end,
	},

	["autocomplete:complete-tab"] = {
		requires = { "doc", "select", "edit" },
		predicate = can_complete_with_tab,
		perform = function()
			command.perform("autocomplete:complete")
		end,
	},
})

keymap.add({
	["tab"] = "autocomplete:complete-tab",
	--["return"]   = "autocomplete:complete",
	["keypad enter"] = "autocomplete:complete",
	["shift+return"] = "autocomplete:force-newline",
	["shift+keypad enter"] = "autocomplete:force-newline",
	["alt+up"] = "autocomplete:previous",
	["alt+down"] = "autocomplete:next",
	["escape"] = "autocomplete:cancel",
}, {
	-- Autocomplete stays modal because the popup is transient editor state, not its own focused view
	layer = "modal",
})

return autocomplete
