-- lx-bundle:true

---@diagnostic disable: need-check-nil
local core = require("core")
local common = require("core.common")
local command = require("core.command")
local config = require("core.config")
-- Text search helpers are scoped under core.text
local search = require("core.text.search")
local keymap = require("core.keymap")
local style = require("core.style")
local CommandView = require("core.command.view")

local max_previous_finds = 50

-- Returns the active document model
local function doc()
	return core.active_view.doc
end

local previous_finds
local last_doc
local last_fn, last_text
local active_find

-- Marks find/replace input text with error color when no matches are available
local function set_findreplace_input_error_state(has_error)
	if core.command_view then
		core.command_view.line_text_color_override = has_error and style.error or nil
	end
end

-- Detects whether the current command input belongs to find/replace prompts
local function is_findreplace_prompt_open()
	if not core.active_view:is(CommandView) then
		return false
	end
	local label = core.command_view.label or ""
	return common.begins_with(label, "Find ") or common.begins_with(label, "Replace ")
end

-- Handles Escape behavior: clear prompt text first, close if already empty
local function escape_toggle_findreplace_prompt()
	if not is_findreplace_prompt_open() then
		return false
	end
	if core.command_view:get_text() ~= "" then
		core.command_view:set_text("", true)
		set_findreplace_input_error_state(false)
	else
		core.command_view:exit()
		set_findreplace_input_error_state(false)
	end
	return true
end

-- Handles Ctrl+F/Ctrl+R behavior: toggle prompt open/close without clearing
local function close_findreplace_prompt_if_open()
	if not is_findreplace_prompt_open() then
		return false
	end
	core.command_view:exit()
	set_findreplace_input_error_state(false)
	return true
end

-- Picks the editor doc view used for initial find/replace seed text
local function get_seed_doc_view()
	local av = core.active_view
	if core.view_can_select(av) then
		return av
	end
	local lav = core.last_active_view
	if core.view_can_select(lav) then
		return lav
	end
	return nil
end

-- Triggers frame flash if the frameflash plugin commands are available
local function flash_frame(kind)
	if kind == "success" then
		command.perform("frameflash:success")
	elseif kind == "error" then
		command.perform("frameflash:error")
	elseif kind == "warning" then
		command.perform("frameflash:warning")
	else
		command.perform("frameflash:info")
	end
end

-- Stores a selection checkpoint for stepping back through find results
local function push_previous_find(doc, sel)
	if last_doc ~= doc then
		last_doc = doc
		previous_finds = {}
	end
	if #previous_finds >= max_previous_finds then
		table.remove(previous_finds, 1)
	end
	table.insert(previous_finds, sel or { doc:get_selection() })
end

-- Resolve the document view even when command view is active
local function get_doc_view()
	local av = core.active_view
	if core.view_can_select(av) then
		return av
	end
	if last_doc then
		local views = core.get_views_referencing_doc(last_doc)
		for i = 1, #views do
			if core.view_can_select(views[i]) then
				return views[i]
			end
		end
	end
end

-- Move one visible page while preserving compatibility with custom view translators
local function move_view_page(dv, direction)
	if not (dv and dv.doc) then
		return false
	end
	local translator = dv.translate and dv.translate[direction]
	if type(translator) ~= "function" then
		return false
	end
	dv.doc:move_to(translator, dv)
	return true
end

-- Replaces matches line-by-line across the whole document so change tracking only
-- marks lines that were actually modified
local function replace_whole_doc_linewise(d, old, new, fn)
	local total = 0
	local ts = system.get_time()
	d.redo_stack = { idx = 1 }
	for i = 1, #d.lines do
		local line = d.lines[i]
		local has_nl = line:sub(-1) == "\n"
		local old_text = has_nl and line:sub(1, -2) or line
		local new_text, n = fn(old_text, old, new)
		n = tonumber(n) or 0
		if n > 0 and new_text ~= old_text then
			local col2 = has_nl and #line or (#line + 1)
			d:raw_replace(i, 1, i, col2, new_text, d.undo_stack, ts, old_text)
			total = total + n
		end
	end
	return total
