-- lx-bundle:true
local core = require("core")
local common = require("core.common")
local command = require("core.command")
local config = require("core.config")
local Doc = require("core.doc")
local keymap = require("core.keymap")
local project = require("core.project")
local LogView = require("core.log.view")
local CommandView = require("core.command.view")
local style = require("core.style")

local fullscreen = false
local find_file_cache = { root = nil, list = nil, time = 0 }

--- Return true when one view is a document-backed non-command view
-- @param v table|nil candidate view
-- @return boolean
local function is_document_view(v)
	return v and v.doc and not (v.is and v:is(CommandView))
end

--- Normalize a filesystem path for stable comparisons
-- Converts separators, strips redundant trailing slash, normalizes drive case
-- @param path string|nil raw path
-- @return string|nil normalized path
local function normalize_path(path)
	if type(path) ~= "string" or path == "" then
		return nil
	end
	local ap = system.absolute_path(path) or path
	ap = ap:gsub("\\", "/"):gsub("/+", "/")
	if #ap > 1 and not ap:match("^%a:/$") then
		ap = ap:gsub("/$", "")
	end
	if ap:match("^%a:$") then
		ap = ap .. "/"
	end
	if ap:match("^%a:/") then
		ap = ap:lower()
	end
	return ap
end

--- Return normalized parent directory for one path
-- @param path string|nil file or directory path
-- @return string|nil normalized parent directory
local function path_dirname(path)
	if type(path) == "string" and (path:match("^%a:/$") or path:match("^%a:$")) then
		return normalize_path(path)
	end
	local dir = path and path:match("^(.*)/[^/]*$")
	if not dir then
		return nil
	end
	if dir:match("^%a:$") then
		dir = dir .. "/"
	end
	return normalize_path(dir)
end

--- Return whether one path points to filesystem root
-- @param path string|nil path to test
-- @return boolean
local function is_root_directory(path)
	local p = normalize_path(path)
	if not p then
		return false
	end
	return p == "/" or p:match("^%a:/$") ~= nil
end

--- Resolve active document base directory from active or last active view
-- @return string|nil normalized directory path
local function get_active_doc_base_dir()
	--- Resolve one view to its doc directory when available
	-- @param v table|nil view candidate
	-- @return string|nil normalized directory
	local function from_view(v)
		local d = v and v.doc
		local fn = d and d.filename
		if fn then
			return path_dirname(normalize_path(fn))
		end
	end
	return from_view(core.active_view) or from_view(core.last_active_view)
end

