-- lx-bundle:true

local core = require("core")
local command_parser = require("core.command.parser")
local command = {}

command.map = {}

-- Dynamic providers resolve command handlers at runtime from command name
-- Each entry uses { predicate=function, requires=table|nil, resolve=function(name), list=function|nil }
command.dynamic = {}
command.last_performed_name = nil
command.last_performed_invocation = nil
-- Keep one optional per-command context available to nested command calls
command.current_context = nil

local always_true = function()
	return true
end

-- Resolve a core view class module once and cache the result
-- Returns nil when the module cannot be loaded
local function get_cached_view_class(cache_key, module_name)
	command._view_class_cache = command._view_class_cache or {}
	if command._view_class_cache[cache_key] ~= nil then
		return command._view_class_cache[cache_key]
	end
	local ok, class_or_error = pcall(require, module_name)
	command._view_class_cache[cache_key] = ok and class_or_error or false
	return command._view_class_cache[cache_key] or nil
end

-- Resolve a command predicate input into a callable predicate
local function resolve_predicate(predicate)
	predicate = predicate or always_true
	if type(predicate) == "string" then
		predicate = require(predicate)
	end
	if type(predicate) == "table" then
		local class = predicate
		predicate = function()
			return core.active_view:is(class)
		end
	end
	return predicate
end

-- Evaluate one requires token against the current active view and capability APIs
local function check_requires_token(token)
	if type(token) ~= "string" or token == "" then
		return false
	end
	local active_view = core.active_view
	if token == "doc" then
		return active_view and active_view.doc ~= nil or false
	end
	if token == "select" then
		return active_view and active_view.doc and core.view_can_select(active_view) or false
	end
	if token == "edit" then
		return active_view and active_view.doc and core.view_can_edit(active_view) or false
	end
	if token == "cursor" then
		return active_view and active_view.doc and core.view_has_cursor(active_view) or false
	end
	if token == "scroll" then
		return active_view
			and type(active_view.scroll_to_line) == "function"
			and type(active_view.scroll_to_make_visible) == "function"
			or false
	end
	-- Prefer explicit built-in view token names for consistency with core module names
	if token == "command_view" then
		local command_view_class = get_cached_view_class("command_view", "core.command.view")
		return active_view and command_view_class and active_view:is(command_view_class) or false
	end
	if token == "log_view" then
		local log_view_class = get_cached_view_class("log_view", "core.log.view")
		return active_view and log_view_class and active_view:is(log_view_class) or false
	end
	if token == "binary_viewer" then
		local binary_viewer_class = get_cached_view_class("binary_viewer", "core.binary.viewer")
		return active_view and binary_viewer_class and active_view:is(binary_viewer_class) or false
	end
	if token == "image_viewer" then
		local image_viewer_class = get_cached_view_class("image_viewer", "core.image.viewer")
		return active_view and image_viewer_class and active_view:is(image_viewer_class) or false
	end
	if token == "text_editor" then
		local text_doc_class = get_cached_view_class("text_editor", "core.text.editor")
		return active_view and text_doc_class and active_view:is(text_doc_class) or false
	end
	-- Plugin capability token format is '@capability_name'
	-- Commands can depend on plugin-provided capabilities without hard-coding plugin names
	-- Behavior:
	-- 1) Strip '@' to get the capability key, for example '@hex_view' -> 'hex_view'
	-- 2) Fail closed when key is empty or capability registry is unavailable
	-- 3) Resolve capability API through core.capabilities.get(name)

	-- 4) If capability API exposes matches(active_view), require that to return true
	-- 5) If no matches() exists, capability presence alone satisfies the token
	if token:sub(1, 1) == "@" then
		local capability_name = token:sub(2)
		if capability_name == "" or not (core.capabilities and core.capabilities.get) then
			return false
		end
		local capability = core.capabilities.get(capability_name)
		if not capability then
			return false
		end
		if type(capability.matches) == "function" then
			return capability.matches(active_view) == true
		end
		return true
	end
	return false
end

-- Evaluate a requires list, all tokens must pass for command availability
local function check_requires(requires)
	if requires == nil then
		return true
	end
	if type(requires) ~= "table" then
		return false
	end
	for index = 1, #requires do
		if not check_requires_token(requires[index]) then
			return false
		end
	end
	return true
end

