-- lx-bundle:true

--- Key binding registration and context-aware dispatch
-- Keeps layer precedence and active-view contexts in one place
-- @module core.keymap

local command = require("core.command")
local Shortcut = require("qui.shortcut")
local keymap = {}

keymap.modkeys = {}
keymap.next_stroke_listener = nil
-- Resolve temporary modes first, then let users replace plugin and built-in bindings
keymap.layer_order = { "modal", "user", "plugin", "core" }
keymap.context_maps = {}
keymap.context_layer_maps = {}
keymap.context_layer_reassignments = {}

-- Cache complete active-context maps so drawing shortcut hints does not rebuild them every frame
local resolved_context_cache = {}
local resolved_context_layer_reassignments = {}

local modkey_map = {
	["left ctrl"] = "ctrl",
	["right ctrl"] = "ctrl",
	["left shift"] = "shift",
	["right shift"] = "shift",
	["left alt"] = "alt",
	["right alt"] = "altgr",
}

if MACOS then
	modkey_map["left gui"] = "cmd"
	modkey_map["right gui"] = "cmd"
end

local modkeys = { "ctrl", "cmd", "alt", "altgr", "shift" }
-- Recognize canonical modifier tokens passed from the SDL event bridge
local modifier_state_tokens = {
	["ctrl"] = true,
	["cmd"] = true,
	["alt"] = true,
	["altgr"] = true,
	["shift"] = true,
}
local mouse_button_strokes = {
	left = "lclick",
	middle = "mclick",
	right = "rclick",
	x1 = "xclick1",
	x2 = "xclick2",
}
local normalize_stroke = Shortcut.normalize

--- Return whether one plain array contains an exact value
-- @param values table array to search
-- @param wanted any value to find
-- @return boolean true when found
local function list_contains(values, wanted)
	for index = 1, #values do
		if values[index] == wanted then
			return true
		end
	end

	return false
end

--- Copy one command list into a stable plain array
-- @param commands table source commands
-- @return table copied commands
local function copy_command_list(commands)
	local out = {}

	for index = 1, #commands do
		out[index] = commands[index]
	end

	return out
end

--- Normalize one context name and fall back to 'global'
-- Empty or invalid context names collapse to the global key binding scope
-- @param context string|nil raw context name
-- @return string normalized context name
local function normalize_context_name(context)
	if type(context) ~= "string" or context == "" then
		return "global"
	end

	return context
end

--- Return or initialize the per-layer operation tables for one context
-- @param context string|nil requested context
-- @return table context_maps layer -> stroke -> operations
local function ensure_context_layer_maps(context)
	local normalized_context = normalize_context_name(context)
	local context_layer_maps = keymap.context_layer_maps[normalized_context]

	if context_layer_maps then
		return context_layer_maps
	end

	context_layer_maps = {}
	local context_layer_reassignments = {}

	for _, layer in ipairs(keymap.layer_order) do
		context_layer_maps[layer] = {}
		context_layer_reassignments[layer] = {}
	end

	keymap.context_layer_maps[normalized_context] = context_layer_maps
	keymap.context_layer_reassignments[normalized_context] = context_layer_reassignments

	return context_layer_maps
end

ensure_context_layer_maps("global")

--- Emit keymap dispatch diagnostics when enabled in config
-- @param fmt string format string
-- @param ... any format args
-- @return nil
local function debug_keymap_log(fmt, ...)
	local line = string.format(fmt, ...)
	local core = package.loaded["core"]
	core.log_quiet("[keymap-debug] %s", line)
end

--- Resolve whether one stroke should be traced for debug logging
-- @param stroke string normalized keystroke
-- @return boolean should log diagnostics for this stroke
local function should_trace_stroke(stroke)
	local cfg = package.loaded["core.config"]
	if not cfg then
		return false
	end
	-- Backward compatibility for the previous escape-only debug switch
	if cfg.debug_escape_keymap and stroke == "escape" then
		return true
	end
	if not cfg.debug_keymap then
		return false
	end
	local filter = cfg.debug_keymap_strokes
	if type(filter) ~= "table" or #filter == 0 then
		return true
	end
	for i = 1, #filter do
		if normalize_stroke(filter[i]) == stroke then
			return true
		end
	end
	return false
end