--- Collect project file candidates from one root directory
-- Scans recursively unless root is a filesystem root directory
-- @param root string directory root
-- @return table list of absolute file paths
local function collect_files_from_root(root)
	local out = {}
	local recursive = not is_root_directory(root)

	--- Scan one directory and append visible file paths
	-- @param path string directory path
	-- @return nil
	local function scan(path)
		local all = system.list_dir(path) or {}
		local dirs, files = {}, {}
		for i = 1, #all do
			local name = all[i]
			if not common.match_pattern(name, config.ignore_files) then
				local full = path .. PATHSEP .. name
				local info = system.get_file_info(full)
				if info then
					-- Collect then sort dirs/files separately for deterministic scan order
					if info.type == "dir" then
						dirs[#dirs + 1] = full
					elseif info.type == "file" then
						files[#files + 1] = full
					end
				end
			end
		end
		table.sort(dirs)
		table.sort(files)
		for i = 1, #files do
			out[#out + 1] = files[i]
		end
		if recursive then
			-- Root directories can explode in size, so recursion is disabled there
			for i = 1, #dirs do
				scan(dirs[i])
			end
		end
	end
	scan(root)
	return out
end

--- Build file candidates for core:find-file command
-- Uses per-doc base directory cache and falls back to project files list
-- @return table list of file paths
local function get_find_file_candidates()
	local root = get_active_doc_base_dir()
	if not root then
		local files = {}
		for _, item in pairs(core.project_files) do
			if item.type == "file" then
				table.insert(files, item.filename)
			end
		end
		return files
	end

	local now = system.get_time()
	if find_file_cache.root ~= root or not find_file_cache.list or (now - find_file_cache.time) > 1.0 then
		-- Rebuild small time-window cache to avoid repeated full scans while typing
		find_file_cache.root = root
		find_file_cache.list = collect_files_from_root(root)
		find_file_cache.time = now
	end
	return find_file_cache.list
end

--- Open one document in a unique tab by focusing existing view when present
-- @param doc table|nil document object
-- @return nil
local function open_doc_unique_tab(doc)
	if not doc then
		return
	end
	local views = core.workspace_view.root_node:get_children()
	for i = 1, #views do
		local view = views[i]
		if view and view.doc == doc then
			local node = core.workspace_view.root_node:get_node_for_view(view)
			if node then
				node:set_active_view(view)
			else
				core.set_active_view(view)
			end
			return
		end
	end
	core.workspace_view:open_doc(doc)
end

--- Resolve style module path from dotted module name
-- @param name string|nil module name
-- @return string|nil resolved file path
local function style_module_to_path(name)
	if type(name) ~= "string" or name == "" then
		return nil
	end
	local rel = name:gsub("%.", "/") .. ".lua"
	local path = DATA_DIR .. rel
	if system.is_file(path) == true then
		return path
	end
	return nil
end

command.add({

	-- Applies pending CLI gridlock state to runtime editor behavior
	["core:set-gridlock"] = function()
		-- Enable gridlock mode when requested via CLI
		if core.pending_gridlock ~= nil then
			core.gridlock = core.pending_gridlock and true or false
			core.pending_gridlock = nil
		end
	end,

	-- Activates a tab in the current pane using pending startup index
	["core:activate-tab"] = function()
		-- Activate tab by index (1-based) in active pane
		local idx = core.pending_activate_tab
		if not idx then
			return
		end
		local node = core.workspace_view:get_active_node()
		local view = node and node.views and node.views[idx]
		if view then
			node:set_active_view(view)
		end
		core.pending_activate_tab = nil
	end,

	-- Activates a pane by pending startup index in leaf traversal order
	["core:activate-pane"] = function()
		-- Activate pane by index (1-based) in pane order
		local idx = core.pending_activate_pane
		if not idx then
			return
		end
		local panes = {}
		local function collect(node)
			if node.type == "leaf" then
				table.insert(panes, node)
			else
				collect(node.a)
				collect(node.b)
			end
		end
		collect(core.workspace_view.root_node)
		local node = panes[idx]
		if node then
			core.set_active_view(node.active_view)
		end
		core.pending_activate_pane = nil
	end,

	-- Activates a specific tab within a specific pane from pending indexes
	["core:activate-pane-tab"] = function()
		-- Activate tab by index within a specific pane
		local pane_idx = core.pending_activate_pane
		local tab_idx = core.pending_activate_tab
		if not (pane_idx and tab_idx) then
			return
		end
		local panes = {}
		local function collect(node)
			if node.type == "leaf" then
				table.insert(panes, node)
			else
				collect(node.a)
				collect(node.b)
			end
		end
		collect(core.workspace_view.root_node)
		local node = panes[pane_idx]
		if node and node.views and node.views[tab_idx] then
			node:set_active_view(node.views[tab_idx])
		end
		core.pending_activate_pane = nil
		core.pending_activate_tab = nil
	end,

	-- Activates a doc tab by pending index in the active pane
	["core:activate-doc"] = function()
		-- Activate doc by index (1-based) from core.pending_activate_index
		local idx = core.pending_activate_index
		if not idx then
			return
		end
		local node = core.workspace_view:get_active_node()
		local view = node and node.views and node.views[idx]
		if view then
			node:set_active_view(view)
		end
		core.pending_activate_index = nil
	end,

	-- Handles escape behavior for command exit, selection clear, close, or quit
	["core:escape"] = function()
		-- Escape closes input panel, clears selection, then closes doc or quits
		-- Prioritize closing autocomplete suggestions when present
		if command.perform("autocomplete:cancel") then
			return
		end
		if core.active_view:is(CommandView) then
			core.active_view:exit()
			return
		end

		local target = core.active_view
		if
			not is_document_view(target)
			and core.last_active_view
			and is_document_view(core.last_active_view)
		then
			target = core.last_active_view
		end

		if is_document_view(target) and core.view_can_select(target) and target.doc:has_selection() then
				-- Clear selection before closing the document
				local line, col = target.doc:get_selection()
				target.doc:set_selection(line, col)
				return
		end
		if is_document_view(target) then
			-- Close the document view, then quit if no docs remain
			local node = core.workspace_view.root_node:get_node_for_view(target)
			if node then
				node:set_active_view(target)
				node:close_active_view(core.workspace_view.root_node)
			end
			return
		end

		-- On non-doc views Escape is non-destructive
	end,

	-- Alternate escape behavior that force-quits once all docs are closed
	["core:escape2"] = function()
		-- Escape variant that quits after closing the last editor view
		-- Prioritize closing autocomplete suggestions when present
		if command.perform("autocomplete:cancel") then
			return
		end
		if core.active_view:is(CommandView) then
			core.active_view:exit()
			return
		end
		if core.active_view:is(LogView) then
			local node = core.workspace_view.root_node:get_node_for_view(core.active_view)
			if node then
				node:close_active_view(core.workspace_view.root_node)
			end
			return
		end

		local target = core.active_view
		if
			not is_document_view(target)
			and core.last_active_view
			and is_document_view(core.last_active_view)
		then
			target = core.last_active_view
		end

		if is_document_view(target) and core.view_can_select(target) and target.doc:has_selection() then
				local line, col = target.doc:get_selection()
				target.doc:set_selection(line, col)
				return
		end
		if is_document_view(target) then
			local node = core.workspace_view.root_node:get_node_for_view(target)
			if node then
				node:set_active_view(target)
				node:close_active_view(core.workspace_view.root_node, function()
					local has_doc = false
					for _, view in ipairs(core.workspace_view.root_node:get_children()) do
						if is_document_view(view) then
							has_doc = true
							break
						end
					end
					if not has_doc then
						core.quit(true)
					end
				end)
			end
			return
		end

		-- On home/other non-doc views, Escape2 force-quits
		core.quit(true)
	end,

	-- Quits the application with unsaved-change checks
	["core:quit"] = function()
		core.quit()
	end,

	-- Quits the application immediately without confirmation
	["core:force-quit"] = function()
		core.quit(true)
	end,

	-- Toggles between fullscreen and normal window modes
	["core:toggle-fullscreen"] = function()
		fullscreen = not fullscreen
		system.set_window_mode(fullscreen and "fullscreen" or "normal")
	end,

	-- Prompts for a loaded module name and reloads it
	["core:reload-module"] = function()
		core.command_view:enter({
			caption = "Reload Module",
			submit = function(text, item)
				local text = item and item.text or text
				core.reload_module(text)
				core.log("Reloaded module %q", text)
			end,
			suggest = function(text)
				local items = {}
				for name in pairs(package.loaded) do
					table.insert(items, name)
				end
				return common.fuzzy_match(items, text)
			end,
		})
	end,

	-- Restarts Lx to fully reinitialize runtime state and scripts
	["core:reinitialize"] = function()
		local dirty_count = 0
		local dirty_name = nil
		for _, doc in ipairs(core.docs) do
			if doc:is_dirty() then
				dirty_count = dirty_count + 1
				dirty_name = doc:get_name()
			end
		end
		if dirty_count > 0 then
			if dirty_count == 1 then
				core.error('Reinitialize blocked: "%s" has unsaved changes', dirty_name)
			else
				core.error("Reinitialize blocked: %d docs have unsaved changes", dirty_count)
			end
			return
		end

		local cmd = string.format("%q", EXE_FILE)
		local ok = core.try(system.exec, cmd)
		if not ok then
			core.error("Failed to restart Lx")
			return
		end
		core.quit(true)
	end,

	-- Opens the command palette and runs the selected command
	["core:find-command"] = function()
		-- Toggle command view off when it is already visible
		if core.active_view == core.command_view then
			core.command_view:exit(false, true)
			return
		end
		-- Build command list from the last non-command active view when reopening the palette
		local source_view = core.active_view
		if source_view == core.command_view and core.last_active_view then
			source_view = core.last_active_view
		end
		-- Evaluate command availability against the source view without mutating pane focus state
		local original_active_view = core.active_view
		core.active_view = source_view or original_active_view
		local ok, commands = pcall(command.get_all_valid)
		core.active_view = original_active_view
		if not ok or type(commands) ~= "table" then
			commands = {}
		end
		-- Classic "Do Command:"
		core.command_view:enter({
			caption = "Command",
			submit = function(_, item)
				if item then
					command.perform(item.command)
				end
			end,
			suggest = function(text)
				local res = common.fuzzy_match(commands, text)
				-- Guard fuzzy_match polymorphic return for strict type checkers
				local items = type(res) == "table" and res or {}
				for i, name in ipairs(items) do
					res[i] = {
						text = name, --MOD: Disable prettyify; command.prettify_name(name),
						info = keymap.get_binding(name),
						command = name,
					}
				end
				return items
			end,
		})
	end,

	["core:repeat-last-command"] = function()
		local last_command_name = command.get_last_performed_name and command.get_last_performed_name() or nil
		if type(last_command_name) ~= "string" or last_command_name == "" then
			return false
		end
		return command.perform(last_command_name)
	end,

	-- Captures the next keystroke and reports mapped command ids
	["core:inspect-next-key"] = function()
		keymap.listen_next_stroke(function(stroke, commands)
			local command_list = "(none)"
			if type(commands) == "table" and #commands > 0 then
				command_list = table.concat(commands, ", ")
			end
			core.log("Key %s -> %s", tostring(stroke), command_list)
		end)
		core.notice("Press a key to inspect its binding")
	end,

	-- Opens a project file selected via fuzzy search
	["core:find-file"] = function()
		project.enable_scan("find-file", true)
		core.command_view:enter({
			caption = "Open File From Project",
			submit = function(text, item)
				project.disable_scan("find-file")
				text = item and item.text or text
				local opened_doc = Doc.open_file(text)
				if opened_doc then
					core.workspace_view:open_doc(opened_doc)
				end
			end,
			suggest = function(text)
				local files = get_find_file_candidates()
				return common.fuzzy_match(files, text)
			end,
			cancel = function()
				project.disable_scan("find-file")
			end,
		})
	end,

	-- Creates and opens a new unsaved document
	["core:new-doc"] = function()
		-- Route new-doc creation through the shared doc lifecycle owner
		core.workspace_view:open_doc(Doc.create())
	end,

	-- Prompts for a file path and opens the selected file
	["core:open-file"] = function()
		core.command_view:enter({
			caption = "Open File",
			submit = function(text)
				local opened_doc = Doc.open_file(text)
				if opened_doc then
					core.workspace_view:open_doc(opened_doc)
				end
			end,
			suggest = common.path_suggest,
		})
	end,

	-- Opens data/user/settings.lua in a unique editor tab and focuses it when already open
	["core:edit-user-settings"] = function()
		local settings_path = USER_SETTINGS_FILE or (DATA_DIR .. "user/settings.lua")
		local opened_doc = Doc.open_file(settings_path)
		if opened_doc then
			open_doc_unique_tab(opened_doc)
		end
	end,

	-- Opens the currently active styles.* module file in a unique editor tab
	["core:edit-current-style"] = function()
		local module_name = style.get_active_style_module and style.get_active_style_module() or nil
		if not module_name then
			core.error('No active style module resolved')
			return
		end
		local path = style_module_to_path(module_name)
		if not path then
			core.error('Style module file not found for "%s"', tostring(module_name))
			return
		end
		local opened_doc = Doc.open_file(path)
		if opened_doc then
			open_doc_unique_tab(opened_doc)
		end
	end,

	-- Opens or creates the project module file
	["core:open-project-module"] = function()
		-- Prefer lx project module file; keep legacy lite filename support
		local filename = ".lxproj.lua"

		if system.is_file(filename) then
			local opened_doc = Doc.open_file(filename)
			if opened_doc then
				core.workspace_view:open_doc(opened_doc)
			end
		else
			local doc = Doc.create()
			-- Guard nil in case open handler returns no document
			if doc then
				core.workspace_view:open_doc(doc)
				doc:save(filename)
			end
		end
	end,
})