end

-- Opens incremental find UI and previews matches as the query changes
local function find(label, search_fn, opts)
	opts = opts or {}
	local dv = core.active_view
	local sel = { dv.doc:get_selection() }
	local text = opts.initial_text
	if text == nil then
		text = dv.doc:get_text(table.unpack(sel))
	end
	local found = false
	active_find = {
		dv = dv,
		sel = sel,
		search_fn = search_fn,
		text = "",
		found = false,
		mode = opts.mode or "find",
		replace_fn = opts.replace_fn,
		find_text = opts.find_text,
		match = nil,
		last_match = nil,
		replaced = 0,
	}
	set_findreplace_input_error_state(false)

	local function preview_match(line, col, query)
		local ok, line1, col1, line2, col2 = pcall(search_fn, dv.doc, line, col, query)
		if ok and line1 and query ~= "" then
			dv.doc:set_selection(line2, col2, line1, col1)
			dv.doc.match_selection = { line1, col1, line2, col2 }
			dv:scroll_to_line(line2, true)
			found = true
			last_fn, last_text = search_fn, query
			if last_doc ~= dv.doc then
				last_doc = dv.doc
				previous_finds = {}
			end
			if not previous_finds or #previous_finds == 0 then
				push_previous_find(dv.doc, sel)
			end
			if active_find then
				active_find.found = true
				active_find.match = { line1, col1, line2, col2 }
				active_find.last_match = { line1, col1, line2, col2 }
			end
			set_findreplace_input_error_state(false)
			return true
		end
		dv.doc:set_selection(table.unpack(sel))
		dv.doc.match_selection = nil
		found = false
		if active_find then
			active_find.found = false
			active_find.match = nil
		end
		set_findreplace_input_error_state(query ~= "")
		return false
	end

	-- Finalizes the current match or restores the original selection on failure
	core.command_view:enter({
		caption = label,
		submit = function(text)
			local query = opts.find_text or text
			active_find = nil
			set_findreplace_input_error_state(false)
			if query == "" then
				dv.doc:set_selection(table.unpack(sel))
				dv.doc.match_selection = nil
				dv:scroll_to_make_visible(sel[1], sel[2])
				return
			end
			if found then
				last_fn, last_text = search_fn, query
				previous_finds = {}
				push_previous_find(dv.doc, sel)
			else
				core.error("Couldn't find %q", query)
				dv.doc:set_selection(table.unpack(sel))
				dv.doc.match_selection = nil
				dv:scroll_to_make_visible(sel[1], sel[2])
			end
		end,
		-- Live-updates the current selection to the next matching range
		suggest = function(text)
			local query = opts.find_text or text
			if active_find then
				active_find.text = query
			end
			if opts.mode == "replace" and opts.find_text then
				return
			end
			preview_match(sel[1], sel[2], query)
		end,
		-- Restores the original selection when the command is cancelled explicitly
		cancel = function(explicit)
			active_find = nil
			set_findreplace_input_error_state(false)
			if explicit then
				dv.doc:set_selection(table.unpack(sel))
				dv.doc.match_selection = nil
				dv:scroll_to_make_visible(sel[1], sel[2])
			end
		end,
	})
	core.command_view:set_text(text, true)

	if opts.mode == "replace" and opts.find_text and opts.find_text ~= "" then
		preview_match(sel[1], sel[2], opts.find_text)
	end
end

local function keep_cursor_at_last_match(st)
	if not st or not st.dv or not st.dv.doc then
		return
	end
	st.dv.doc.match_selection = nil
	if config.find_replace_keep_last_match_on_exit == false then
		st.dv.doc:set_selection(table.unpack(st.sel))
		st.dv:scroll_to_make_visible(st.sel[1], st.sel[2])
		return
	end
	local m = st.match or st.last_match
	if m then
		st.dv.doc:set_selection(m[1], m[2], m[1], m[2])
		st.dv:scroll_to_make_visible(m[1], m[2])
	end