--- Return key binding contexts from one view or the focused view
-- Views may provide a most-specific-first context chain through 'get_keymap_contexts'
-- Only real input surfaces should add view contexts here
-- Transient popup state should stay on modal bindings instead of pretending to be its own view context
-- Missing or invalid view context hooks fall back to the 'global' context only
-- @param context_view table|nil explicit input target
-- @return table contexts ordered target context chain
local function get_active_keymap_contexts(context_view)
	local contexts = {}
	local seen = {}
	local core = package.loaded["core"]
	local active_view = context_view or (core and core.active_view or nil)

	--- Append one context name once while preserving input order
	-- @param context string|nil requested context name
	local function add_context(context)
		local normalized_context = normalize_context_name(context)

		if seen[normalized_context] then
			return
		end

		seen[normalized_context] = true
		contexts[#contexts + 1] = normalized_context
	end

	if active_view and type(active_view.get_keymap_contexts) == "function" then
		local ok, active_contexts = pcall(active_view.get_keymap_contexts, active_view)

		if ok and type(active_contexts) == "table" then
			for index = 1, #active_contexts do
				add_context(active_contexts[index])
			end
		end
	end

	add_context("global")

	return contexts
end

--- Build one stroke token from active modifier state and key token
-- @param k string key token received from input event
-- @return string composed stroke token
local function key_to_stroke(key)
	local stroke = ""
	for _, modifier_key in ipairs(modkeys) do
		if keymap.modkeys[modifier_key] then
			stroke = stroke .. modifier_key .. "+"
		end
	end
	return stroke .. key
end

--- Build one flat command context table for input-driven dispatch
-- Keeps command payload plumbing generic without changing legacy handlers
-- @param stroke string normalized stroke token
-- @param context table source payload
-- @return table payload enriched with stroke
local function build_stroke_command_context(stroke, context)
	local payload = {}

	for key, value in pairs(context) do
		payload[key] = value
	end

	payload.stroke = stroke

	return payload
end

--- Sync tracked modifier keys from one canonical modifier state string
-- Accepts strings like 'ctrl+shift' or ''
-- @param modifier_state string|nil canonical modifier state
-- @return nil
function keymap.sync_modkeys(modifier_state)
	if modifier_state == nil then
		return
	end

	local next_state = {}

	if type(modifier_state) == "string" and modifier_state ~= "" then
		for token in modifier_state:gmatch("[^+]+") do
			if modifier_state_tokens[token] then
				next_state[token] = true
			end
		end
	end

	for _, mk in ipairs(modkeys) do
		keymap.modkeys[mk] = next_state[mk] or false
	end

	-- Keep AltGr behavior aligned with the explicit keydown path
	if keymap.modkeys["altgr"] then
		keymap.modkeys["ctrl"] = false
	end
end

--- Apply stored binding operations to one effective command list
-- Later calls represent stronger layers or more specific contexts
-- @param effective_commands table mutable command list
-- @param operations table ordered binding operations
-- @return nil
local function apply_operations(effective_commands, operations)
	for _, operation in ipairs(operations) do
		if operation.kind == "bind" or operation.kind == "unbind" then
			-- Binding and unbinding both replace everything inherited for this key
			for index = #effective_commands, 1, -1 do
				effective_commands[index] = nil
			end
		end

		if operation.kind == "bind" or operation.kind == "extend" then
			for command_index = #operation.commands, 1, -1 do
				local cmd = operation.commands[command_index]
				-- Extending the same command twice should not make dispatch try it twice
				if operation.kind == "bind" or not list_contains(effective_commands, cmd) then
					-- Stronger declarations add their commands ahead of inherited fallbacks
					table.insert(effective_commands, 1, cmd)
				end
			end
		elseif operation.kind == "unextend" then
			local removed_commands = {}

			for _, cmd in ipairs(operation.commands) do
				removed_commands[cmd] = true
			end

			for index = #effective_commands, 1, -1 do
				if removed_commands[effective_commands[index]] then
					table.remove(effective_commands, index)
				end
			end
		end
	end
end

--- Replace one shortcut with a copied command list
-- Copying keeps the two sides of a swap independent after resolution
-- @param context_map table stroke -> effective commands
-- @param stroke string normalized shortcut
-- @param commands table source commands
-- @return nil
local function replace_commands(context_map, stroke, commands)
	context_map[stroke] = copy_command_list(commands)
end

--- Resolve every shortcut swap, move, and copy for one layer at the same time
-- Every declaration reads the weaker-layer state before any reassignment changes it
-- @param context string exact keymap context
-- @param layer string target layer
-- @param context_map table effective map built through weaker layers
-- @return table reassigned_commands stroke -> copied commands
local function resolve_layer_reassignments(context, layer, context_map)
	local context_reassignments = keymap.context_layer_reassignments[context] or {}
	local reassignments = context_reassignments[layer] or {}
	local reassigned_commands = {}

	for _, pair in ipairs(reassignments) do
		local source_commands = context_map[pair.source]
		local destination_commands = context_map[pair.destination]
		local connector = pair.kind == "swap" and "and" or "to"
		local missing_stroke = (not source_commands or #source_commands == 0) and pair.source
			or (pair.kind == "swap" and (not destination_commands or #destination_commands == 0) and pair.destination)

		assert(
			not missing_stroke,
			string.format(
				"keymap.%s cannot %s '%s' %s '%s' in context '%s' at layer '%s': '%s' has no inherited binding",
				pair.kind,
				pair.kind,
				pair.source,
				connector,
				pair.destination,
				context,
				layer,
				missing_stroke
			)
		)

		if pair.kind == "swap" then
			reassigned_commands[pair.source] = copy_command_list(destination_commands)
		elseif pair.kind == "move" then
			reassigned_commands[pair.source] = {}
		end
		reassigned_commands[pair.destination] = copy_command_list(source_commands)
	end

	return reassigned_commands
end

--- Apply already-resolved shortcut changes as complete bindings
-- @param context_map table stroke -> effective commands
-- @param resolved_commands table stroke -> replacement commands
-- @return nil
local function apply_resolved_commands(context_map, resolved_commands)
	for stroke, commands in pairs(resolved_commands) do
		replace_commands(context_map, stroke, commands)
	end
end

--- Build a reverse lookup from one complete stroke map
-- @param context_map table stroke -> commands
-- @return table reverse_map command -> first stroke
local function build_reverse_map(context_map)
	local reverse_map = {}

	for stroke, commands in pairs(context_map) do
		for _, cmd in ipairs(commands) do
			reverse_map[cmd] = reverse_map[cmd] or stroke
		end
	end

	return reverse_map
end

--- Rebuild resolved maps used for exports and context-aware lookup
-- Active-view maps are invalidated because their source declarations changed
-- @return nil
local function rebuild_context_maps()
	keymap.context_maps = {}
	resolved_context_cache = {}
	resolved_context_layer_reassignments = {}

	for context, context_layer_maps in pairs(keymap.context_layer_maps) do
		local context_map = {}
		local context_resolved_reassignments = {}

		-- Apply weak layers first so stronger layers can deliberately replace or adjust their result
		for layer_index = #keymap.layer_order, 1, -1 do
			local layer = keymap.layer_order[layer_index]
			local layer_map = context_layer_maps[layer] or {}
			local reassigned_commands = resolve_layer_reassignments(context, layer, context_map)

			for stroke, operations in pairs(layer_map) do
				local effective_commands = context_map[stroke] or {}
				context_map[stroke] = effective_commands
				apply_operations(effective_commands, operations)
			end

			apply_resolved_commands(context_map, reassigned_commands)
			context_resolved_reassignments[layer] = reassigned_commands
		end

		keymap.context_maps[context] = context_map
		resolved_context_layer_reassignments[context] = context_resolved_reassignments
	end
end

--- Resolve and cache one complete map for an active context chain
-- Layers decide authority first; context specificity decides ties inside one layer
-- @param contexts table most-specific-first active context names
-- @return table resolved map and reverse map
local function resolve_context_chain(contexts)
	local cache_key = table.concat(contexts, "\31")
	local cached = resolved_context_cache[cache_key]

	if cached then
		return cached
	end

	local context_map = {}

	-- Walk weak to strong so each later declaration can replace or adjust inherited commands
	for layer_index = #keymap.layer_order, 1, -1 do
		local layer = keymap.layer_order[layer_index]

		-- Broader contexts apply first, then the most specific context wins within this layer
		for context_index = #contexts, 1, -1 do
			local context = contexts[context_index]
			local context_layer_maps = keymap.context_layer_maps[context] or {}
			local layer_map = context_layer_maps[layer] or {}

			for stroke, operations in pairs(layer_map) do
				local effective_commands = context_map[stroke] or {}
				context_map[stroke] = effective_commands
				apply_operations(effective_commands, operations)
			end

			local context_reassignments = resolved_context_layer_reassignments[context] or {}
			apply_resolved_commands(context_map, context_reassignments[layer] or {})
		end
	end

	cached = {
		map = context_map,
		reverse_map = build_reverse_map(context_map),
	}
	resolved_context_cache[cache_key] = cached

	return cached
end

--- Resolve the cached binding maps for one surface or the focused surface
-- @param context_view table|nil explicit input target
-- @return table resolved map and reverse map
-- @return table contexts most-specific-first active contexts
local function resolve_active_context_chain(context_view)
	local contexts = get_active_keymap_contexts(context_view)

	return resolve_context_chain(contexts), contexts
end

--- Execute one normalized stroke against mapped command candidates
-- @param stroke string stroke to dispatch
-- @param context table|nil optional source payload for matched commands
-- @param context_view table|nil explicit input target
-- @return boolean true when one command handled the stroke
local function perform_stroke(stroke, context, context_view)
	stroke = normalize_stroke(stroke)
	context = build_stroke_command_context(stroke, context)
	local resolved, contexts = resolve_active_context_chain(context_view)
	local commands = resolved.map[stroke]
	local trace = should_trace_stroke(stroke)

	if commands then
		if trace then
			debug_keymap_log(
				"stroke=%s contexts=%s candidates=%s",
				stroke,
				table.concat(contexts, ","),
				table.concat(commands, ", ")
			)
		end

		local handled = false

		for _, cmd in ipairs(commands) do
			local performed = command.perform(cmd, context)

			if trace then
				debug_keymap_log("try %s => %s", cmd, tostring(performed == true))
			end

			if performed then
				handled = true
				break
			end
		end

		if trace then
			debug_keymap_log("result handled=%s", tostring(handled))
		end

		return handled
	end

	if trace then
		debug_keymap_log("stroke=%s candidates=(none)", stroke)
	end

	return false
end

--- Return sorted context names for keymap exports
-- Keeps broader 'global' output at the end for easier scanning
-- @return table names ordered context names
local function get_export_context_names()
	local names = {}

	for context in pairs(keymap.context_layer_maps) do
		names[#names + 1] = context
	end

	table.sort(names, function(left, right)
		if left == "global" then
			return false
		end

		if right == "global" then
			return true
		end

		return left < right
	end)

	return names
end

--- Build one export row for one stroke and its commands
-- @param context string export context name
-- @param layer string effective layer or concrete layer name
-- @param stroke string normalized stroke
-- @param operation string resolved, bind, unbind, extend, or unextend
-- @param commands table source commands
-- @param paired_stroke string|nil other shortcut for swap or move declarations
-- @return table row export row
local function build_export_row(context, layer, stroke, operation, commands, paired_stroke)
	return {
		context = context,
		layer = layer,
		stroke = stroke,
		operation = operation,
		commands = copy_command_list(commands),
		paired_stroke = paired_stroke,
	}
end

--- Build one complete keymap export snapshot
-- Precomputes context and layer arrays so output formatters stay simple
-- @return table export_data structured export snapshot
function keymap.build_export_data()
	local export_data = {
		generated_at = os.date("%Y-%m-%d %H:%M:%S"),
		contexts = {},
	}
	local context_names = get_export_context_names()

	for context_index = 1, #context_names do
		local context = context_names[context_index]
		local effective_map = keymap.context_maps[context] or {}
		local effective_strokes = {}
		local layer_sections = {}

		for stroke in pairs(effective_map) do
			effective_strokes[#effective_strokes + 1] = stroke
		end

		table.sort(effective_strokes)

		local effective_rows = {}

		for stroke_index = 1, #effective_strokes do
			local stroke = effective_strokes[stroke_index]
			effective_rows[#effective_rows + 1] = build_export_row(
				context,
				"effective",
				stroke,
				"resolved",
				effective_map[stroke]
			)
		end

		for layer_index = 1, #keymap.layer_order do
			local layer = keymap.layer_order[layer_index]
			local context_layer_maps = keymap.context_layer_maps[context] or {}
			local layer_map = context_layer_maps[layer] or {}
			local context_layer_reassignments = keymap.context_layer_reassignments[context] or {}
			local reassignments = context_layer_reassignments[layer] or {}
			local layer_strokes = {}

			for stroke in pairs(layer_map) do
				layer_strokes[#layer_strokes + 1] = stroke
			end

			table.sort(layer_strokes)

			local layer_rows = {}

			for stroke_index = 1, #layer_strokes do
				local stroke = layer_strokes[stroke_index]
				local operations = layer_map[stroke]

				for operation_index = 1, #operations do
					local operation = operations[operation_index]
					layer_rows[#layer_rows + 1] = build_export_row(
						context,
						layer,
						stroke,
						operation.kind,
						operation.commands
					)
				end
			end

			for _, pair in ipairs(reassignments) do
				layer_rows[#layer_rows + 1] = build_export_row(
					context,
					layer,
					pair.source,
					pair.kind,
					{},
					pair.destination
				)
			end

			table.sort(layer_rows, function(left, right)
				if left.stroke == right.stroke then
					return left.operation < right.operation
				end

				return left.stroke < right.stroke
			end)

			layer_sections[#layer_sections + 1] = {
				context = context,
				layer = layer,
				rows = layer_rows,
			}
		end

		export_data.contexts[#export_data.contexts + 1] = {
			name = context,
			effective_rows = effective_rows,
			layer_sections = layer_sections,
		}
	end

	return export_data
end

--- Normalize one command value into a checked command list
-- @param operation_name string public operation name for clear errors
-- @param commands string|table command id or ordered command ids
-- @return table normalized_commands copied command ids
local function normalize_commands(operation_name, commands)
	if type(commands) == "string" then
		commands = { commands }
	end

	assert(type(commands) == "table", "keymap." .. operation_name .. " requires command names for every shortcut")
	assert(#commands > 0, "keymap." .. operation_name .. " requires at least one command for every shortcut")
	local normalized_commands = {}

	for index = 1, #commands do
		assert(type(commands[index]) == "string", "keymap." .. operation_name .. " received a non-string command")
		normalized_commands[index] = commands[index]
	end

	return normalized_commands
end

--- Resolve optional layer argument shared by all public registration operations
-- @param operation_name string public operation name for clear errors
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return string layer selected layer
-- @return table contexts context -> shortcut declarations
local function resolve_registration(operation_name, layer_or_contexts, optional_contexts)
	local layer = "user"
	local contexts = layer_or_contexts

	if type(layer_or_contexts) == "string" then
		layer = layer_or_contexts
		contexts = optional_contexts
	else
		assert(optional_contexts == nil, "keymap." .. operation_name .. " received an unexpected second argument")
	end

	assert(list_contains(keymap.layer_order, layer), "keymap." .. operation_name .. " received unknown layer '" .. tostring(layer) .. "'")
	assert(type(contexts) == "table", "keymap." .. operation_name .. " requires a context table")

	return layer, contexts
end

--- Return the swap or move that already owns one shortcut in the same place
-- @param context string exact keymap context
-- @param layer string target layer
-- @param stroke string normalized shortcut
-- @return table|nil pair
local function find_stroke_reassignment(context, layer, stroke)
	local context_reassignments = keymap.context_layer_reassignments[context] or {}

	for _, pair in ipairs(context_reassignments[layer] or {}) do
		if pair.source == stroke or pair.destination == stroke then
			return pair
		end
	end
end

--- Store one kind of keymap operation across one or more contexts
-- Binding and unbinding replace earlier operations in the same layer; extending operations accumulate
-- @param operation_name string bind, unbind, extend, or unextend
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
local function register_operation(operation_name, layer_or_contexts, optional_contexts)
	local layer, contexts = resolve_registration(operation_name, layer_or_contexts, optional_contexts)

	for context, bindings in pairs(contexts) do
		assert(type(context) == "string" and context ~= "", "keymap." .. operation_name .. " received an invalid context")
		assert(type(bindings) == "table", "keymap." .. operation_name .. " requires bindings below context '" .. context .. "'")
		local layer_map = ensure_context_layer_maps(context)[layer]

		if operation_name == "unbind" then
			for index = 1, #bindings do
				local normalized_stroke = normalize_stroke(bindings[index])
				local reassignment = find_stroke_reassignment(context, layer, normalized_stroke)
				assert(type(normalized_stroke) == "string", "keymap.unbind requires shortcut names")
				assert(
					not reassignment,
					string.format(
						"keymap.%s cannot change '%s' in context '%s' at layer '%s': the shortcut already belongs to %s",
						operation_name,
						normalized_stroke,
						context,
						layer,
						"keymap." .. tostring(reassignment and reassignment.kind)
					)
				)
				layer_map[normalized_stroke] = { { kind = "unbind", commands = {} } }
			end
		else
			for stroke, commands in pairs(bindings) do
				local normalized_stroke = normalize_stroke(stroke)
				local reassignment = find_stroke_reassignment(context, layer, normalized_stroke)
				assert(type(normalized_stroke) == "string", "keymap." .. operation_name .. " requires shortcut names")
				assert(
					not reassignment,
					string.format(
						"keymap.%s cannot change '%s' in context '%s' at layer '%s': the shortcut already belongs to %s",
						operation_name,
						normalized_stroke,
						context,
						layer,
						"keymap." .. tostring(reassignment and reassignment.kind)
					)
				)
				local operation = {
					kind = operation_name,
					commands = normalize_commands(operation_name, commands),
				}

				if operation_name == "bind" then
					layer_map[normalized_stroke] = { operation }
				else
					local operations = layer_map[normalized_stroke] or {}
					operations[#operations + 1] = operation
					layer_map[normalized_stroke] = operations
				end
			end
		end
	end

	rebuild_context_maps()
end

--- Store shortcut swaps, moves, or copies without depending on declaration order
-- All three operations keep reading their complete command lists from weaker layers
-- @param operation_name string swap, move, or copy
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
local function register_reassignment(operation_name, layer_or_contexts, optional_contexts)
	local layer, contexts = resolve_registration(operation_name, layer_or_contexts, optional_contexts)
	local planned_pairs = {}
	local used_strokes = {}

	-- Validate the complete request before storing any pair so one bad entry changes nothing
	for context, pairs_to_reassign in pairs(contexts) do
		assert(type(context) == "string" and context ~= "", "keymap." .. operation_name .. " received an invalid context")
		assert(
			type(pairs_to_reassign) == "table",
			"keymap." .. operation_name .. " requires shortcut pairs below context '" .. context .. "'"
		)
		local layer_map = ensure_context_layer_maps(context)[layer]
		used_strokes[context] = used_strokes[context] or {}

		for source_stroke, destination_stroke in pairs(pairs_to_reassign) do
			local source = normalize_stroke(source_stroke)
			local destination = normalize_stroke(destination_stroke)
			local self_relation = operation_name == "swap" and "with" or "onto"
			local existing_source = find_stroke_reassignment(context, layer, source)
			local existing_destination = find_stroke_reassignment(context, layer, destination)
			local repeated_pair = existing_source
				and existing_source == existing_destination
				and existing_source.kind == operation_name
				and ((existing_source.source == source and existing_source.destination == destination)
					or (operation_name == "swap"
						and existing_source.source == destination
						and existing_source.destination == source))

			assert(
				type(source) == "string" and type(destination) == "string",
				"keymap." .. operation_name .. " requires shortcut names on both sides"
			)
			assert(
				source ~= destination,
				string.format("keymap.%s cannot %s '%s' %s itself", operation_name, operation_name, source, self_relation)
			)

			for _, stroke in ipairs({ source, destination }) do
				assert(
					not used_strokes[context][stroke],
					string.format("keymap.%s uses '%s' more than once in context '%s'", operation_name, stroke, context)
				)
				local existing_reassignment = find_stroke_reassignment(context, layer, stroke)
				assert(
					repeated_pair or not existing_reassignment,
					string.format(
						"keymap.%s cannot use '%s' in context '%s' at layer '%s': the shortcut already belongs to keymap.%s",
						operation_name,
						stroke,
						context,
						layer,
						tostring(existing_reassignment and existing_reassignment.kind)
					)
				)
				assert(
					layer_map[stroke] == nil,
					string.format(
						"keymap.%s cannot use '%s' in context '%s' at layer '%s': the shortcut already has a declaration in that layer",
						operation_name,
						stroke,
						context,
						layer
					)
				)

				used_strokes[context][stroke] = true
			end

			if not repeated_pair then
				planned_pairs[#planned_pairs + 1] = {
					context = context,
					layer = layer,
					kind = operation_name,
					source = source,
					destination = destination,
				}
			end
		end
	end

	table.sort(planned_pairs, function(left, right)
		if left.context == right.context then
			return left.source < right.source
		end

		return left.context < right.context
	end)

	for _, pair in ipairs(planned_pairs) do
		local context_reassignments = keymap.context_layer_reassignments[pair.context][pair.layer]
		context_reassignments[#context_reassignments + 1] = pair
	end

	local rebuilt, rebuild_error = pcall(rebuild_context_maps)

	if rebuilt then
		return
	end

	-- Restore the previous valid keymap when a required inherited shortcut is missing
	for plan_index = #planned_pairs, 1, -1 do
		local planned_pair = planned_pairs[plan_index]
		local context_reassignments = keymap.context_layer_reassignments[planned_pair.context][planned_pair.layer]

		for pair_index = #context_reassignments, 1, -1 do
			if context_reassignments[pair_index] == planned_pair then
				table.remove(context_reassignments, pair_index)
				break
			end
		end
	end

	rebuild_context_maps()
	error(rebuild_error, 2)
end

--- Swap two complete inherited shortcut bindings
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.swap(layer_or_contexts, optional_contexts)
	register_reassignment("swap", layer_or_contexts, optional_contexts)
end

--- Move one complete inherited shortcut binding without naming its commands
-- The source becomes free and its command list replaces the destination in the same context
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.move(layer_or_contexts, optional_contexts)
	register_reassignment("move", layer_or_contexts, optional_contexts)
end

--- Copy one complete inherited shortcut binding without naming its commands
-- The source stays active and its command list replaces the destination in the same context
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.copy(layer_or_contexts, optional_contexts)
	register_reassignment("copy", layer_or_contexts, optional_contexts)
end

--- Replace inherited commands for keys grouped below their required contexts
-- Omitting the layer writes an ordinary user binding
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.bind(layer_or_contexts, optional_contexts)
	register_operation("bind", layer_or_contexts, optional_contexts)
end

--- Disable keys grouped below their required contexts
-- Omitting the layer writes an ordinary user unbinding
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.unbind(layer_or_contexts, optional_contexts)
	register_operation("unbind", layer_or_contexts, optional_contexts)
end

--- Add commands at the selected layer's priority without replacing inherited commands
-- Omitting the layer extends ordinary user bindings
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.extend(layer_or_contexts, optional_contexts)
	register_operation("extend", layer_or_contexts, optional_contexts)
end

--- Remove named commands from keys grouped below their required contexts
-- Omitting the layer removes commands through an ordinary user declaration
-- @param layer_or_contexts string|table explicit layer or user contexts
-- @param optional_contexts table|nil contexts supplied after an explicit layer
-- @return nil
function keymap.unextend(layer_or_contexts, optional_contexts)
	register_operation("unextend", layer_or_contexts, optional_contexts)
end

--- Return first configured stroke for one command id
-- @param cmd string command id
-- @return string|nil stroke token
function keymap.get_binding(cmd)
	local resolved = resolve_active_context_chain()

	return resolved.reverse_map[cmd]
end

--- Returns all commands currently bound to one stroke
-- @param stroke string keystroke token with optional modifiers
-- @return string normalized_stroke
-- @return table commands bound to normalized stroke
function keymap.get_commands_for_stroke(stroke)
	local normalized_stroke = normalize_stroke(stroke)
	local resolved = resolve_active_context_chain()
	local commands = resolved.map[normalized_stroke]

	if type(commands) == "table" then
		return normalized_stroke, copy_command_list(commands)
	end

	return normalized_stroke, {}
end

--- Arms a one-shot callback that receives the next non-modifier keystroke
-- @param listener function callback with stroke and commands
-- @return boolean true when listener was accepted
function keymap.listen_next_stroke(listener)
	if type(listener) ~= "function" then
		return false
	end
	keymap.next_stroke_listener = listener
	return true
end

--- Handle keydown event by tracking modifiers and dispatching stroke
-- @param k string key token from input event
-- @param modifier_state string|nil canonical modifier state
-- @return boolean true when key was handled
function keymap.on_key_down(k, modifier_state)
	keymap.sync_modkeys(modifier_state)

	local mk = modkey_map[k]

	if mk then
		keymap.modkeys[mk] = true
		-- Work around Windows altgr behavior that also toggles ctrl+alt
		if mk == "altgr" then
			keymap.modkeys["ctrl"] = false
		end
	else
		local stroke = key_to_stroke(k)
		local listener = keymap.next_stroke_listener
		local command_context = {
			origin = "keyboard",
			key = k,
		}

		if listener then
			keymap.next_stroke_listener = nil
			-- Consume this keystroke so inspection commands do not also execute mapped actions
			local normalized_stroke, commands = keymap.get_commands_for_stroke(stroke)
			listener(normalized_stroke, commands)
			return true
		end

		local handled = perform_stroke(stroke, command_context)
		return handled
	end
	return false
end

--- Handle a mouse-button press through keymap dispatch
-- Converts event-layer button names to unambiguous click strokes
-- @param button string canonical event-layer button name
-- @param x number pointer x coordinate
-- @param y number pointer y coordinate
-- @param clicks number click count reported by SDL
-- @return boolean true when one mouse binding handled the event
function keymap.on_mouse_down(button, x, y, clicks)
	local click = mouse_button_strokes[button]

	if not click then
		return false
	end

	return perform_stroke(key_to_stroke(click), {
		origin = "mouse",
		button = button,
		mouse_x = x,
		mouse_y = y,
		clicks = clicks,
	})
end

--- Handle mouse wheel event and route it through keymap dispatch
-- Accepts call shapes on_mouse_wheel(y), (x, y), or (x, y, dx, dy)
-- @param a number first wheel arg
-- @param b number|nil second wheel arg
-- @param c number|nil third wheel arg
-- @param d number|nil fourth wheel arg
-- @param target_view table|nil view beneath the pointer
-- @return boolean true when one wheel binding handled the event
function keymap.on_mouse_wheel(a, b, c, d, target_view)
	-- Prefer vertical delta when available in mixed call signatures
	local y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a

	if not y or y == 0 then
		return false
	end

	local core = package.loaded["core"]
	local mouse = core and core.workspace_view and core.workspace_view.mouse or nil
	local wheel = (y > 0) and "wheelup" or "wheeldown"
	local stroke = key_to_stroke(wheel)

	return perform_stroke(stroke, {
		origin = "mouse",
		mouse_x = mouse and mouse.x or nil,
		mouse_y = mouse and mouse.y or nil,
		wheel_y = y,
		target_view = target_view,
	}, target_view)
end

--- Handle keyup event and clear tracked modifier state
-- @param k string key token from input event
-- @param modifier_state string|nil canonical modifier state
-- @return nil
function keymap.on_key_up(k, modifier_state)
	keymap.sync_modkeys(modifier_state)

	local mk = modkey_map[k]

	if mk then
		keymap.modkeys[mk] = false
	end
end

keymap.bind("core", {
	global = {
		-- Open the bundled documentation
		["f1"] = "core:docs",
		["ctrl+shift+p"] = "core:find-command",
		["ctrl+p"] = "core:find-file",
		["ctrl+o"] = "core:open-file",
		["ctrl+n"] = "core:new-doc",
		-- Quit
		["ctrl+q"] = "core:quit",

		-- Toggle fullscreen
		["alt+enter"] = "core:toggle-fullscreen",
		["f11"] = "core:toggle-fullscreen",

		-- Toggle log
		["f12"] = "log:toggle",

		["alt+shift+j"] = "workspace:split-left",
		["alt+shift+l"] = "workspace:split-right",
		["alt+shift+i"] = "workspace:split-up",
		["alt+shift+k"] = "workspace:split-down",
		["alt+j"] = "workspace:switch-to-left",
		["alt+l"] = "workspace:switch-to-right",
		["alt+i"] = "workspace:switch-to-up",
		["alt+k"] = "workspace:switch-to-down",

		["ctrl+w"] = "workspace:close",
		["ctrl+tab"] = "workspace:switch-to-next-tab",
		["ctrl+shift+tab"] = "workspace:switch-to-previous-tab",
		["ctrl+pageup"] = "workspace:move-tab-left",
		["ctrl+pagedown"] = "workspace:move-tab-right",

		-- Switch active pane by index
		["alt+1"] = "workspace:switch-to-pane-1",
		["alt+2"] = "workspace:switch-to-pane-2",
		["alt+3"] = "workspace:switch-to-pane-3",
		["alt+4"] = "workspace:switch-to-pane-4",
		["alt+5"] = "workspace:switch-to-pane-5",
		["alt+6"] = "workspace:switch-to-pane-6",
		["alt+7"] = "workspace:switch-to-pane-7",
		["alt+8"] = "workspace:switch-to-pane-8",
		["alt+9"] = "workspace:switch-to-pane-9",

		-- Switch active tab by index
		["ctrl+1"] = "workspace:switch-to-tab-1",
		["ctrl+2"] = "workspace:switch-to-tab-2",
		["ctrl+3"] = "workspace:switch-to-tab-3",
		["ctrl+4"] = "workspace:switch-to-tab-4",
		["ctrl+5"] = "workspace:switch-to-tab-5",
		["ctrl+6"] = "workspace:switch-to-tab-6",
		["ctrl+7"] = "workspace:switch-to-tab-7",
		["ctrl+8"] = "workspace:switch-to-tab-8",
		["ctrl+9"] = "workspace:switch-to-tab-9",
	},
})
return keymap
