-- 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.

local core = require("core")
local common = require("core.common")
local command = require("core.command")
local command_parser = require("core.command.parser")
local config = require("core.config")
local dev_details = require("core.dev.details")
local Doc = require("core.doc")
local fileops = require("core.file.ops")
local keymap = require("core.keymap")
local navigate = require("core.navigate")
local open_mode = require("core.open_mode")
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, scan_id = 0, scanning = false }

--- 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

--- Clear one selection stage for Escape
-- Secondary selections collapse before the primary selection
-- @param view table|nil document view
-- @return boolean cleared
local function clear_selection_step(view)
	if not (is_document_view(view) and core.view_can_select(view)) then
		return false
	end

	local doc = view.doc
	if doc.get_selection_count ~= nil and doc:get_selection_count() > 1 then
		local line1, col1, line2, col2 = doc:get_selection()
		doc:set_selection(line1, col1, line2, col2)

		return true
	end

	if not doc:has_selection() then
		return false
	end

	local line, col = doc:get_selection()
	doc:set_selection(line, col)

	return true
end

--- Let the focused surface handle Escape before core close and quit behavior
-- @return boolean handled
local function escape_active_view()
	local active_view = core.active_view

	return active_view
		and type(active_view.on_escape) == "function"
		and active_view:on_escape() == true
end

--- Normalize a filesystem path for stable comparisons
-- @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

	return system.normalize_path(system.absolute_path(path) or path)
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)
	local normalized = normalize_path(path)

	return normalized and system.path_dirname(normalized) or nil
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 system.path_dirname(p) == p
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

--- Refresh project file candidates incrementally on the core coroutine scheduler
-- @param root string directory root
local function refresh_find_file_candidates(root)
	find_file_cache.scan_id = find_file_cache.scan_id + 1
	local scan_id = find_file_cache.scan_id
	local candidates = {}
	find_file_cache.root = root
	find_file_cache.list = candidates
	find_file_cache.time = system.get_time()
	find_file_cache.scanning = true

	core.add_coroutine(function()
		local state = {
			file_count = 0,
			root_is_root = is_root_directory(root),
			on_file = function(entry)
				if find_file_cache.scan_id ~= scan_id then
					return
				end

				candidates[#candidates + 1] = entry.filename

				if #candidates % 32 == 0 then
					core.command_view.last_change_id = nil
					core.redraw = true
				end
			end,
		}

		fileops.get_files(root, {}, state)

		if find_file_cache.scan_id == scan_id then
			find_file_cache.scanning = false
			find_file_cache.time = system.get_time()
			core.command_view.last_change_id = nil
			core.redraw = true
		end
	end, find_file_cache)
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 (not find_file_cache.scanning and (now - find_file_cache.time) > 1.0)
	then
		refresh_find_file_candidates(root)
	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

--- Open one settings file and optionally start it when an override does not exist yet
-- @param path string|nil full settings path
-- @param path_error string|nil path lookup error
-- @param description string readable settings scope
-- @param create_missing boolean true for optional override files
local function open_settings_file(path, path_error, description, create_missing)
	if not path then
		core.error("Could not locate %s settings: %s", description, tostring(path_error))
		return
	end

	local opened_doc
	if system.is_file(path) then
		opened_doc = Doc.open_file(path)
	elseif create_missing then
		-- Keep the new override unsaved until the user chooses to write it
		opened_doc = Doc.create(path)
	else
		core.error('Could not open %s settings: file "%s" is missing', description, path)
		return
	end

	open_doc_unique_tab(opened_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.register({

	-- Returns to the previous recorded document position
	["navigate:back"] = navigate.back,

	-- Returns to the next document position after moving back
	["navigate:forward"] = navigate.forward,

	-- Toggles friendly reports shared by Lx, formats, and plugins
	["dev:toggle-details"] = function()
		local enabled = dev_details.toggle()
		if core.status_view then
			-- The live state belongs in the status bar, not in its own log entry
			core.status_view:show_message(
				"i",
				style.text,
				"Developer details: " .. (enabled and "on" or "off")
			)
		end
	end,

	-- 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 escape_active_view() 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 clear_selection_step(target) then
			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 escape_active_view() 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 clear_selection_step(target) then
			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,

	-- Restarts Lx while preserving the current command line
	["core:restart"] = function()
		core.restart()
	end,

	-- Restarts Lx immediately while preserving the current command line
	["core:force-restart"] = function()
		core.restart(nil, 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,

	-- Reloads the named module directly or prompts when no name was supplied
	["core:reload-module"] = function(_, args)
		local name = args and args.name

		if type(name) == "string" and name ~= "" then
			core.reload_module(name)
			core.log("Reloaded module %q", name)

			return
		end

		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,

	-- Reloads loaded Lua source changed during this Lx session
	["core:reload-changed"] = function()
		local reloaded, failures = core.reload_changed_modules()

		for _, failure in ipairs(failures) do
			core.warning("Could not reload module %q: %s", failure.name, failure.error)
		end

		if #failures > 0 then
			core.error("Reloaded %d changed modules; %d failed", reloaded, #failures)
			return false
		end

		core.log("Reloaded %d changed modules; %d failed", reloaded, #failures)

		return true
	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, exec_error = system.exec(cmd)
		if not ok then
			core.error("Failed to restart Lx: %s", exec_error or "unknown process error")
			return
		end
		core.quit(true)
	end,

	-- Opens the bundled documentation home page as a Markdown preview
	["core:docs"] = function()
		local docs_path = system.join_path(system.join_path(EXE_DIR, "docs"), "index.md")
		local docs_doc = Doc.open_file(docs_path, {
			open_mode = open_mode.preview,
		})

		if docs_doc then
			core.workspace_view:open_doc(docs_doc, {
				open_origin = "external",
				open_mode = open_mode.preview,
			})
		end
	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(text, item)
				local _, payload = command_parser.split_invocation(text)
				if item then
					command.perform(item.command .. (payload or ""))
				elseif text ~= "" then
					command.perform(text)
				end
			end,
			suggest = function(text)
				local command_text, payload = command_parser.split_invocation(text)
				local res = common.fuzzy_match(commands, command_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),
						complete_text = name .. (payload or ""),
						info = keymap.get_binding(name),
						command = name,
					}
				end
				return items
			end,
		})
	end,

	["core:repeat-last-command"] = function()
		local last_invocation = command.get_last_performed_invocation
			and command.get_last_performed_invocation()
			or nil
		last_invocation = last_invocation
			or (command.get_last_performed_name and command.get_last_performed_name())
		if type(last_invocation) ~= "string" or last_invocation == "" then
			return false
		end
		return command.perform(last_invocation)
	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 the most specific user-and-computer override
	["core:edit-user-settings"] = function()
		local settings = require("core.settings")
		local settings_path, path_error = settings.user_computer_path()
		open_settings_file(settings_path, path_error, "user", true)
	end,

	-- Opens the override shared by every user on the current computer
	["core:edit-computer-settings"] = function()
		local settings = require("core.settings")
		local settings_path, path_error = settings.computer_path()
		open_settings_file(settings_path, path_error, "computer", true)
	end,

	-- Opens the shared global settings file and starts a blank one when needed
	["core:edit-global-settings"] = function()
		local settings = require("core.settings")
		local settings_path, path_error = settings.path("global.lua")
		open_settings_file(settings_path, path_error, "global", true)
	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,
})