end

-- Interactive replace: first collect old/new text, then enter find-like session
local function replace_interactive(kind, default, search_fn, replace_fn)
	core.command_view:enter({
		caption = "Replace " .. kind,
		submit = function(old)
		if old == "" then
			return
		end
		local dv = core.active_view
		local sel = { dv.doc:get_selection() }

		active_find = {
			dv = dv,
			sel = sel,
			search_fn = search_fn,
			text = old,
			found = false,
			mode = "replace",
			replace_fn = replace_fn,
			find_text = old,
			match = nil,
			replaced = 0,
			initialized = false,
		}

		--  local s = string.format("Replace %s %q With", kind, old)
		local s = string.format("Replace %s With", kind)
		set_findreplace_input_error_state(false)
		core.command_view:enter({
			caption = s,
			submit = function(new)
			-- Fallback submit path (Enter is normally intercepted by find-next)
			local st = active_find
			active_find = nil
			set_findreplace_input_error_state(false)
			keep_cursor_at_last_match(st)
			end,
			suggest = function(new)
			local st = active_find
			if not st or st.initialized then
				return
			end
			st.initialized = true
			local ok, line1, col1, line2, col2 = pcall(search_fn, st.dv.doc, st.sel[1], st.sel[2], st.find_text)
			if ok and line1 and st.find_text ~= "" then
				st.dv.doc:set_selection(line2, col2, line1, col1)
				st.dv.doc.match_selection = { line1, col1, line2, col2 }
				st.dv:scroll_to_line(line2, true)
				st.found = true
				st.match = { line1, col1, line2, col2 }
				st.last_match = { line1, col1, line2, col2 }
				last_fn, last_text = st.search_fn, st.find_text
				if last_doc ~= st.dv.doc then
					last_doc = st.dv.doc
					previous_finds = {}
				end
				if not previous_finds or #previous_finds == 0 then
					push_previous_find(st.dv.doc, st.sel)
				end
				set_findreplace_input_error_state(false)
			else
				st.found = false
				st.match = nil
				st.dv.doc:set_selection(table.unpack(st.sel))
				st.dv.doc.match_selection = nil
				set_findreplace_input_error_state(st.find_text ~= "")
			end
			end,
			cancel = function(explicit)
			local st = active_find
			active_find = nil
			set_findreplace_input_error_state(false)
			if explicit and st and st.mode == "replace" then
				keep_cursor_at_last_match(st)
			elseif explicit and st then
				st.dv.doc:set_selection(table.unpack(st.sel))
				st.dv.doc.match_selection = nil
				st.dv:scroll_to_make_visible(st.sel[1], st.sel[2])
			end
			end,
		})
		core.command_view:set_text(old, true)
		end,
	})
	core.command_view:set_text(default, true)
end

-- Prompts for find/replace inputs and applies replacement over selection/document
local function replace(kind, default, fn)
	-- Captures the search term to replace
	core.command_view:enter({
		caption = "Replace " .. kind,
		submit = function(old)
		if old == "" then
			return
		end

		--  local s = string.format("Replace %s %q With", kind, old)
		local s = string.format("Replace %s With", kind)
		-- Captures replacement text and performs the replacement operation
		core.command_view:enter({
			caption = s,
			submit = function(new)
			if old == new then
				return
			end
			-- Applies the chosen replace strategy to each replacement scope
			local ok, n = pcall(function()
				local d = doc()
				if d:has_selection() then
					return d:replace(function(text)
						return fn(text, old, new)
					end)
				end
				return replace_whole_doc_linewise(d, old, new, fn)
			end)
			if not ok then
				flash_frame("error")
				core.error("Replace failed: %s", tostring(n))
				return
			end
			if n > 0 then
				flash_frame("success")
			else
				flash_frame("info")
			end
			core.log("Replaced %d instance(s) of %s %q with %q", n, kind, old, new)
			end,
		})
		core.command_view:set_text(old, true)
		end,
	})
	core.command_view:set_text(default, true)
