-- 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 cli = {}
local Doc = require("core.doc")
local doc_options = require("core.doc.options")
local file_location = require("core.file.location")
local open_mode = require("core.open_mode")

local MAX_TAB_SIZE = 32 -- Keep indentation practical and prevent accidental huge allocations
local MAX_ACTIVATION_INDEX = 1000000 -- Allow large workspaces without accepting meaningless indices

--- Format one document option snapshot for CLI logging
-- @param opts table|nil document options
-- @return string formatted
function cli.format_doc_options(opts)
	opts = type(opts) == "table" and opts or {}
	local parts = {}

	--- Append one key-value pair to a formatted option list
	-- @param key string field name
	-- @param value any field value
	local function push(key, value)
		if value == nil then
			return
		end

		parts[#parts + 1] = string.format("%s=%s", key, tostring(value))
	end

	push("name", opts.filename)
	push("format", opts.format_name)
	push("encoding", opts.encoding)
	push("codepage", opts.codepage)
	push("tab", opts.indent_size)
	push("newline", opts.newline)

	return #parts > 0 and table.concat(parts, ", ") or "<none>"
end

--- Format force-load options for CLI logging
-- @param opts table|nil force-load options
-- @return string formatted
function cli.format_force_load_options(opts)
	opts = type(opts) == "table" and opts or {}
	local parts = {}

	--- Append one force-load key-value pair to a formatted list
	-- @param key string field name
	-- @param value any field value
	local function push(key, value)
		if value == nil then
			return
		end

		parts[#parts + 1] = string.format("%s=%s", key, tostring(value))
	end

	push("force_encoding", opts.force_encoding)
	push("force_bom", opts.force_bom)
	push("force_codepage", opts.force_codepage)

	return #parts > 0 and table.concat(parts, ", ") or "<none>"
end

--- Return one friendly value for command-line reports
-- @param value any value to describe
-- @return string text
local function describe_value(value)
	if value == nil then
		return "none"
	elseif type(value) == "boolean" then
		return value and "yes" or "no"
	end

	return tostring(value)
end

--- Describe one parsed editor location without raw table fields
-- @param location table parsed location
-- @return string text
local function describe_location(location)
	if location.type == "offset" then
		local text = "offset " .. describe_value(location.offset)
		if location.end_offset ~= nil then
			text = text .. " to " .. describe_value(location.end_offset)
		end

		return text
	end

	local text = "line " .. describe_value(location.line)
	if location.column ~= nil then
		text = text .. ", column " .. describe_value(location.column)
	end
	if location.end_line ~= nil then
		text = text .. " to line " .. describe_value(location.end_line)
		if location.end_column ~= nil then
			text = text .. ", column " .. describe_value(location.end_column)
		end
	end

	return text
end

--- Append one parsed file target and every option attached to it
-- @param lines table report lines
-- @param number integer displayed target number
-- @param group table parsed file group
-- @param prefix string 'main' or 'split'
local function append_target_details(lines, number, group, prefix)
	local locations = group[prefix .. "_locations"] or {}
	lines[#lines + 1] = string.format("Target %d:", number)
	lines[#lines + 1] = "Path: " .. describe_value(group[prefix])
	lines[#lines + 1] = "Document options: " .. cli.format_doc_options(group[prefix .. "_options"])
	lines[#lines + 1] = "Mode: " .. describe_value(group[prefix .. "_open_mode"])
	lines[#lines + 1] = "Handler: " .. describe_value(group[prefix .. "_open_handler"])
	lines[#lines + 1] = "Action: " .. describe_value(group[prefix .. "_open_action"])
	lines[#lines + 1] = "Source format: " .. describe_value(group[prefix .. "_source_format"])
	lines[#lines + 1] = "Result format: " .. describe_value(group[prefix .. "_result_format"])
	lines[#lines + 1] = "Pinned: " .. describe_value(group[prefix .. "_pinned"])

	if prefix == "split" then
		lines[#lines + 1] = "Split direction: " .. describe_value(group.split_dir)
	end

	lines[#lines + 1] = string.format("Locations: %d", #locations)
	for index = 1, #locations do
		lines[#lines + 1] = string.format("Location %d: %s", index, describe_location(locations[index]))
	end
end

--- Describe every parsed command-line input and decision
-- @param parsed table|nil parsed request
-- @return table lines friendly report lines
function cli.describe_request(parsed)
	parsed = type(parsed) == "table" and parsed or {}
	local arguments = parsed.arguments or {}
	local groups = parsed.groups or {}
	local pending_new_docs = parsed.pending_new_docs or {}
	local errors = parsed.cli_option_errors or {}
	local notes = parsed.cli_notes or {}
	local location_options = parsed.location_options or {}
	local lines = {
		"Command line:",
		string.format("Arguments: %d", #arguments),
	}

	for index = 1, #arguments do
		lines[#lines + 1] = string.format("Argument %d: %s", index, tostring(arguments[index]))
	end

	lines[#lines + 1] = "Parsed request:"
	lines[#lines + 1] = "Project folder: " .. describe_value(parsed.project_dir)
	lines[#lines + 1] = string.format("File groups: %d", #groups)
	lines[#lines + 1] = string.format("New documents: %d", #pending_new_docs)
	lines[#lines + 1] = string.format("Errors: %d", #errors)
	lines[#lines + 1] = "Help: " .. describe_value(parsed.help_requested == true)
	lines[#lines + 1] = "Wait: " .. describe_value(parsed.wait_requested == true)
	lines[#lines + 1] = "Default document options: " .. cli.format_doc_options(parsed.cli_doc_options)
	lines[#lines + 1] = "Pending document name: " .. describe_value(parsed.pending_name_for_next_doc)

	if #errors > 0 then
		lines[#lines + 1] = "Command-line errors:"
		for index = 1, #errors do
			lines[#lines + 1] = errors[index]
		end
	end

	local target_number = 0
	for index = 1, #groups do
		local group = groups[index]
		target_number = target_number + 1
		append_target_details(lines, target_number, group, "main")
		if group.split then
			target_number = target_number + 1
			append_target_details(lines, target_number, group, "split")
		end
	end

	for index = 1, #pending_new_docs do
		lines[#lines + 1] = string.format("New document %d:", index)
		lines[#lines + 1] = "Options: " .. cli.format_doc_options(pending_new_docs[index])
	end

	lines[#lines + 1] = "Location rules:"
	lines[#lines + 1] = "Base: " .. describe_value(location_options.base)
	lines[#lines + 1] = "Line base: " .. describe_value(location_options.line_base)
	lines[#lines + 1] = "Column base: " .. describe_value(location_options.column_base)
	lines[#lines + 1] = "Offset base: " .. describe_value(location_options.offset_base)
	lines[#lines + 1] = "Column unit: " .. describe_value(location_options.column_unit)
	lines[#lines + 1] = "Offset unit: " .. describe_value(location_options.offset_unit)
	lines[#lines + 1] = "Range end: " .. describe_value(location_options.range_end)

	if #notes > 0 then
		lines[#lines + 1] = "Notes:"
		for index = 1, #notes do
			lines[#lines + 1] = notes[index]
		end
	end

	return lines
end

--- Apply a pending startup diff override to parsed open groups
-- @param core table core module
-- @param groups table parsed startup groups
-- @return table groups
local function apply_pending_diff_group_override(core, groups)
	if not (core.pending_diff_mode and core.pending_diff_files) then
		return groups
	end

	local left = core.pending_diff_files[1]
	local right = core.pending_diff_files[2]
	local left_info = left and system.get_file_info(left)
	local right_info = right and system.get_file_info(right)

	if left_info and left_info.type == "file" and right_info and right_info.type == "file" then
		local left_abs = system.absolute_path(left)
		local right_abs = system.absolute_path(right)
		core.diff_context = {
			enabled = true,
			left = left_abs,
			right = right_abs,
		}
		return {
			{ main = left_abs, split = right_abs, split_dir = "right", },
		}
	end

	return groups
end

--- Parse one CLI argv list into document-open requests and startup flags
-- @param core table core module
-- @param args table|nil argv list
-- @param opts table|nil parser mode flags
-- @return table parsed request state
function cli.parse_startup_cli_args(core, args, opts)
	args = type(args) == "table" and args or ARGS
	opts = type(opts) == "table" and opts or {}
	local allow_runtime_control_options = opts.allow_runtime_control_options ~= false
	local allow_project_dir_change = opts.allow_project_dir_change ~= false
	local allow_diff_mode = opts.allow_diff_mode ~= false
	local working_directory = type(opts.working_directory) == "string"
		and opts.working_directory ~= "" and opts.working_directory or nil

	-- Help ignores every other argument and opens the packaged guide
	local help_aliases = { ["--help"] = true, ["-h"] = true, ["-?"] = true, ["/?"] = true }
	local help_index = 1
	while help_index <= #args do
		-- The value after '--file' is always a filename, even when it looks like help
		if args[help_index] == "--file" then
			help_index = help_index + 2
		elseif help_aliases[args[help_index]] then
			return {
				project_dir = EXE_DIR,
				groups = {},
				pending_new_docs = {},
				cli_doc_options = {},
				help_requested = true,
				cli_option_errors = {},
				cli_notes = {},
				arguments = args,
			}
		else
			help_index = help_index + 1
		end
	end

	-- Example usage:
	-- lx.exe --encoding utf8 --codepage cp437 file1.txt --tab 8 --codepage none file2.txt
	-- lx.exe --encoding utf16 --new -N scratch.txt --format lua
	-- lx.exe --defaults --new --name notes.md

	local state = {
		project_dir = EXE_DIR,
		groups = {},
		current_group = nil,
		split_next = false,
		split_dir_next = "right",
		pending_new_docs = {},
		pending_name_for_next_doc = nil,
		cli_doc_options = {},
		current_open_mode = nil,
		current_open_handler = nil,
		current_open_action = nil,
		current_source_format = nil,
		pin_next_file = false,
		pending_locations = nil,
		pending_location_option = nil,
		queued_file_count = 0,
		only_file_target = nil,
		location_options = {
			base = 1,
			line_base = nil,
			column_base = nil,
			offset_base = 0,
			column_unit = "codepoint",
			offset_unit = "byte",
			range_end = "inclusive",
		},
		wait_requested = false,
		mode_switch_count = 0,
		only_switched_mode = nil,
		handler_switch_count = 0,
		only_switched_handler = nil,
		action_switch_count = 0,
		only_switched_action = nil,
		cli_option_errors = {},
		cli_notes = {},
		arguments = args,
	}

	local function push_cli_note(fmt, ...)
		state.cli_notes[#state.cli_notes + 1] = string.format(fmt, ...)
	end

	local function push_option_error(fmt, ...)
		state.cli_option_errors[#state.cli_option_errors + 1] = string.format(fmt, ...)
	end

	local function consume_pending_name(doc_options)
		if type(state.pending_name_for_next_doc) == "string" and state.pending_name_for_next_doc ~= "" then
			doc_options.filename = state.pending_name_for_next_doc
			state.pending_name_for_next_doc = nil
		end
	end

	--- Parse one filename argument and make only its physical part absolute
	-- @param path_token string command-line filename
	-- @param parse_locations boolean whether a trailing location suffix is syntax
	-- @return table|nil reference parsed file reference
	local function parse_file_target(path_token, parse_locations)
		local reference, parse_error = system.parse_file_reference(path_token, parse_locations)
		if not reference then
			push_option_error('Invalid file reference "%s": %s', path_token, tostring(parse_error))
			return nil
		end

		-- Resolve relative paths where this request was launched, not where the primary window started
		local physical_filename = system.absolute_path(reference.filename, working_directory)
			or reference.filename
		reference.filename = system.format_filename(physical_filename, reference.virtual_path)

		return reference
	end

	--- Queue one parsed file reference with the location options waiting for it
	-- @param reference table parsed file reference
	local function queue_file_target(reference)
		local filename = reference.filename
		local has_suffix_locations = #reference.locations > 0
		local locations = has_suffix_locations and reference.locations or state.pending_locations or {}
		local file_doc_options = doc_options.clone_doc_options(state.cli_doc_options)
		local pin_file = state.pin_next_file
		local target_prefix
		state.pending_locations = nil
		state.pending_location_option = nil
		state.pin_next_file = false
		consume_pending_name(file_doc_options)

		if state.split_next then
			if not state.current_group then
				state.current_group = {
					main = filename,
					main_options = file_doc_options,
					main_open_mode = state.current_open_mode,
					main_open_handler = state.current_open_handler,
					main_open_action = state.current_open_action,
					main_source_format = state.current_source_format,
					main_pinned = pin_file,
					main_locations = locations,
				}
				table.insert(state.groups, state.current_group)
				target_prefix = "main"
			else
				state.current_group.split = filename
				state.current_group.split_dir = state.split_dir_next
				state.current_group.split_options = file_doc_options
				state.current_group.split_open_mode = state.current_open_mode
				state.current_group.split_open_handler = state.current_open_handler
				state.current_group.split_open_action = state.current_open_action
				state.current_group.split_source_format = state.current_source_format
				state.current_group.split_pinned = pin_file
				state.current_group.split_locations = locations
				target_prefix = "split"
			end
			state.split_next = false
		else
			state.current_group = {
				main = filename,
				main_options = file_doc_options,
				main_open_mode = state.current_open_mode,
				main_open_handler = state.current_open_handler,
				main_open_action = state.current_open_action,
				main_source_format = state.current_source_format,
				main_pinned = pin_file,
				main_locations = locations,
			}
			table.insert(state.groups, state.current_group)
			target_prefix = "main"
		end

		state.queued_file_count = state.queued_file_count + 1
		state.only_file_target = {
			group = state.current_group,
			prefix = target_prefix,
			has_suffix_locations = has_suffix_locations,
		}
	end

	--- Parse one bounded positive integer option value
	-- @param value string raw option value
	-- @param option_name string option shown in errors
	-- @param maximum integer largest accepted value
	-- @return number|nil value parsed integer
	local function parse_positive_integer(value, option_name, maximum)
		local number = math.tointeger(tonumber(value))
		if number and number > 0 and number <= maximum then
			return number
		end

		push_option_error(
			'Invalid %s value "%s" (expected integer from 1 to %d)',
			option_name,
			tostring(value),
			maximum
		)
		return nil
	end

	local option_aliases = {}

	--- Register aliases that share one value count and handler
	-- @param names table option aliases
	-- @param num_values integer required value count
	-- @param handler function option handler
	-- @param option_settings table|nil parser settings
	local function register_option(names, num_values, handler, option_settings)
		local option_info = {
			names = names,
			num_values = num_values,
			handler = handler,
			inline_only = option_settings and option_settings.inline_only == true,
		}
		for i = 1, #names do
			option_aliases[names[i]] = option_info
		end
	end

	--- Accept one base value from the exact '0' or '1' vocabulary
	-- @param value string raw option value
	-- @param option_name string option shown in errors
	-- @return integer|nil base
	local function parse_location_base(value, option_name)
		if value == "0" then
			return 0
		elseif value == "1" then
			return 1
		end

		push_option_error('Invalid %s value "%s" (expected 0 or 1)', option_name, tostring(value))
		return nil
	end

	--- Accept one value from a small named option vocabulary
	-- @param value string raw option value
	-- @param option_name string option shown in errors
	-- @param allowed table allowed names
	-- @param expected string expected names shown in errors
	-- @return string|nil value
	local function parse_location_choice(value, option_name, allowed, expected)
		if allowed[value] then
			return value
		end

		push_option_error('Invalid %s value "%s" (expected %s)', option_name, tostring(value), expected)
		return nil
	end

	--- Parse a prefix location option with the native location grammar
	-- @param value string text after the option
	-- @param option_name string option shown in errors
	-- @return table|nil locations parsed locations
	local function parse_prefix_locations(value, option_name)
		local locations, parse_error = system.parse_file_locations(value)
		if not locations then
			push_option_error('Invalid %s location "%s": %s', option_name, tostring(value), tostring(parse_error))
			return nil
		end

		return locations
	end

	--- Replace the location request waiting for the next file
	-- @param value string location text
	-- @param option_name string option shown in errors
	local function select_prefix_locations(value, option_name)
		local locations = parse_prefix_locations(value, option_name)
		if locations then
			state.pending_locations = locations
			state.pending_location_option = option_name
		end
	end

	--- Make one checked mode active for files that follow it
	-- @param mode string shared open mode
	local function select_open_mode(mode)
		state.current_open_mode = open_mode.check(mode)
		state.mode_switch_count = state.mode_switch_count + 1
		state.only_switched_mode = mode
	end

	--- Make one handler active for files that follow it
	-- @param handler_name string registered open handler
	local function select_open_handler(handler_name)
		state.current_open_handler = handler_name
		state.handler_switch_count = state.handler_switch_count + 1
		state.only_switched_handler = handler_name
	end

	--- Make one source action active for files that follow it
	-- @param action_name string handler-owned action
	local function select_open_action(action_name)
		state.current_open_action = action_name
		state.action_switch_count = state.action_switch_count + 1
		state.only_switched_action = action_name
	end

	register_option({ "--diff", }, 2, function(values)
		if not allow_diff_mode then
			push_cli_note("Ignored %s because it is only available during startup", "--diff")
			return
		end

		local left = values[1]
		local right = values[2]
		if type(left) == "string" and type(right) == "string" then
			core.pending_diff_mode = true
			core.pending_diff_files = { left, right, }
		end
	end)

	register_option({ "--pane" }, 0, function()
		state.split_next = true
		state.split_dir_next = "right"
	end)

	register_option({ "--pin" }, 0, function()
		-- Pin applies once, to the next file rather than every later file
		state.pin_next_file = true
	end)

	register_option({ "--line" }, 1, function(values, used_name)
		local locations = parse_prefix_locations(values[1], used_name)
		local location = locations and locations[1]
		if location
			and #locations == 1
			and location.type == "text"
			and location.column == nil
			and location.end_line == nil
		then
			state.pending_locations = locations
			state.pending_location_option = used_name
		elseif locations then
			push_option_error('Invalid %s value "%s" (expected one line)', used_name, tostring(values[1]))
		end
	end)

	register_option({ "--column" }, 1, function(values, used_name)
		local location = state.pending_locations and state.pending_locations[1]

		if not (location
			and #state.pending_locations == 1
			and location.type == "text"
			and location.end_line == nil)
		then
			push_option_error('Option "%s" requires a preceding line location', used_name)
			return
		end

		local columns = parse_prefix_locations(values[1], used_name)
		local column = columns and columns[1]
		if column
			and #columns == 1
			and column.type == "text"
			and column.column == nil
			and column.end_line == nil
		then
			location.column = column.line
		elseif columns then
			push_option_error('Invalid %s value "%s" (expected one column)', used_name, tostring(values[1]))
		end
	end)

	register_option({ "--goto" }, 1, function(values, used_name)
		select_prefix_locations(values[1], used_name)
	end)

	register_option({ "--wait" }, 0, function()
		state.wait_requested = true
	end)

	register_option({ "--base" }, 1, function(values, used_name)
		local base = parse_location_base(values[1], used_name)
		if base then
			state.location_options.base = base
		end
	end, { inline_only = true })

	register_option({ "-b0" }, 0, function()
		state.location_options.base = 0
	end)

	register_option({ "-b1" }, 0, function()
		state.location_options.base = 1
	end)

	register_option({ "--line-base" }, 1, function(values, used_name)
		local base = parse_location_base(values[1], used_name)
		if base then
			state.location_options.line_base = base
		end
	end, { inline_only = true })

	register_option({ "-l0" }, 0, function()
		state.location_options.line_base = 0
	end)

	register_option({ "-l1" }, 0, function()
		state.location_options.line_base = 1
	end)

	register_option({ "--column-base" }, 1, function(values, used_name)
		local base = parse_location_base(values[1], used_name)
		if base then
			state.location_options.column_base = base
		end
	end, { inline_only = true })

	register_option({ "-c0" }, 0, function()
		state.location_options.column_base = 0
	end)

	register_option({ "-c1" }, 0, function()
		state.location_options.column_base = 1
	end)

	register_option({ "--offset-base" }, 1, function(values, used_name)
		local base = parse_location_base(values[1], used_name)
		if base then
			state.location_options.offset_base = base
		end
	end, { inline_only = true })

	register_option({ "-o0" }, 0, function()
		state.location_options.offset_base = 0
	end)

	register_option({ "-o1" }, 0, function()
		state.location_options.offset_base = 1
	end)

	local column_units = { codepoint = true, utf8 = true, utf16 = true, byte = true }
	local function set_column_unit(values, used_name)
		local unit = parse_location_choice(
			values[1],
			used_name,
			column_units,
			"codepoint, utf8, utf16, or byte"
		)
		if unit then
			state.location_options.column_unit = unit
		end
	end

	register_option({ "--column-unit" }, 1, set_column_unit, { inline_only = true })
	register_option({ "-cu" }, 1, set_column_unit)

	local offset_units = { byte = true, codepoint = true }
	register_option({ "--offset-unit" }, 1, function(values, used_name)
		local unit = parse_location_choice(values[1], used_name, offset_units, "byte or codepoint")
		if unit then
			state.location_options.offset_unit = unit
		end
	end, { inline_only = true })

	local range_end_values = { inclusive = true, exclusive = true }
	register_option({ "--range-end" }, 1, function(values, used_name)
		local range_end = parse_location_choice(
			values[1],
			used_name,
			range_end_values,
			"inclusive or exclusive"
		)
		if range_end then
			state.location_options.range_end = range_end
		end
	end, { inline_only = true })

	register_option({ "--gridlock" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--gridlock")
			return
		end

		core.pending_gridlock = true
	end)

	register_option({ "--no-cache", "-nc" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--no-cache")
			return
		end

		core.pending_no_cache = true
	end)

	register_option({ "--update-cache", "-uc" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--update-cache")
			return
		end

		core.pending_update_cache = true
	end)

	register_option({ "--run-tests", "-rt" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--run-tests")
			return
		end

		core.pending_run_tests = true
	end)

	register_option({ "--run-test" }, 1, function(values)
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--run-test")
			return
		end

		-- A named unit is still a forced test run, but avoids loading unrelated units
		core.pending_run_tests = true
		core.pending_run_test = values[1]
	end)

	register_option({ "--no-tests", "-nt" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--no-tests")
			return
		end

		core.disable_tests = true
	end)

	register_option({ "--edit", "-e" }, 0, function()
		select_open_mode(open_mode.edit)
	end)

	register_option({ "--view", "-v" }, 0, function()
		select_open_mode(open_mode.view)
	end)

	register_option({ "--preview", "-p" }, 0, function()
		select_open_mode(open_mode.preview)
	end)

	register_option({ "--bytes", "-b" }, 0, function()
		select_open_mode(open_mode.view)
		select_open_handler("bytes")
	end)

	register_option({ "--hex", "-x" }, 0, function()
		select_open_mode(open_mode.view)
		select_open_handler("hex")
	end)

	register_option({ "--extract", "-ex" }, 0, function()
		select_open_action("extract")
	end)

	register_option({ "--unpack", "-un" }, 0, function()
		select_open_action("unpack")
	end)

	register_option({ "--handler", "-ha" }, 1, function(values)
		local handler_name = values[1]

		if type(handler_name) == "string" and handler_name ~= "" then
			select_open_handler(handler_name)
		end
	end)

	register_option({ "--action", "-ac" }, 1, function(values)
		local action_name = values[1]

		if type(action_name) == "string" and action_name ~= "" then
			select_open_action(action_name)
		end
	end)

	register_option({ "--exit" }, 0, function()
		if not allow_runtime_control_options then
			push_cli_note("Ignored %s because it is only available during startup", "--exit")
			return
		end

		core.pending_exit = true
	end)

	register_option({ "--new", "-n" }, 0, function()
		local new_doc_options = doc_options.clone_doc_options(state.cli_doc_options)
		consume_pending_name(new_doc_options)
		table.insert(state.pending_new_docs, new_doc_options)
	end)

	register_option({ "--name", "-N" }, 1, function(values)
		local name = values[1]
		if type(name) == "string" and name ~= "" then
			state.pending_name_for_next_doc = name
		end
	end)

	register_option({ "--format", "-f" }, 1, function(values)
		local format_name = values[1]
		if type(format_name) == "string" and format_name ~= "" then
			state.cli_doc_options.format_name = format_name
		end
	end)

	register_option({ "--source-format", "-sf" }, 1, function(values)
		local format_name = values[1]
		if type(format_name) == "string" and format_name ~= "" then
			state.current_source_format = format_name
		end
	end)

	register_option({ "--tab-size", "--tab", "-t" }, 1, function(values, used_name)
		local n = parse_positive_integer(values[1], used_name, MAX_TAB_SIZE)
		if n then
			state.cli_doc_options.indent_size = n
		end
	end)

	register_option({ "--encoding", "-en" }, 1, function(values)
		local encoding = values[1]
		if type(encoding) == "string" and encoding ~= "" then
			if doc_options.normalize_doc_text_encoding(encoding) then
				state.cli_doc_options.encoding = encoding
			else
				push_option_error('Invalid --encoding value "%s"', encoding)
			end
		end
	end)

	register_option({ "--codepage", "-cp" }, 1, function(values)
		local codepage_name = values[1]
		if type(codepage_name) == "string" and codepage_name ~= "" then
			if doc_options.normalize_doc_codepage(codepage_name) then
				state.cli_doc_options.codepage = codepage_name
			else
				push_option_error('Invalid --codepage value "%s"', codepage_name)
			end
		end
	end)

	register_option({ "--newline", "-nl" }, 1, function(values)
		local mode = type(values[1]) == "string" and values[1]:lower() or nil
		if mode == "lf" or mode == "crlf" or mode == "auto" then
			state.cli_doc_options.newline = mode
		else
			push_option_error('Invalid --newline value "%s" (expected lf|crlf|auto)', tostring(values[1]))
		end
	end)

	register_option({ "--defaults", "-D", }, 0, function()
		-- Reset sequential CLI overrides back to configured defaults behavior
		state.cli_doc_options = {}
		state.pending_name_for_next_doc = nil
		state.current_open_mode = nil
		state.current_open_handler = nil
		state.current_open_action = nil
		state.current_source_format = nil
		state.pin_next_file = false
		state.pending_locations = nil
		state.pending_location_option = nil
	end)

	register_option({ "--activate-tab", "-at" }, 1, function(values, used_name)
		local n = parse_positive_integer(values[1], used_name, MAX_ACTIVATION_INDEX)
		if n then
			core.pending_activate_tab = n
		end
	end)

	register_option({ "--activate-pane", "-ap" }, 1, function(values, used_name)
		local n = parse_positive_integer(values[1], used_name, MAX_ACTIVATION_INDEX)
		if n then
			core.pending_activate_pane = n
		end
	end)

	register_option({ "--activate-pane-tab", "-apt" }, 2, function(values, used_name)
		local p = parse_positive_integer(values[1], used_name .. " pane", MAX_ACTIVATION_INDEX)
		local t = parse_positive_integer(values[2], used_name .. " tab", MAX_ACTIVATION_INDEX)
		if p and t then
			core.pending_activate_pane = p
			core.pending_activate_tab = t
		end
	end)

	local function normalize_cli_path(path)
		if type(path) ~= "string" or path == "" then
			return nil
		end

		return system.path_key(system.absolute_path(path) or path)
	end

	local exe_norm = normalize_cli_path(EXE_FILE)
	local exe_base = exe_norm and exe_norm:match("([^/\\]+)$") or nil

	local function is_executable_token(arg)
		if type(arg) ~= "string" then
			return false
		end
		local normalized = normalize_cli_path(arg)
		if normalized and exe_norm and normalized == exe_norm then
			return true
		end
		local base = (normalized or arg):match("([^/\\]+)$")
		return base and exe_base and base:lower() == exe_base:lower() or false
	end

	local arg_start = 1
	if type(args[1]) == "string" and is_executable_token(args[1]) then
		arg_start = 2
	end

	local function parse_option_token(token)
		if type(token) ~= "string" then
			return nil, nil
		end

		local long_name, long_value = token:match("^(%-%-[^=]+)=(.*)$")
		if long_name then
			return long_name, long_value
		end

		local short_name, short_value = token:match("^(%-[^=]+)=(.*)$")
		if short_name then
			return short_name, short_value
		end
		return token, nil
	end

	local index = arg_start
	while index <= #args do
		local token = args[index]
		if type(token) ~= "string" then
			index = index + 1
			goto continue
		end

		if token == "--file" then
			local literal_path = args[index + 1]
			if type(literal_path) ~= "string" then
				push_option_error('Option "--file" expects 1 value(s)')
				index = index + 1
				goto continue
			end

			local reference = parse_file_target(literal_path, false)
			if reference then
				queue_file_target(reference)
			end
			index = index + 2
			goto continue
		end

		if token:sub(1, 1) == "+" then
			local locations = parse_prefix_locations(token:sub(2), token)
			local location = locations and locations[1]
			if location
				and #locations == 1
				and location.type == "text"
				and location.end_line == nil
			then
				state.pending_locations = locations
				state.pending_location_option = token
			elseif locations then
				push_option_error('Invalid location shortcut "%s" (expected +line or +line:column)', token)
			end
			index = index + 1
			goto continue
		end

		local pane_dir = token:match("^%-%-pane%-(.+)$")
		if pane_dir == "left" or pane_dir == "right" or pane_dir == "up" or pane_dir == "down" then
			state.split_next = true
			state.split_dir_next = pane_dir
			index = index + 1
			goto continue
		end

		local option_name, inline_value = parse_option_token(token)
		local option_info = option_aliases[option_name]

		if option_info then
			if option_info.inline_only and inline_value == nil then
				push_option_error('Option "%s" requires the form %s=VALUE', option_name, option_name)
				index = index + 1
				goto continue
			end

			local values = {}
			local consumed = 0

			if inline_value ~= nil then
				values[#values + 1] = inline_value
			end

			while #values < option_info.num_values do
				local next_index = index + consumed + 1
				local next_value = args[next_index]
				if type(next_value) ~= "string" then
					push_option_error('Option "%s" expects %d value(s)', option_name, option_info.num_values)
					break
				end

				local next_option_name = parse_option_token(next_value)
				if option_aliases[next_option_name]
					or next_value == "--file"
					or next_value:sub(1, 1) == "+"
				then
					push_option_error('Option "%s" expects %d value(s)', option_name, option_info.num_values)
					break
				end

				values[#values + 1] = next_value
				consumed = consumed + 1
			end

			if #values == option_info.num_values then
				option_info.handler(values, option_name, token)
			end

			index = index + consumed + 1
			goto continue
		end

		if token:sub(1, 1) == "-" then
			-- Unknown options must never turn their following value into a misleading file request
			push_option_error('Unknown option "%s"', token)
			index = index + 1
			goto continue
		end

		local reference = parse_file_target(token, true)
		local info = reference and system.get_file_info(reference.filename) or {}
		if not reference then
			-- A failed filename still consumes the prefix that targeted it
			state.pending_locations = nil
			state.pending_location_option = nil
		end

		if reference and info.type == "file" then
			queue_file_target(reference)
		elseif info.type == "dir" then
			if allow_project_dir_change then
				state.project_dir = token
			else
				push_cli_note("Ignored project folder outside startup: %s", token)
			end
			state.split_next = false
		elseif reference then
			-- Treat non-option tokens as filename intents even if file-info resolution differs at parse time
			queue_file_target(reference)
		end

		index = index + 1
		::continue::
	end

	if state.pending_locations then
		if state.queued_file_count == 1 then
			local target = state.only_file_target
			if not target.has_suffix_locations then
				target.group[target.prefix .. "_locations"] = state.pending_locations
			end
		else
			push_option_error(
				'Location option "%s" has no following file',
				tostring(state.pending_location_option)
			)
		end
	end

	-- One mode switch is an unambiguous request for every file, regardless of its position
	if state.mode_switch_count == 1 then
		for _, group in ipairs(state.groups) do
			group.main_open_mode = state.only_switched_mode
			if group.split then
				group.split_open_mode = state.only_switched_mode
			end
		end
	end

	-- One handler or action switch follows the same whole-command convenience as one mode switch
	if state.handler_switch_count == 1 and state.mode_switch_count <= 1 then
		for _, group in ipairs(state.groups) do
			group.main_open_handler = state.only_switched_handler
			if group.split then
				group.split_open_handler = state.only_switched_handler
			end
		end
	end
	if state.action_switch_count == 1 and state.mode_switch_count <= 1 then
		for _, group in ipairs(state.groups) do
			group.main_open_action = state.only_switched_action
			if group.split then
				group.split_open_action = state.only_switched_action
			end
		end
	end

	--- Separate source detection from the final format after an action
	-- @param group table queued file group
	-- @param prefix string 'main' or 'split'
	local function finalize_format_request(group, prefix)
		local options = group[prefix .. "_options"] or {}
		local action = group[prefix .. "_open_action"]
		local source_format = group[prefix .. "_source_format"]

		if action then
			group[prefix .. "_result_format"] = options.format_name
			options.format_name = nil
		elseif options.format_name == nil then
			-- Without a transformation, the source is also the final document
			options.format_name = source_format
			group[prefix .. "_source_format"] = nil
		end
	end

	for _, group in ipairs(state.groups) do
		finalize_format_request(group, "main")
		if group.split then
			finalize_format_request(group, "split")
		end
	end

	return {
		project_dir = state.project_dir,
		groups = state.groups,
		pending_new_docs = state.pending_new_docs,
		pending_name_for_next_doc = state.pending_name_for_next_doc,
		cli_doc_options = state.cli_doc_options,
		cli_option_errors = state.cli_option_errors,
		cli_notes = state.cli_notes,
		arguments = state.arguments,
		location_options = state.location_options,
		wait_requested = state.wait_requested,
	}
end

--- Apply one parsed CLI request to the current session
-- @param core table core module
-- @param parsed table|nil parser result
-- @param opts table|nil apply options
-- @return boolean changed
function cli.apply_request(core, parsed, opts)
	local request_start_time = system.get_time()
	parsed = type(parsed) == "table" and parsed or {}
	opts = type(opts) == "table" and opts or {}
	local apply_project_dir = opts.apply_project_dir == true
	local auto_create_doc = opts.auto_create_doc == true
	local focus_window = opts.focus_window == true
	local activate_pane = opts.activate_pane
	local activate_tab = opts.activate_tab
	local wait_targets = opts.wait_targets
	local wait_target_set = wait_targets and {} or nil
	local help_requested = parsed.help_requested == true
	local groups = parsed.groups or {}
	local pending_new_docs = parsed.pending_new_docs or {}
	local pending_name_for_next_doc = parsed.pending_name_for_next_doc
	local cli_doc_options = parsed.cli_doc_options or {}
	local cli_option_errors = parsed.cli_option_errors or {}
	local location_options = parsed.location_options or {}
	-- Only the initial lone file may become the Browser's reusable preview
	local provisional_single_file = opts.provisional_single_file == true
		and #groups == 1
		and groups[1].split == nil
		and groups[1].main_pinned ~= true
		and #pending_new_docs == 0
	local changed = false
	local command = require("core.command")

	--- Retain each document once for this request's wait lifecycle
	-- @param doc table|nil opened or reused document
	local function add_wait_target(doc)
		if wait_targets and doc and not wait_target_set[doc] then
			wait_target_set[doc] = true
			wait_targets[#wait_targets + 1] = doc
		end
	end

	--- Return whether one explicit handler needs the original file bytes
	-- @param handler_name string|nil requested handler
	-- @return boolean raw_binary
	local function uses_raw_binary_handler(handler_name)
		local name = type(handler_name) == "string" and handler_name:lower() or nil

		return name == "bytes" or name == "hex"
	end

	--- Apply one file's requested locations and release its temporary source bytes
	-- @param filename string requested filename
	-- @param doc table opened document
	-- @param view table opened document view
	-- @param locations table parsed locations
	local function apply_locations(filename, doc, view, locations)
		if not locations or #locations == 0 then
			return
		end

		local source_error = doc._location_source_error
		local ok, location_error = file_location.apply(
			doc,
			view,
			locations,
			location_options,
			doc._location_source_data
		)
		doc._location_source_data = nil
		doc._location_source_error = nil

		if not ok then
			core.error('Cannot apply location to "%s": %s', filename, source_error or location_error)
		end
	end

	if apply_project_dir and type(parsed.project_dir) == "string" and parsed.project_dir ~= "" then
		system.chdir(parsed.project_dir)
	end

	groups = apply_pending_diff_group_override(core, groups)

	for i = 1, #cli_option_errors do
		core.error("%s", cli_option_errors[i])
	end

	if help_requested then
		local help_path = EXE_DIR .. PATHSEP .. "docs" .. PATHSEP .. "cli.md"
		local help_doc = Doc.open_file(help_path, {
			open_mode = open_mode.preview,
		})

		if help_doc then
			add_wait_target(help_doc)
			core.workspace_view:open_doc(help_doc, {
				open_origin = "external",
				open_mode = open_mode.preview,
			})
			changed = true
		end
	end

	if #groups > 0 then
		local main_node = core.workspace_view:get_active_node()
		local split_node = nil

		for _, group in ipairs(groups) do
			core.set_active_view(main_node.active_view)
			local main_open_mode = open_mode.check(group.main_open_mode)
			local main_locations = group.main_locations or {}
			local main_open_handler = group.main_open_handler
			local main_open_action = group.main_open_action
			local main_source_format = group.main_source_format
			local main_result_format = group.main_result_format
			local main_doc = Doc.open_file(group.main, {
				-- Source actions must read the container before a raw handler presents their result
				force_binary = main_open_action == nil and uses_raw_binary_handler(main_open_handler),
				open_mode = main_open_mode,
				open_handler = main_open_handler,
				open_action = main_open_action,
				source_format_name = main_source_format,
				result_format_name = main_result_format,
				doc_options = group.main_options,
				retain_location_source = file_location.needs_source_data(main_locations, location_options),
			})

			if main_doc then
				add_wait_target(main_doc)
				local main_view = core.workspace_view:open_doc(main_doc, {
					open_origin = "external",
					open_mode = main_open_mode,
					open_handler = main_open_handler,
					open_action = main_open_action,
					result_format_name = main_result_format,
				})
				apply_locations(group.main, main_doc, main_view, main_locations)

				if provisional_single_file and core.browser and core.browser.get_view then
					local browser_view = core.browser.get_view()
					if browser_view then
						-- Reuse the untouched startup file as the first Browser preview
						browser_view:set_preview_view(main_view)
					end
				end
				changed = true
			end

			if group.split then
				local split_open_mode = open_mode.check(group.split_open_mode)
				local split_locations = group.split_locations or {}
				local split_open_handler = group.split_open_handler
				local split_open_action = group.split_open_action
				local split_source_format = group.split_source_format
				local split_result_format = group.split_result_format
				if not split_node then
					local split_doc = Doc.open_file(group.split, {
						force_binary = split_open_action == nil and uses_raw_binary_handler(split_open_handler),
						open_mode = split_open_mode,
						open_handler = split_open_handler,
						open_action = split_open_action,
						source_format_name = split_source_format,
						result_format_name = split_result_format,
						doc_options = group.split_options,
						retain_location_source = file_location.needs_source_data(split_locations, location_options),
					})
					if split_doc then
						add_wait_target(split_doc)
						local split_view = core.create_doc_view(split_doc, {
							open_origin = "external",
							open_mode = split_open_mode,
							open_handler = split_open_handler,
							open_action = split_open_action,
							result_format_name = split_result_format,
						})
						apply_locations(group.split, split_doc, split_view, split_locations)
						local dir = group.split_dir or "right"
						main_node = main_node:split(dir, split_view)
						-- Resolve by contained view because left and upper splits swap child branches
						split_node = core.workspace_view.root_node:get_node_for_view(split_view)
						core.workspace_view.root_node:update_layout()
						core.set_active_view(main_node.active_view)
						changed = true
					end
				else
					local split_doc = Doc.open_file(group.split, {
						force_binary = split_open_action == nil and uses_raw_binary_handler(split_open_handler),
						open_mode = split_open_mode,
						open_handler = split_open_handler,
						open_action = split_open_action,
						source_format_name = split_source_format,
						result_format_name = split_result_format,
						doc_options = group.split_options,
						retain_location_source = file_location.needs_source_data(split_locations, location_options),
					})
					if split_doc then
						add_wait_target(split_doc)
						local split_view = core.create_doc_view(split_doc, {
							open_origin = "external",
							open_mode = split_open_mode,
							open_handler = split_open_handler,
							open_action = split_open_action,
							result_format_name = split_result_format,
						})
						apply_locations(group.split, split_doc, split_view, split_locations)
						split_node:add_view(split_view)
						core.workspace_view.root_node:update_layout()
						changed = true
					end
				end
			end
		end
	end

	if #pending_new_docs > 0 then
		for i = 1, #pending_new_docs do
			local startup_doc = Doc.create(nil, pending_new_docs[i])
			add_wait_target(startup_doc)
			core.workspace_view:open_doc(startup_doc)
			changed = true
		end
	end

	-- A file launch must never add an automatic untitled tab, even when one requested file fails to load
	if auto_create_doc and not help_requested and #groups == 0 and #core.docs == 0 then
		local auto_doc_opts = doc_options.clone_doc_options(cli_doc_options)

		if type(pending_name_for_next_doc) == "string" and pending_name_for_next_doc ~= "" then
			auto_doc_opts.filename = pending_name_for_next_doc
			pending_name_for_next_doc = nil
		end

		local startup_doc = Doc.create(nil, auto_doc_opts)
		add_wait_target(startup_doc)
		core.workspace_view:open_doc(startup_doc)
		changed = true
	end

	if activate_pane ~= nil or activate_tab ~= nil then
		local saved_activate_pane = core.pending_activate_pane
		local saved_activate_tab = core.pending_activate_tab

		core.pending_activate_pane = activate_pane
		core.pending_activate_tab = activate_tab

		if core.pending_activate_pane and core.pending_activate_tab then
			command.perform("core:activate-pane-tab")
		elseif core.pending_activate_pane then
			command.perform("core:activate-pane")
		elseif core.pending_activate_tab then
			command.perform("core:activate-tab")
		end

		core.pending_activate_pane = saved_activate_pane
		core.pending_activate_tab = saved_activate_tab
	end

	if focus_window then
		system.raise_window()
		changed = true
	end

	if changed then
		core.redraw = true
	end

	local dev_details = require("core.dev.details")
	if opts.report_details ~= false and dev_details.is_enabled() then
		local lines = cli.describe_request(parsed)
		lines[#lines + 1] = "Application:"
		lines[#lines + 1] = "Changed session: " .. describe_value(changed)
		lines[#lines + 1] = string.format("Open documents: %d", #(core.docs or {}))
		lines[#lines + 1] = string.format("Time: %.1f ms", (system.get_time() - request_start_time) * 1000)
		dev_details.report("Command-line request", lines)
	end

	return changed
end

--- Track one local or forwarded request until all its documents close
-- @param core table core module
-- @param request_id integer|nil forwarded request identifier
-- @param targets table documents opened or reused by the request
-- @param quit_when_done boolean close this primary process after completion
function cli.track_wait_request(core, request_id, targets, quit_when_done)
	core.cli_wait_requests = core.cli_wait_requests or {}
	core.cli_wait_requests[#core.cli_wait_requests + 1] = {
		request_id = request_id,
		targets = targets or {},
		quit_when_done = quit_when_done == true,
	}
end

--- Complete requests whose exact document objects are no longer open
-- @param core table core module
-- @return boolean quit whether the primary launcher may now close
function cli.update_wait_requests(core)
	local requests = core.cli_wait_requests or {}
	if #requests == 0 then
		return false
	end

	local open_docs = {}
	for _, doc in ipairs(core.docs or {}) do
		open_docs[doc] = true
	end

	local quit = false
	for index = #requests, 1, -1 do
		local request = requests[index]
		local waiting = false
		for _, doc in ipairs(request.targets) do
			if open_docs[doc] then
				waiting = true
				break
			end
		end

		if not waiting then
			-- Keep live launchers pending when native signaling fails
			local completed = true
			if request.request_id then
				completed = system.complete_forwarded_cli(request.request_id)
			end

			if completed then
				quit = request.quit_when_done or quit
				table.remove(requests, index)
			end
		end
	end

	return quit
end

return cli