--- Normalize one command spec into executable fields
-- Keeps shared command-spec handling in one place for both registered and contributed commands
-- @param command_name string command identifier
-- @param entry table|function raw command spec or perform function
-- @param default_predicate function|table|string|nil fallback predicate input
-- @return table spec normalized command spec
function command.normalize_spec(command_name, entry, default_predicate)
	local perform = entry
	local requires = nil
	local predicate = default_predicate or always_true
	local visibility = true

	if type(entry) == "table" then
		perform = entry.perform

		requires = entry.requires
		visibility = entry.visibility ~= false -- Only explicit false hides a command from the palette
		if entry.predicate ~= nil then
			predicate = resolve_predicate(entry.predicate)
		end
	end

	assert(type(perform) == "function", "command handler must be a function: " .. tostring(command_name))
	predicate = resolve_predicate(predicate)
	return {
		perform = perform,
		requires = requires,
		predicate = predicate,
		visibility = visibility,
	}
end

--- Return true when one normalized command spec is currently executable
-- Checks both predicate and requires so other modules can reuse the same availability rules
-- @param spec table normalized command spec
-- @return boolean enabled true when command is currently available
function command.is_spec_enabled(spec)
	if type(spec) ~= "table" then
		return false
	end

	if type(spec.predicate) ~= "function" then
		return false
	end

	return spec.predicate() and check_requires(spec.requires)
end

--- Register or replace commands by name
-- Later registrations deliberately replace earlier ones
-- @param arg1 table|string|function command map, command name, or legacy predicate
-- @param arg2 table|nil command spec or legacy command map
-- @return nil
function command.register(arg1, arg2)
	-- Register a complete command map
	if arg2 == nil then
		local map = arg1
		assert(type(map) == "table", "command.register(map): map table expected")
		for command_name, command_entry in pairs(map) do
			assert(type(command_name) == "string", "command.register(map): command names must be strings")
			command.map[command_name] = command.normalize_spec(command_name, command_entry, always_true)
		end
		return
	end

	-- Register one named command spec
	if type(arg1) == "string" and type(arg2) == "table" and arg2.perform ~= nil then
		local command_name = arg1
		command.map[command_name] = command.normalize_spec(command_name, arg2, always_true)
		return
	end

	-- Reject an ambiguous map shape before checking the legacy form
	if type(arg1) == "table" and arg1.requires ~= nil then
		error("command.register({ requires = ... }, map) is not supported. Use a command map or a named spec.")
	end

	-- Keep the existing predicate form while callers move to command specs
	local predicate = resolve_predicate(arg1)
	local map = arg2
	assert(type(map) == "table", "command.register(predicate, map): map table expected")
	for command_name, command_entry in pairs(map) do
		assert(type(command_name) == "string", "command.register(predicate, map): command names must be strings")
		command.map[command_name] = command.normalize_spec(command_name, command_entry, predicate)
	end
end

-- Capitalize the first character of a string
local function capitalize_first(str)
	return str:sub(1, 1):upper() .. str:sub(2)
end

-- Convert internal command ids into display-friendly names
function command.prettify_name(name)
	return name:gsub(":", ": "):gsub("-", " "):gsub("%S+", capitalize_first)
end

--- Run one palette callback without letting it abort collection
-- @param label string command or provider label used in error reports
-- @param callback function protected palette callback
-- @return boolean ok true when the callback completed
-- @return any result callback result or nil after failure
local function call_palette_callback(label, callback)
	local traceback = (_G.debug and _G.debug.traceback) or tostring
	local ok, result = xpcall(callback, traceback)

	if ok then
		return true, result
	end

	local traceback_text = tostring(result)
	local first_line = traceback_text:match("^[^\r\n]+") or traceback_text
	local summary = first_line:match("^.-:%d+:%s*(.*)$") or first_line
	core.error_with_trace(
		traceback_text,
		'Command palette callback failed for "%s": %s',
		label,
		summary
	)

	return false, nil
end

--- Check one palette command without letting its predicate abort collection
-- @param name string command identifier used in error reports
-- @param spec table normalized command spec
-- @return boolean enabled true when the command is currently available
local function is_palette_command_enabled(name, spec)
	local ok, result = call_palette_callback(name, function()
		return command.is_spec_enabled(spec)
	end)

	return ok and result == true
end

-- Return all command names currently valid for execution
function command.get_all_valid()
	local res = {}
	local seen = {}
	for name, cmd in pairs(command.map) do
		if cmd.visibility and is_palette_command_enabled(name, cmd) then
			table.insert(res, name)
			seen[name] = true
		end
	end
	-- Include dynamic command names for command palette discovery
	-- Providers may return different lists based on current active context
	for index, dyn in ipairs(command.dynamic) do
		local provider_label = string.format("dynamic provider %d", index)
		if is_palette_command_enabled(provider_label, dyn) then
			local list_ok, list = call_palette_callback(provider_label .. " list", function()
				return dyn.list and dyn.list() or nil
			end)
			if list_ok and type(list) == "table" then
				for i = 1, #list do
					local name = list[i]
					if type(name) == "string" and not seen[name] then
						table.insert(res, name)
						seen[name] = true
					end
				end
			end
		end
	end
	return res