end

-- Replaces plain literal text with case sensitivity
local function replace_literal(text, old, new)
	return text:gsub(old:gsub("%W", "%%%1"), new:gsub("%%", "%%%%"), nil)
end

-- Replaces plain literal text case-insensitively
local function replace_literal_caseless(text, old, new)
	if old == "" then
		return text, 0
	end
	local lower_text = text:lower()
	local lower_old = old:lower()
	local out = {}
	local last = 1
	local n = 0
	while true do
		local s, e = lower_text:find(lower_old, last, true)
		if not s then
			break
		end
		out[#out + 1] = text:sub(last, s - 1)
		out[#out + 1] = new
		last = e + 1
		n = n + 1
	end
	out[#out + 1] = text:sub(last)
	return table.concat(out), n
end

local function advance_next_match(st, query)
	local line, col = st.dv.doc:get_selection()
	local ok, line1, col1, line2, col2 = pcall(st.search_fn, st.dv.doc, line, col, query)
	if ok and line1 and query ~= "" then
		st.dv.doc:set_selection(line2, col2, line1, col1)
		st.dv.doc.match_selection = { line1, col1, line2, col2 }
		st.dv:scroll_to_line(line2, true)
		st.text = query
		st.found = true
		st.match = { line1, col1, line2, col2 }
		st.last_match = { line1, col1, line2, col2 }
		last_fn, last_text = st.search_fn, query
		set_findreplace_input_error_state(false)
		return true
	end
	st.found = false
	st.match = nil
	st.dv.doc.match_selection = nil
	set_findreplace_input_error_state(query ~= "")
	return false
end

-- Returns true only when command view is active for an in-progress find prompt
local function has_active_find_prompt()
	return core.active_view:is(CommandView) and active_find ~= nil
end

-- Repeat-find supports both doc views and command view, this OR cannot be encoded with current requires tokens
local function can_repeat_find()
	local active_view = core.active_view
	if active_view and active_view.doc and core.view_can_select(active_view) then
		return true
	end
	return active_view and active_view:is(CommandView) or false
end

command.add({
	-- Advances to the next match while keeping the find prompt open
	["find-replace:find-next"] = {
		requires = { "command_view" },
		predicate = has_active_find_prompt,
		perform = function()
			local st = active_find
			local text = core.command_view:get_text()
			local query = st and (st.find_text or text) or text
			if not st then
				return false
			end
			if st.mode == "replace" then
				st.dv.doc.match_selection = nil
			end
			if query == "" then
				set_findreplace_input_error_state(false)
				if st.mode == "replace" then
					core.command_view:exit(true)
					keep_cursor_at_last_match(st)
					active_find = nil
				else
					active_find = nil
					st.dv.doc:set_selection(table.unpack(st.sel))
					st.dv.doc.match_selection = nil
					st.dv:scroll_to_make_visible(st.sel[1], st.sel[2])
					core.command_view:exit(true)
				end
				return true
			end
			if st.mode == "replace" and st.found and st.match and st.replace_fn then
				local repl = text
				local m = st.match
				local old_match = st.dv.doc:get_text(m[1], m[2], m[3], m[4])
				local new_match, rn = st.replace_fn(old_match, query, repl)
				rn = tonumber(rn) or 0
				if rn > 0 and new_match ~= old_match then
					st.dv.doc.redo_stack = { idx = 1 }
					st.dv.doc:raw_replace(
						m[1],
						m[2],
						m[3],
						m[4],
						new_match,
						st.dv.doc.undo_stack,
						system.get_time(),
						old_match
					)
					st.replaced = st.replaced + 1
					local nl, nc = st.dv.doc:position_offset(m[1], m[2], #new_match)
					st.dv.doc:set_selection(nl, nc)
				end
			end
			local moved = advance_next_match(st, query)
			if st.mode == "replace" and not moved then
				core.command_view:exit(true)
				keep_cursor_at_last_match(st)
				active_find = nil
				return true
			end
			return true
		end,
	},

	-- Closes the find prompt and keeps the currently highlighted match
	["find-replace:accept-find"] = {
		requires = { "command_view" },
		predicate = has_active_find_prompt,
		perform = function()
			local st = active_find
			local text = core.command_view:get_text()
			local query = st and (st.find_text or text) or text
			if not st then
				return false
			end
			if st.mode == "replace" then
				st.dv.doc.match_selection = nil
			end
			if st.mode == "replace" then
				local moved = advance_next_match(st, query)
				if not moved then
					core.command_view:exit(true)
					keep_cursor_at_last_match(st)
					active_find = nil
				end
				return true
			end
			local s1, s2 = st.dv.doc:get_selection(true)
			active_find = nil
			if query ~= "" and st.found then
				last_fn, last_text = st.search_fn, query
				previous_finds = {}
				push_previous_find(st.dv.doc, st.sel)
			end
			core.command_view:exit(true)
			-- Keep cursor at match start and clear transient match overlay on accept
			st.dv.doc:set_selection(s1, s2, s1, s2)
			st.dv.doc.match_selection = nil
			st.dv:scroll_to_make_visible(s1, s2)
			return true
		end,
	},

	-- Pages the underlying document up while keeping find/replace prompt active
	["find-replace:page-up"] = {
		requires = { "command_view" },
		predicate = has_active_find_prompt,
		perform = function()
			local st = active_find
			if not st then
				return false
			end
			if not move_view_page(st.dv, "previous_page") then
				return false
			end
			local line, col = st.dv.doc:get_selection()
			st.dv:scroll_to_make_visible(line, col)
			return true
		end,
	},

	-- Pages the underlying document down while keeping find/replace prompt active
	["find-replace:page-down"] = {
		requires = { "command_view" },
		predicate = has_active_find_prompt,
		perform = function()
			local st = active_find
			if not st then
				return false
			end
			if not move_view_page(st.dv, "next_page") then
				return false
			end
			local line, col = st.dv.doc:get_selection()
			st.dv:scroll_to_make_visible(line, col)
			return true
		end,
	},

	-- Handles Esc for find/replace prompts with clear-then-close behavior
	["find-replace:toggle-prompt"] = {
		requires = { "command_view" },
		predicate = is_findreplace_prompt_open,
		perform = function()
			return escape_toggle_findreplace_prompt()
		end,
	},

	-- Finds the next occurrence of the current selection and selects it
	["find-replace:select-next"] = {
		requires = { "doc", "select" },
		perform = function()
			if not core.active_view.doc:has_selection() then
				return
			end
			local l1, c1, l2, c2 = doc():get_selection(true)
			local text = doc():get_text(l1, c1, l2, c2)
			local l1, c1, l2, c2 = search.find(doc(), l2, c2, text, { wrap = true })
			if l2 then
				doc():set_selection(l2, c2, l1, c1)
			end
		end,
	},

	-- Starts case-insensitive plain-text find
	["find-replace:find"] = {
		requires = { "doc", "select" },
		perform = function()
			if close_findreplace_prompt_if_open() then
				return
			end
			local dv = get_seed_doc_view()
			local seed = dv and dv:get_word_under_cursor_or_selection() or ""
			-- Uses wrapped, case-insensitive document search
			find("Find Text", function(doc, line, col, text)
				local opt = { wrap = true, no_case = true }
				return search.find(doc, line, col, text, opt)
			end, { initial_text = seed })
		end,
	},

	-- Starts case-insensitive pattern-based find
	["find-replace:find-pattern"] = {
		requires = { "doc", "select" },
		perform = function()
			-- Uses wrapped, case-insensitive pattern search
			find("Find Text Pattern", function(doc, line, col, text)
				local opt = { wrap = true, no_case = true, pattern = true }
				return search.find(doc, line, col, text, opt)
			end)
		end,
	},

	-- Restores the previous find position from history
	["find-replace:previous-find"] = {
		requires = { "doc", "select" },
		perform = function()
			local sel = table.remove(previous_finds)
			if not sel or doc() ~= last_doc then
				core.error("No previous finds")
				return
			end
			doc():set_selection(table.unpack(sel))
			core.active_view:scroll_to_line(sel[3], true)
		end,
	},

	-- Replaces all plain-text matches, escaping pattern characters
	["find-replace:replace-all"] = {
		requires = { "doc", "edit" },
		perform = function()
			replace("Text", "", replace_literal)
		end,
	},

	-- Replaces all plain-text matches case-insensitively
	["find-replace:caseless-replace-all"] = {
		requires = { "doc", "edit" },
		perform = function()
			replace("Text (Caseless)", "", replace_literal_caseless)
		end,
	},

	-- Transitional alias: step-1 behavior keeps replace == replace-all
	["find-replace:replace"] = {
		requires = { "doc", "edit" },
		perform = function()
			if close_findreplace_prompt_if_open() then
				return
			end
			local dv = get_seed_doc_view()
			local seed = dv and dv:get_word_under_cursor_or_selection() or ""
			replace_interactive("Text", seed, function(doc, line, col, text)
				local opt = { wrap = true }
				return search.find(doc, line, col, text, opt)
			end, replace_literal)
		end,
	},

	-- Replaces pattern matches using Lua pattern semantics
	["find-replace:replace-pattern"] = {
		requires = { "doc", "edit" },
		perform = function()
			-- Applies direct pattern replacement
			replace("Pattern", "", function(text, old, new)
				return text:gsub(old, new)
			end)
		end,
	},

	-- Replaces symbol tokens that exactly match the chosen symbol name
	["find-replace:replace-symbol"] = {
		requires = { "doc", "edit" },
		perform = function()
			local first = ""
			if doc():has_selection() then
				local text = doc():get_text(doc():get_selection())
				first = text:match(config.symbol_pattern) or ""
			end
			-- Replaces only whole-symbol matches based on configured symbol pattern
			replace("Symbol", first, function(text, old, new)
				local n = 0
				-- Substitutes only symbols equal to 'old' and counts replacements
				local res = text:gsub(config.symbol_pattern, function(sym)
					if old == sym then
						n = n + 1
						return new
					end
				end)
				return res, n
			end)
		end,
	},

	-- Repeats the last find operation from the current cursor position
	["find-replace:repeat-find"] = {
		predicate = can_repeat_find,
		perform = function()
			if not last_fn then
				core.error("No find to continue from")
				return
			end
			local dv = get_doc_view()
			if not dv then
				core.error("No document to search")
				return
			end
			local line, col = dv.doc:get_selection()
			local line1, col1, line2, col2 = last_fn(dv.doc, line, col, last_text)
			if line1 then
				push_previous_find(dv.doc)
				dv.doc:set_selection(line2, col2, line1, col1)
				-- Track active match selection for overlay rendering
				dv.doc.match_selection = { line1, col1, line2, col2 }
				dv:scroll_to_line(line2, true)
			end
		end,
	},
})

-- Ensure find prompt key handling wins regardless of other keymap layer bindings
keymap.add({
	["enter"] = "find-replace:find-next",
	["keypad enter"] = "find-replace:find-next",
	["ctrl+return"] = "find-replace:accept-find",
	["ctrl+keypad enter"] = "find-replace:accept-find",
	["pageup"] = "find-replace:page-up",
	["pagedown"] = "find-replace:page-down",
	["escape"] = "find-replace:toggle-prompt",
}, {
	-- Scope find prompt overrides to the command prompt surface instead of intercepting them globally
	layer = "modal",
	context = "command_view",
})