end

-- Execute a command by name when it exists and passes its predicate
local function should_track_last_performed(name)
	if type(name) ~= "string" or name == "" then
		return false
	end
	if name == "core:find-command" then
		return false
	end
	if name == "core:repeat-last-command" or name == "core:repeat-command" then
		return false
	end
	if name:sub(1, 8) == "command:" then
		return false
	end
	return true
end

local function perform(invocation, context)
	if type(invocation) ~= "string" then
		return false
	end

	local name = invocation
	local args = nil
	local cmd = command.map[name]

	-- Keep registered plain commands on the direct lookup path
	if not cmd and invocation:find("(", 1, true) then
		name, args = command_parser.parse_invocation(invocation)
		cmd = command.map[name]
	end

	if cmd and command.is_spec_enabled(cmd) then
		local handled = cmd.perform(context, args)
		if handled ~= false and should_track_last_performed(name) then
			command.last_performed_name = name
			command.last_performed_invocation = invocation
		end
		return handled ~= false
	end
	-- Fall back to dynamic providers if no static command matched
	-- First provider that resolves a function handles the command
	for _, dyn in ipairs(command.dynamic) do
		if command.is_spec_enabled(dyn) then
			local fn = dyn.resolve and dyn.resolve(name) or nil
			if type(fn) == "function" then
				local handled = fn(context, args)
				if handled ~= false and should_track_last_performed(name) then
					command.last_performed_name = name
					command.last_performed_invocation = invocation
				end
				return handled ~= false
			end
		end
	end

	-- Make broken shortcuts visible instead of silently treating their command name as unhandled
	if not cmd then
		local stroke = type(context) == "table" and context.stroke or nil

		if stroke then
			core.warning("Unknown command %q for shortcut %q", invocation, stroke)
		else
			core.warning("Unknown command %q", invocation)
		end
	end

	return false
end

function command.get_last_performed_name()
	return command.last_performed_name
end

--- Return the full invocation of the last repeatable command
-- @return string|nil invocation command name and its original args
function command.get_last_performed_invocation()
	return command.last_performed_invocation
end

--- Return the current command execution context
-- Nested command calls inherit this until an explicit override replaces it
-- @return table|any|nil context current command payload
function command.get_context()
	return command.current_context
end

--- Register a dynamic command provider that resolves handlers at execution time
-- @param predicate function|table|nil provider availability rule
-- @param provider table dynamic resolver and optional command list
-- @return nil
function command.register_dynamic(predicate, provider)
	assert(type(provider) == "table", "dynamic provider must be a table")
	assert(type(provider.resolve) == "function", "dynamic provider requires resolve(name)")
	assert(provider.requires == nil or type(provider.requires) == "table", "dynamic provider requires list must be a table")
	local base_predicate = resolve_predicate(predicate)
	table.insert(command.dynamic, {
		predicate = function()
			return base_predicate()
		end,
		requires = provider.requires,
		resolve = provider.resolve,
		list = provider.list,
	})
end

--- Execute a command safely and report whether it was handled
-- The optional context is forwarded to new handlers and ignored by legacy ones
-- Invocation args are decoded and forwarded as the handler's second parameter
-- Nested calls inherit the current context unless one override is provided
-- @param name string command identifier with optional args object
-- @param context table|any|nil optional command payload
-- @return boolean handled true when the command ran without returning false
function command.perform(name, context)
	local resolved_context = context

	if resolved_context == nil then
		resolved_context = command.current_context
	end

	local previous_context = command.current_context
	command.current_context = resolved_context
	command._trace_depth = (command._trace_depth or 0) + 1

	local ok, res = core.try(perform, name, resolved_context)

	command._trace_depth = math.max(0, (command._trace_depth or 1) - 1)
	command.current_context = previous_context

	return not ok or res
end

-- Load built-in command modules into the command registry
function command.add_defaults()
	-- Load built-in command modules including isolated debug command set
	local modules = {
		"core.commands",
		"core.browser.commands",
		"core.log.commands",
		"core.debug.commands",
		"core.workspace.commands",
		"core.command.commands",
		"core.doc.commands",
		"core.text.commands",
		"core.text.occurrence",
		"core.text.findreplace",
		"core.binary.commands",
		"core.image.commands",
		"core.preview.commands",
	}
	-- Deleting the optional updater removes its commands without affecting startup
	if package.searchpath("core.update", package.path) then
		modules[#modules + 1] = "core.update.commands"
	end

	for _, module_name in ipairs(modules) do
		require(module_name)
	end
end

return command
