-- lx-bundle:true

--- Core runtime bootstrap, document flow, and main loop helpers
-- @module core

require("core.strict")

-- Declare shared runtime helpers before strict global checks begin
global {
	DEVELOPER_MODE = false,
}

local common = require("core.common")
local config = require("core.config")
local open_handler = require("core.open_handler")
local open_mode = require("core.open_mode")
local fileops = require("core.file.ops") -- Share temp-file session helpers outside bootstrap
local loc = require("core.i18n.loc")
local plugin = require("core.plugin") -- Share canonical plugin discovery and nested entry fallback
local project = require("core.project") -- Share project scanning and project module loading outside bootstrap
local core_debug = require("core.debug") -- Centralize debug config defaults and debug runtime plumbing
local fonts = require("core.style.fonts") -- Centralize fallback font selection and style font rebuilds
local fontcache = require("core.style.fontcache") -- Lua-side font load dedupe for repeated path/size requests

core_debug.apply_config_defaults(config) -- Apply debug config switches in one place
core_debug.apply_font_load_timing(config) -- Apply initial font-load timing before style font loading
fontcache.install() -- Install cached renderer.font.load before style builds initial fonts

--[[
	Configure font renderer feature flags from config
]]

--- Apply configured font renderer feature flags
local function apply_font_renderer_config()
	renderer.font.allow_font_bitmap_glyphs(config.allow_font_bitmap_glyphs == true)
	renderer.font.allow_font_svg_glyphs(config.allow_font_svg_glyphs == true)

	if renderer.font.set_quality then
		renderer.font.set_quality(config.font_quality or {})
	end
end

--- Apply configured renderer presentation options
local function apply_renderer_config()
	renderer.set_vsync(config.vsync == true)
end

apply_font_renderer_config()
apply_renderer_config()

local style = require("core.style")
local fps_overlay = require("core.ui.fps_overlay") -- Keep the workspace FPS display independent from plugins

local hook = require("core.hook")
local command
local keymap
local cli
local format_registry
local WorkspaceView
local StatusView
local CommandView
local Doc
local DocView
local TextEditor
local TextViewer
local BinaryViewer
local ImageViewer

--[[
Runtime baseline for this project (verified with 'core:lua-info'):
- '_VERSION': Lua 5.3+
- JIT: unavailable
- Native bitwise syntax ('>>', '&'): available

Implications:
- Shared runtime code may use native integer and bitwise operators.
- Do not require compatibility bit libraries in core modules.
]]

local core = {}
local module_reload_start_time = os.time()
local DEFAULT_FRAME_RATE = 60
local MIN_FRAME_RATE = 1
local MAX_FRAME_RATE = 1000
core.hook = hook
core.loc = loc
-- Keep current native mode available without repeated system queries
core.window_mode = system.get_window_mode()
core.line_change_pending = setmetatable({}, { __mode = "k" })
core.project_scan_enabled = false
core.project_scan_force = false
core.project_scan_reasons = {}
local automatic_update_coroutine = {}

-- Expose core.debug helpers for command layer reuse
core.debug = core_debug

-- Plugin capability registry for command 'requires' tokens
core.capabilities = {
	_registry = {},
}

--- Return a finite target frame rate for timing calculations
-- @return number frame_rate validated frames per second
function core.get_target_frame_rate()
	local frame_rate = config.fps

	if type(frame_rate) ~= "number"
		or frame_rate ~= frame_rate
		or frame_rate == math.huge
		or frame_rate == -math.huge
		or frame_rate <= 0
	then
		return DEFAULT_FRAME_RATE
	end

	return common.clamp(frame_rate, MIN_FRAME_RATE, MAX_FRAME_RATE)
end

--[[
	Manage cross-plugin capability registration and lookup
]]

--- Register a named capability API table
-- @param name string capability name
-- @param api table capability table
-- @return boolean registered
function core.capabilities.provide(name, api)
	if type(name) ~= "string" or name == "" then
		return false
	end

	if type(api) ~= "table" then
		return false
	end

	core.capabilities._registry[name] = api

	return true
end

--- Return a previously registered capability table
-- @param name string capability name
-- @return table|nil api
function core.capabilities.get(name)
	if type(name) ~= "string" or name == "" then
		return nil
	end

	return core.capabilities._registry[name]
end

--- Check whether a capability exists
-- @param name string capability name
-- @return boolean exists
function core.capabilities.has(name)
	return core.capabilities.get(name) ~= nil
end

--- Remove a registered capability
-- @param name string capability name
-- @return boolean removed
function core.capabilities.revoke(name)
	if type(name) ~= "string" or name == "" then
		return false
	end

	if core.capabilities._registry[name] == nil then
		return false
	end

	core.capabilities._registry[name] = nil

	return true
end

core_debug.attach(core) -- Initialize debug runtime state tracking fields

--[[
	Normalize document options and derived encoding metadata
]]

--- Emit one grouped core hook stage when hook bus is available
-- @param event_name string hook event name
-- @param stage string hook stage name
-- @param payload table|nil event payload
local function emit_core_hook(event_name, stage, payload)
	if core.hook then
		core.hook.emit(event_name, stage, payload)
	end
end

--[[
	Coalesce document line change events before dispatch
]]

--- Queue a debounced line-change notification
-- @param doc table|nil changed document
-- @param line1 number first changed line
-- @param line2 number last changed line
-- @param kind string|nil change kind
-- @param meta table|nil extra metadata
function core.notify_line_change(doc, line1, line2, kind, meta)
	if not doc then
		return
	end

	line1 = math.max(1, math.floor(tonumber(line1) or 1))
	line2 = math.max(1, math.floor(tonumber(line2) or line1))

	if line2 < line1 then
		line1, line2 = line2, line1
	end

	local now = system.get_time()
	doc.content_size_change_time = now	-- Share this existing edit timestamp with delayed status details
	local delay = tonumber(config.line_change_hook_delay) or 0.12

	if delay < 0 then
		delay = 0
	end

	local change_id = (doc.get_change_id and doc:get_change_id()) or 0
	-- Content revisions stay monotonic while undo history may move in either direction
	doc._text_revision = (doc._text_revision or 0) + 1
	local text_revision = doc._text_revision

	local p = core.line_change_pending[doc]

	if not p then
		p = {
			doc = doc,
			line1 = line1,
			line2 = line2,
			kinds = {},
			meta = {},
			change_id = change_id,
			first_text_revision = text_revision,
			text_revision = text_revision,
			time = now,
			due = now + delay,
		}
		core.line_change_pending[doc] = p
	else
		p.line1 = math.min(p.line1, line1)
		p.line2 = math.max(p.line2, line2)
		p.change_id = change_id
		p.text_revision = text_revision
		p.time = now
		p.due = now + delay
	end

	if kind then
		p.kinds[kind] = true
	end

	if type(meta) == "table" then
		for k, v in pairs(meta) do
			p.meta[k] = v
		end
	end
end

--- Dispatch pending debounced line-change notifications
function core.dispatch_line_change_hook()
	local now = system.get_time()

	for doc, p in pairs(core.line_change_pending) do
		if p.due <= now then
			core.line_change_pending[doc] = nil
			local kinds = {}

			for k in pairs(p.kinds) do
				kinds[#kinds + 1] = k
			end

			table.sort(kinds)
			local event = {
				doc = p.doc,
				line1 = p.line1,
				line2 = p.line2,
				kinds = kinds,
				change_id = p.change_id,
				first_text_revision = p.first_text_revision,
				text_revision = p.text_revision,
				time = p.time,
				meta = p.meta,
			}
			emit_core_hook("core.line_change", "dispatch", event)
		end
	end
end

--[[
	Initialize runtime state, services, and startup documents
]]

--- Report the complete startup request, results, and stage timings to developers
-- @param startup_cli table parsed startup command line
-- @param timings table ordered startup stage timings
-- @param results table startup results
-- @param start_time number complete startup start time
local function report_startup_details(startup_cli, timings, results, start_time)
	local dev_details = require("core.dev.details")
	if not dev_details.is_enabled() then
		return
	end

	local lines = cli.describe_request(startup_cli)
	lines[#lines + 1] = "Startup controls:"
	lines[#lines + 1] = "Developer build: " .. (DEVELOPER_MODE and "yes" or "no")
	lines[#lines + 1] = "No format cache: " .. (core.pending_no_cache and "yes" or "no")
	lines[#lines + 1] = "Update format cache: " .. (core.pending_update_cache and "yes" or "no")
	lines[#lines + 1] = "Gridlock: " .. (core.pending_gridlock and "yes" or "no")
	lines[#lines + 1] = "Run tests: " .. (core.pending_run_tests and "yes" or "no")
	lines[#lines + 1] = "Named test: " .. tostring(core.pending_run_test or "none")
	lines[#lines + 1] = "Tests disabled: " .. (core.disable_tests and "yes" or "no")
	lines[#lines + 1] = "Exit after startup: " .. (core.pending_exit and "yes" or "no")
	lines[#lines + 1] = "Diff mode: " .. (core.pending_diff_mode and "yes" or "no")
	lines[#lines + 1] = "Activate pane: " .. tostring(core.pending_activate_pane or "none")
	lines[#lines + 1] = "Activate tab: " .. tostring(core.pending_activate_tab or "none")
	if core.pending_diff_files then
		lines[#lines + 1] = "Diff left: " .. tostring(core.pending_diff_files[1] or "none")
		lines[#lines + 1] = "Diff right: " .. tostring(core.pending_diff_files[2] or "none")
	end
	lines[#lines + 1] = "Results:"
	lines[#lines + 1] = "Format registry: " .. (results.format_registry and "failed" or "ready")
	lines[#lines + 1] = "Plugins: " .. (results.plugins and "failed" or "ready")
	lines[#lines + 1] = "User settings: " .. (results.user and "failed" or "ready")
	lines[#lines + 1] = "Project settings: " .. (results.project and "failed" or "ready")
	lines[#lines + 1] = "Format extensions: " .. (results.format_extensions and "failed" or "ready")
	lines[#lines + 1] = "Tests: " .. (core.disable_tests and "skipped" or (results.tests and "passed" or "failed"))
	lines[#lines + 1] = string.format("Open documents: %d", #core.docs)
	lines[#lines + 1] = string.format("Wait targets: %d", results.wait_targets)
	lines[#lines + 1] = "Timings:"
	for index = 1, #timings do
		local timing = timings[index]
		lines[#lines + 1] = string.format("%s: %.1f ms", timing.name, timing.duration_ms)
	end
	lines[#lines + 1] = string.format("Startup total: %.1f ms", (system.get_time() - start_time) * 1000)
	dev_details.report("Startup", lines)
end

--- Initialize core state, services, and startup documents
function core.init()
	local startup_start_time = system.get_time()
	local startup_stage_time = startup_start_time
	local startup_timings = {}

	--- Finish one startup stage and begin timing the next one
	-- @param name string friendly stage name
	local function finish_startup_stage(name)
		local now = system.get_time()
		startup_timings[#startup_timings + 1] = {
			name = name,
			duration_ms = (now - startup_stage_time) * 1000,
		}
		startup_stage_time = now
	end

	loc.register_translations("core", require("core.i18n.translations"))
	loc.set_locale(config.locale)

	-- The optional unpackaged module identifies a local developer build
	local dev_path = package.searchpath("core.dev.local", package.path)
	DEVELOPER_MODE = dev_path ~= nil
	if DEVELOPER_MODE then
		require("core.dev.local")
	end

	command = require("core.command")
	keymap = require("core.keymap")
	cli = require("core.cli")
	format_registry = require("core.format")
	WorkspaceView = require("core.workspace.view")
	StatusView = require("core.status.view")
	CommandView = require("core.command.view")
	Doc = require("core.doc")
	DocView = require("core.doc.view")
	TextEditor = require("core.text.editor")
	TextViewer = require("core.text.viewer")
	BinaryViewer = require("core.binary.viewer")
	ImageViewer = require("core.image.viewer")
	core.zoom = require("core.ui.zoom")
	core.zoom.init()

	-- Core handlers stay below plugin priority and are chosen explicitly or as final fallbacks
	open_handler.register({
		name = "bytes",
		priority = -100,
		automatic = false,
		raw = true,
		kinds = { "any" },
		modes = { open_mode.view },
		create_view = function(request)
			local view_options = {}

			for name, value in pairs(request.options or {}) do
				view_options[name] = value
			end

			view_options.open_handler = "bytes"

			return BinaryViewer(request.doc, view_options)
		end,
	})

	open_handler.register({
		name = "hex",
		priority = -100,
		automatic = false,
		raw = true,
		kinds = { "any" },
		modes = { open_mode.view },
		create_view = function(request)
			local view_options = {}

			for name, value in pairs(request.options or {}) do
				view_options[name] = value
			end

			view_options.open_handler = "hex"

			return BinaryViewer(request.doc, view_options)
		end,
	})

	open_handler.register({
		name = "image",
		priority = -100,
		automatic = false,
		formats = { "any" },
		kinds = { "image" },
		modes = { open_mode.view, open_mode.preview },
		create_view = function(request)
			return ImageViewer(request.doc)
		end,
	})

	-- Create doc UIs through a core-owned factory
	-- Every document uses one explicit, format, kind, and global open-mode resolver
	-- Binary placeholder docs without one selected handler use BinaryViewer
	-- Image docs always use ImageViewer
	--- Create the correct view type for one document
	-- @param doc table document instance
	-- @param opts table|nil view construction options
	-- @return table view
	core.create_doc_view = function(doc, opts)
		opts = opts or {}
		local requested_mode = open_mode.check(opts.open_mode, 2)
		local resolved_mode = open_mode.resolve(doc, requested_mode)
		local requested_handler = opts.open_handler or (doc and doc.action_default_handler)

		if requested_handler and not open_handler.get(requested_handler) then
			error("unknown open handler " .. tostring(requested_handler), 2)
		end

		local handler_view, handler_error, selected_handler = open_handler.create_view({
			doc = doc,
			format = doc and doc.format or nil,
			kind = doc and doc.kind or nil,
			mode = resolved_mode,
			action = opts.open_action,
			handler = requested_handler,
			options = opts,
		})

		if handler_error then
			error(string.format(
				'Open handler "%s" failed: %s',
				tostring(selected_handler and selected_handler.name or requested_handler or "unknown"),
				tostring(handler_error)
			), 2)
		end
		if handler_view then
			return handler_view
		end

		if resolved_mode == open_mode.preview then
			-- A missing preview handler must stay read-only
			resolved_mode = open_mode.view
		end

		if doc and doc.is_image == true then
			return ImageViewer(doc)
		end

		if doc and doc.is_binary_placeholder then
			local binary_options = {}
			for name, value in pairs(opts) do
				binary_options[name] = value
			end

			return BinaryViewer(doc, binary_options)
		end

		if resolved_mode == open_mode.view then
			return TextViewer(doc, opts)
		end
		return TextEditor(doc, opts)
	end
	finish_startup_stage("Core setup")

	local startup_cli = cli.parse_startup_cli_args(
		core,
		nil
	)
	local cli_option_errors = startup_cli.cli_option_errors
	local format_cache_path = CACHE_DIR .. "formats$.lua"

	system.chdir(startup_cli.project_dir)
	finish_startup_stage("Command line")

	core.frame_start = 0
	core.clip_rect_stack = { { 0, 0, 0, 0 } }
	core.log_items = {}
	core.docs = {}
	core.cli_wait_requests = {} -- Request-owned documents that keep '--wait' launchers blocked
	core.coroutines = setmetatable({}, { __mode = "k" })
	core.log_item_next_id = 0 -- Monotonic log item ID generator for stable targeting
	core.startup_error_log_id = nil -- Exact startup error ID captured at creation time
	core.redraw = true
	core.pending_quit_if_no_docs = false
	project.init_state()
	fps_overlay.start(core) -- Start after coroutine storage exists and before user settings can enable the overlay

	local workspace_shell = WorkspaceView.build_default_shell()
	core.workspace_view = workspace_shell.workspace_view
	core.command_view = workspace_shell.command_view
	core.status_view = workspace_shell.status_view
	core.browser = require("core.browser")
	core.browser.init() -- Dock the built-in Browser before commands and layered settings load

	-- Start integrated doc monitor service after core docs/coroutines state is initialized
	require("core.doc.monitor").start()

	project.add_scan_coroutine()
	finish_startup_stage("Workspace")

	-- Capture cache file state before format registry init
	local format_cache_before = system.get_file_info(format_cache_path)

	-- Build/load the format registry cache while format modules remain lazy
	-- Tags errors emitted in this startup phase as 'startup_error'
	core._startup_error_source = "format"

	local had_format_registry_error = not core.try(function()
		format_registry.init_registry({
			no_cache = core.pending_no_cache,
			force_update = core.pending_update_cache and not core.pending_no_cache,
		})
	end)
	core._startup_error_source = nil

	-- Capture cache file state after format registry init
	local cache_after = system.get_file_info(format_cache_path)
	if core.pending_no_cache then
		local before_s = format_cache_before and (format_cache_before.modified or "exists") or "missing"
		local after_s = cache_after and (cache_after.modified or "exists") or "missing"

		core.log_quiet(
			"no-cache cachefile state: before=%s after=%s path=%s",
			tostring(before_s),
			tostring(after_s),
			format_cache_path
		)
		print(
			string.format(
				"[Debug] no-cache cachefile state; before: %s, after: %s, path: %s",
				tostring(before_s),
				tostring(after_s),
				format_cache_path
			)
		)
	end
	finish_startup_stage("Formats")
	command.add_defaults()
	finish_startup_stage("Commands")
	core._startup_error_source = "plugins"

	local had_plugin_error = not core.load_plugins()
	core._startup_error_source = nil
	finish_startup_stage("Plugins")

	core._startup_error_source = "user"
	local had_user_error = not core.try(require, "user")
	core._startup_error_source = nil
	loc.set_locale(config.locale)

	if not had_user_error then
		apply_renderer_config() -- Apply presentation settings after the user-selected layers have loaded
	end

	if not had_user_error and fonts and type(fonts.reload_if_needed) == "function" then
		apply_font_renderer_config() -- Apply user-configured font backend knobs before rebuilding style fonts
		fonts.reload_if_needed(style) -- Apply the font selection from the loaded settings layers
	end
	finish_startup_stage("User settings and presentation")

	core._startup_error_source = "project"
	local had_project_error = not project.load_module()
	core._startup_error_source = nil
	finish_startup_stage("Project settings")

	-- Validate plugin and project format extensions after every startup extension source has loaded
	core._startup_error_source = "format"
	local had_format_extension_error = not core.try(function()
		format_registry.validate_extensions()
		open_mode.refresh_config()
		open_handler.refresh_config()
	end)
	core._startup_error_source = nil
	finish_startup_stage("Format extensions")

	-- Run one startup audit pass for pattern.syntax string migration hints
	if core._subsyntax_ref_audit_done ~= true then
		core._subsyntax_ref_audit_done = true
		core._startup_error_source = "syntax"
		core.try(function()
			local tokenizer = require("core.format.tokenizer")
			tokenizer.audit_subsyntax_refs({
				format_registry = format_registry,
				core = core,
				include_registry = true,
				log_summary = true,
				details_limit = 12,
			})
		end)
		core._startup_error_source = nil
	end
	finish_startup_stage("Syntax audit")

	local startup_wait_targets = startup_cli.wait_requested and {} or nil
	cli.apply_request(core, startup_cli, {
		auto_create_doc = config.new_doc_auto_create_on_startup == true and #core.docs == 0,
		provisional_single_file = true,
		wait_targets = startup_wait_targets,
		report_details = false,
	})
	if startup_wait_targets then
		-- The primary launcher exits through the normal protected quit path when its targets close
		cli.track_wait_request(core, nil, startup_wait_targets, true)
	end
	finish_startup_stage("Startup documents")

	if #cli_option_errors > 0
		or had_format_registry_error
		or had_plugin_error
		or had_user_error
		or had_project_error
		or had_format_extension_error
	then
		command.perform("log:open-last-error")
	end

	-- Activate requested tab/pane after startup
	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

	-- Apply gridlock after startup
	if core.pending_gridlock then
		command.perform("core:set-gridlock")
	end
	finish_startup_stage("Startup actions")

	-- Auto-run tests by config, or force all or one named unit from the CLI
	local run_tests_ok = true

	if core.disable_tests ~= true then
		run_tests_ok = core.debug.run_tests(core, config, {
			force = core.pending_run_tests == true,
			cli = core.pending_run_tests == true,
			unit = core.pending_run_test,
		})
	end

	core.pending_exit_code = run_tests_ok and 0 or 1
	finish_startup_stage("Tests")

	-- Deleting the optional updater disables all automatic update work
	if package.searchpath("core.update", package.path) then
		-- pending.json remains until the restarted installed Lx removes update work
		local pending_info_file = system.join_path(
			system.join_path(USER_DIR, "update"),
			"pending.json"
		)
		if system.is_file(pending_info_file) then
			require("core.update").cleanup()
		end

		-- Finish a pending update now, otherwise keep update checks outside the startup grace period
		if not system.is_file(pending_info_file)
			and not core.pending_exit
			and core.pending_run_tests ~= true
			and core.pending_run_test == nil
		then
			local pending_update = system.join_path(
				system.join_path(USER_DIR, "update"),
				"pending.zip"
			)
			local prepared_update = system.join_path(
				system.join_path(system.join_path(USER_DIR, "update"), "pending"),
				"manifest.json"
			)
			if system.is_file(pending_update) or system.is_file(prepared_update) then
				require("core.update").stage_pending(true)
			else
				local update_check_delay = math.max(0, tonumber(config.update_check_delay) or 10)
				core.add_coroutine(function()
					coroutine.yield(update_check_delay)
					require("core.update").check()
				end, automatic_update_coroutine)
			end
		end
	end
	finish_startup_stage("Updates")

	report_startup_details(startup_cli, startup_timings, {
		format_registry = had_format_registry_error,
		plugins = had_plugin_error,
		user = had_user_error,
		project = had_project_error,
		format_extensions = had_format_extension_error,
		tests = run_tests_ok,
		wait_targets = startup_wait_targets and #startup_wait_targets or 0,
	}, startup_start_time)
end

--- Apply one forwarded CLI argv list to the running session
-- @param args table argv list
-- @param working_directory string|nil directory inherited by the forwarding process
-- @param wait_targets table|nil request-owned target list that survives open errors
-- @return boolean changed
function core.open_cli_args(args, working_directory, wait_targets)
	if type(args) ~= "table" then
		return false
	end

	local saved_activate_pane = core.pending_activate_pane
	local saved_activate_tab = core.pending_activate_tab

	core.pending_activate_pane = nil
	core.pending_activate_tab = nil

	local parsed = cli.parse_startup_cli_args(
		core,
		args,
		{
			allow_runtime_control_options = false,
			allow_project_dir_change = false,
			allow_diff_mode = false,
			working_directory = working_directory,
		}
	)

	local runtime_activate_pane = core.pending_activate_pane
	local runtime_activate_tab = core.pending_activate_tab

	core.pending_activate_pane = saved_activate_pane
	core.pending_activate_tab = saved_activate_tab

	local changed = cli.apply_request(core, parsed, {
		focus_window = true,
		activate_pane = runtime_activate_pane,
		activate_tab = runtime_activate_tab,
		wait_targets = wait_targets,
	})

	return changed
end

--[[
	Manage temporary files, plugin loading, and active view state
]]

--- Ask before closing Lx when documents contain unsaved changes
-- @return boolean allowed
local function confirm_quit()
	local dirty_count = 0
	local dirty_name

	for _, doc in ipairs(core.docs) do
		if doc:is_dirty() then
			dirty_count = dirty_count + 1
			dirty_name = doc:get_name()
		end
	end

	if dirty_count > 0 then
		local text

		if dirty_count == 1 then
			text = string.format('"%s" has unsaved changes. Quit anyway?', dirty_name)
		else
			text = string.format("%d docs have unsaved changes. Quit anyway?", dirty_count)
		end

		local confirm = system.show_confirm_dialog("Unsaved Changes", text)

		if not confirm then
			return false
		end
	end

	return true
end

--- Attempt graceful shutdown and prompt for unsaved documents
-- @param force boolean|nil exit immediately when true
function core.quit(force)
	if not force then
		-- One title-bar click can queue both native quit event forms
		if core.quit_prompted_this_step then
			return
		end
		core.quit_prompted_this_step = true

		if not confirm_quit() then
			return
		end
	end

	fileops.delete_temp_files()
	os.exit()
end

--- Restart Lx with the current command line and optional extra arguments
-- @param extra_arguments table|nil arguments appended to the current command line
-- @param force boolean|nil skip unsaved-document checks when true
-- @return boolean started
function core.restart(extra_arguments, force)
	-- Confirm before launching so cancelling cannot leave an unwanted second process
	if not force and not confirm_quit() then
		return false
	end

	local arguments = { "--new-instance" }
	for index = 2, #(ARGS or {}) do
		arguments[#arguments + 1] = ARGS[index]
	end
	for _, argument in ipairs(extra_arguments or {}) do
		arguments[#arguments + 1] = argument
	end
	local launched, launch_error = system.spawn(EXE_FILE, arguments)
	if not launched then
		core.error("Could not restart Lx: %s", tostring(launch_error))

		return false
	end

	core.quit(true)

	return true
end

--- Load plugins and tag formats registered by each module
-- @return boolean no_errors
function core.load_plugins()
	local no_errors = true
	local format_registry = require("core.format")
	local modules = {}
	local bundled_plugins = rawget(_G, "BUNDLED_PLUGINS")
	local plugins_disabled = os.getenv("LX_BENCH_DISABLE_PLUGINS") == "1"

	-- Performance comparisons can start with no plugins without renaming live source folders
	if plugins_disabled then
		core.log_quiet("Plugin loading disabled by LX_BENCH_DISABLE_PLUGINS")
	elseif type(bundled_plugins) == "table" and #bundled_plugins > 0 then
		for _, modname in ipairs(bundled_plugins) do
			if type(modname) == "string" then
				table.insert(modules, modname)
			end
		end
	else
		-- Keep canonical plugin ids stable across flat and nested self-name layouts
		modules = plugin.get_modules(PLUGINS_DIR, system, function(name, flat_path, nested_path)
			core.warning(
				'Plugin module "%s" has both flat and nested entries; using flat "%s", ignoring "%s"',
				tostring(name),
				tostring(flat_path),
				tostring(nested_path)
			)
		end)
	end

	local t0_all = system.get_time()
	local loaded_count = 0
	local failed_count = 0

	for _, modname in ipairs(modules) do
		local before = #format_registry.items
		local t0 = system.get_time()
		local translation_owner = nil
		local ok = core.try(function()
			local translations_module = plugin.get_translations_module(modname, PLUGINS_DIR, system)
			if translations_module then
				local translations = require(translations_module)
				translation_owner = plugin.get_translation_owner(modname)
				loc.register_translations(translation_owner, translations)
			end

			-- Supply plugin ownership once so extensions do not repeat their own module id
			local loaded, err = format_registry.with_extension_owner(
				modname,
				plugin.require_module,
				modname,
				PLUGINS_DIR
			)
			if not loaded then
				error(err)
			end
		end)
		local dt_ms = (system.get_time() - t0) * 1000

		if ok then
			-- Tag formats registered by this plugin with their stable module id
			for i = before + 1, #format_registry.items do
				format_registry.items[i]._module_name = modname
			end
			core.log_quiet("Loaded plugin %q in %.1fms", modname, dt_ms)
			loaded_count = loaded_count + 1
		else
			if translation_owner then
				loc.unregister_translations(translation_owner)
			end
			no_errors = false
			failed_count = failed_count + 1
		end
	end

	local total_ms = (system.get_time() - t0_all) * 1000
	core.log_quiet_level(
		"success",
		"Loaded %d plugins in %.1fms (%d ok, %d fail)",
		loaded_count + failed_count,
		total_ms,
		loaded_count,
		failed_count
	)

	return no_errors
end

--- Reload a module and update existing table references in place
-- @param name string module name
function core.reload_module(name)
	-- Keep ordinary module replacement unchanged inside the optional keymap transaction
	local function reload()
		local old = package.loaded[name]
		package.loaded[name] = nil
		local loaded, new = pcall(require, name)

		-- A failed reload keeps the working module instead of leaving an empty cache entry
		if not loaded then
			package.loaded[name] = old
			error(new, 0)
		end

		if type(old) == "table" then
			if type(new) ~= "table" then
				package.loaded[name] = old
				error(string.format(
					"Cannot reload table module %q as %s; restart Lx",
					name,
					type(new)
				), 0)
			end

			for key in pairs(old) do
				if new[key] == nil then
					old[key] = nil
				end
			end
			for k, v in pairs(new) do
				old[k] = v
			end

			package.loaded[name] = old
		end
	end

	-- User settings describe the complete user layer, so omitted shortcuts must disappear on reload
	if name == "user" then
		keymap.replace_layer("user", reload)
	else
		reload()
	end

	-- Saved user settings can replace every direct open lookup map
	if name == "user" then
		require("core.format").refresh_overrides()
		require("core.open_mode").refresh_config()
		require("core.open_handler").refresh_config()
	end
end

--- Reload currently loaded Lua modules changed since this Lx process started
-- @return number reloaded number of successful reloads
-- @return table failures module names and their errors
function core.reload_changed_modules()
	local names = {}
	local failures = {}
	local reloaded = 0

	-- Only changed source files reload; runtime and untouched plugin state stays alive
	for name in pairs(package.loaded) do
		local path = name ~= "core" and package.searchpath(name, package.path)
		local info = path and system.get_file_info(path)

		if info and type(info.modified) == "number"
			and info.modified >= module_reload_start_time
		then
			names[#names + 1] = name
		end
	end

	-- Nested dependencies update before the modules that keep references to them
	table.sort(names, function(left, right)
		return #left > #right
	end)

	for _, name in ipairs(names) do
		local loaded, reload_error = pcall(core.reload_module, name)

		if loaded then
			reloaded = reloaded + 1
		else
			failures[#failures + 1] = { name = name, error = reload_error }
		end
	end

	return reloaded, failures
end

--- Change the active view and preserve pane-local selection state
-- @param view table view instance
function core.set_active_view(view)
	assert(view, "Tried to set active view to nil")

	if view ~= core.active_view then
		-- Keep cursor/selection state per pane when the view exposes selection
		local prev = core.active_view

		if core.view_can_select(prev) and prev.doc then
			if prev.doc.get_selections and prev.doc.set_selections then
				prev._view_selections, prev._view_primary_selection_index = prev.doc:get_selections()
			else
				prev._view_selection = { prev.doc:get_selection() }
			end
		end

		core.last_active_view = prev
		core.active_view = view

		if core.view_can_select(view) and view.doc then
			if view.doc.get_selections and view.doc.set_selections then
				if view._view_selections then
					view.doc:set_selections(
						view._view_selections,
						view._view_primary_selection_index
					)
				else
					-- Initialize pane-local snapshots from the complete selection collection
					view._view_selections, view._view_primary_selection_index = view.doc:get_selections()
				end
			elseif view._view_selection then
				view.doc:set_selection(table.unpack(view._view_selection))
			else
				-- Initialize pane-local snapshot from current document selection
				view._view_selection = { view.doc:get_selection() }
			end
		end
		-- Refresh OS cursor immediately after view switch, even without mouse movement
		if core.workspace_view and core.workspace_view.sync_mouse_cursor then
			core.workspace_view:sync_mouse_cursor()
		end
	end
end

--- Return the active view
-- @return table|nil view
function core.get_active_view()
	return core.active_view
end

--- Check whether a view has a selectable position model
-- @param view table|nil view instance
-- @return boolean can_select
function core.view_can_select(view)
	view = view or core.active_view

	if not (view and view.doc) then
		return false
	end

	if view.can_select ~= nil then
		return view.can_select == true
	end

	return true
end

--- Check whether a view allows in-place document edits
-- @param view table|nil view instance
-- @return boolean can_edit
function core.view_can_edit(view)
	view = view or core.active_view

	if not (view and view.doc) then
		return false
	end

	if view.can_edit ~= nil then
		return view.can_edit == true
	end

	return true
end

--- Check whether a view exposes a cursor model
-- @param view table|nil view instance
-- @return boolean has_cursor
function core.view_has_cursor(view)
	view = view or core.active_view

	if not (view and view.doc) then
		return false
	end

	if view.has_cursor_model ~= nil then
		return view.has_cursor_model == true
	end

	return true
end

--- Register a coroutine task with an optional weak-reference key
-- @param f function coroutine entry
-- @param weak_ref any|nil weak-reference key
function core.add_coroutine(f, weak_ref)
	local key = weak_ref or #core.coroutines + 1

	--- Wrap one coroutine entry with protected execution
	local fn = function()
		return core.try(f)
	end

	core.coroutines[key] = { cr = coroutine.create(fn), wake = 0 }

	return key
end

--- Cancel one registered coroutine task
-- @param key any key returned by 'core.add_coroutine' or passed as its weak reference
function core.remove_coroutine(key)
	core.coroutines[key] = nil
end

--- Push an intersected clip rectangle onto the renderer clip stack
-- @param x number x position
-- @param y number y position
-- @param w number width
-- @param h number height
function core.push_clip_rect(x, y, w, h)
	local x2, y2, w2, h2 = table.unpack(core.clip_rect_stack[#core.clip_rect_stack])
	local r, b, r2, b2 = x + w, y + h, x2 + w2, y2 + h2
	x, y = math.max(x, x2), math.max(y, y2)
	b, r = math.min(b, b2), math.min(r, r2)
	w, h = r - x, b - y
	table.insert(core.clip_rect_stack, { x, y, w, h })
	renderer.set_clip_rect(x, y, w, h)
end

--- Pop the clip stack and restore the previous renderer clip rectangle
function core.pop_clip_rect()
	table.remove(core.clip_rect_stack)
	local x, y, w, h = table.unpack(core.clip_rect_stack[#core.clip_rect_stack])
	renderer.set_clip_rect(x, y, w, h)
end

--- Return all views currently displaying the given document
-- @param doc table document instance
-- @return table views
function core.get_views_referencing_doc(doc)
	local res = {}
	local views = core.workspace_view.root_node:get_children()

	for _, view in ipairs(views) do
		if view.doc == doc then
			table.insert(res, view)
		end
	end

	return res
end

--[[
	Format log locations and emit runtime log entries
]]

--- Format a path for log output relative to the executable directory
-- @param path string|nil source path
-- @return string path
function core.to_log_path(path)
	if type(path) ~= "string" or path == "" then
		return "?"
	end

	local root = EXE_DIR or ""

	if root ~= "" and path:sub(1, #root) == root then
		-- Pattern breakdown:
		--   ^[\\/]+ strips one or more leading slashes or backslashes after removing EXE_DIR
		return path:sub(#root + 1):gsub("^[\\/]+", ""):gsub("\\", "/")
	end

	return path:gsub("\\", "/")
end

--- Append one runtime error to the user error log
-- @param line string
local function append_error_log_line(line)
	local path = system.join_path(USER_DIR, "error.log")
	local ok, fp = pcall(io.open, path, "ab")

	if not ok or not fp then
		return
	end

	local text = tostring(line or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
	fp:write(text, "\n")
	fp:close()
end

--- Format one log location string from source path and line number
-- @param path string|nil source path
-- @param line number|nil source line
-- @return string location
local function format_log_location(path, line)
	local source_path = core.to_log_path(type(path) == "string" and path or "?")
	local source_line = type(line) == "number" and line or 0
	return string.format("%s:%d", source_path, source_line)
end

--- Extract the first concrete file frame from a traceback string
-- @param traceback_text string|nil traceback text
-- @return string|nil location
local function get_traceback_location(traceback_text)
	if type(traceback_text) ~= "string" or traceback_text == "" then
		return nil
	end

	-- Pattern breakdown:
	--   [^\r\n]+ yields one traceback line at a time without newline characters
	for line in traceback_text:gmatch("[^\r\n]+") do
		-- Pattern breakdown:
		--   ^%s*(.-):(%d+): captures a trimmed path and its following decimal line number
		local path, line_number = line:match("^%s*(.-):(%d+):")

		if path and path ~= "" and path ~= "[C]" then
			return format_log_location(path, tonumber(line_number))
		end
	end

	return nil
end

--- Resolve one log source location from traceback or live stack info
-- @param stack_level number|nil stack level
-- @param traceback_text string|nil traceback text
-- @return string location
local function get_log_location(stack_level, traceback_text)
	local from_traceback = get_traceback_location(traceback_text)

	if from_traceback then
		return from_traceback
	end

	local info = debug.getinfo(stack_level or 2, "Sl")

	if not info then
		return format_log_location("?", 0)
	end

	return format_log_location(info.short_src, info.currentline)
end

--- Append a formatted log item and optionally show it in the status view
-- @param icon string|nil status icon
-- @param icon_color table|nil status icon color
-- @param level string log level
-- @param opts table|nil logging options
-- @param fmt string format string
-- @return table item
local function log_with_opts(icon, icon_color, level, opts, fmt, ...)
	opts = type(opts) == "table" and opts or {}
	local text = string.format(fmt, ...)

	if icon and core.status_view and core.status_view.show_message then
		-- Guard early-startup logs before status view is initialized
		core.status_view:show_message(icon, icon_color, text)
	end

	local at = opts.at or get_log_location(opts.stack_level or 2, opts.traceback)
	local now = system.get_time()
	local msec = math.floor((now - math.floor(now)) * 1000) -- Capture millisecond component for log timestamp formatting
	core.log_item_next_id = core.log_item_next_id + 1
	local item = {
		id = core.log_item_next_id,
		text = text,
		time = os.time(),
		msec = msec,
		at = at,
		level = level,
	} -- Store log ID/level/timestamp for stable LogView targeting and styling
	table.insert(core.log_items, item)
	if #core.log_items > config.max_log_items then
		table.remove(core.log_items, 1)
	end
	if level == "error" then
		append_error_log_line(string.format("[lx-error] %s (%s)", text, at))
	end

	-- Emit a post-log event without allowing recursive log-hook cascades
	if core._emitting_log_hook ~= true then
		core._emitting_log_hook = true
		hook.emit("core.log", "after", {
			item = item,
			icon = icon,
			icon_color = icon_color,
			level = level,
			text = text,
		})
		core._emitting_log_hook = false
	end

	return item
end

--- Log an informational message and show it in the status view
-- @return table item
function core.log(...)
	local item = log_with_opts("i", style.text, "info", { stack_level = 4 }, ...)
	return item
end

--- Log a warning message and show it in the status view
-- @return table item
function core.warning(...)
	local item = log_with_opts("!", style.warning or style.error or style.text, "warning", { stack_level = 4 }, ...)
	return item
end

--- Log a notice message and show it in the status view
-- @return table item
function core.notice(...)
	local item = log_with_opts("i", style.notice or style.text, "notice", { stack_level = 4 }, ...)
	return item
end

--- Log a success message and show it in the status view
-- @return table item
function core.success(...)
	local item = log_with_opts("i", style.success or style.text, "success", { stack_level = 4 }, ...)
	return item
end

--- Log a message without showing a status popup
-- @return table item
function core.log_quiet(...)
	local item = log_with_opts(nil, nil, "quiet", { stack_level = 4 }, ...)
	return item
end

--- Log a quiet message with an explicit level for log coloring
-- @param level string requested level
-- @param fmt string format string
-- @return table item
function core.log_quiet_level(level, fmt, ...)
	local kind = (level == "error" or level == "warning" or level == "notice" or level == "success" or level == "info")
			and level
		or "quiet"
	local item = log_with_opts(nil, nil, kind, { stack_level = 4 }, fmt, ...)
	return item
end

--- Log an error message and show it in the status view
-- @return table item
function core.error(...)
	local item = log_with_opts("!", style.error, "error", { stack_level = 4 }, ...)
	return item
end

--- Log an error using the first concrete frame from a traceback string
-- @param traceback_text string|nil traceback text
-- @return table item
function core.error_with_trace(traceback_text, ...)
	local item = log_with_opts("!", style.error, "error", {
		stack_level = 4,
		traceback = traceback_text,
	}, ...)

	if type(traceback_text) == "string" and traceback_text ~= "" then
		-- Strip tab indentation from traceback text before attaching it to the log item
		item.info = traceback_text:gsub("\t", "")
	end

	return item
end

--- Execute a function with protected error logging and traceback capture
-- @param fn function target function
-- @return boolean ok
-- @return any result_or_error
function core.try(fn, ...)
	local err

	local ok, res = xpcall(fn, function(msg)
		-- Strip tab indentation so traceback lines stay readable in the log view
		local traceback_text = debug.traceback(nil, 2):gsub("\t", "")
		local item = core.error_with_trace(traceback_text, "%s", msg)

		-- Carry startup-phase source metadata on errors raised during init
		if core._startup_error_source then
			item.startup_error = true
			item.startup_error_source = core._startup_error_source
			core.startup_error_log_id = item.id
		end

		item.info = traceback_text
		err = msg
	end, ...)

	if ok then
		return true, res
	end

	return false, err
end

--[[
	Dispatch events, step frames, and persist fatal errors
]]

--- Dispatch one input or system event to the appropriate core handler
-- @param type string event type
-- @return boolean did_keymap
function core.on_event(type, ...)
	local args = { ... }
	local did_keymap = false

	if type == "windowmode" then
		-- Native mode events update shared state before observers run
		core.window_mode = args[1]
	elseif type == "textinput" then
		core.workspace_view:on_text_input(...)
	elseif type == "textediting" then
		core.workspace_view:on_text_editing(...)
	elseif type == "keydown" then
		did_keymap = keymap.on_key_down(...)
	elseif type == "keyup" then
		keymap.on_key_up(...)
	elseif type == "mousemove" then
		core.workspace_view:on_mouse_move(...)
	elseif type == "mouseleave" then
		core.workspace_view:on_mouse_left(...)
	elseif type == "mousedown" then
		core.workspace_view:on_mouse_down(...)
	elseif type == "mouseup" then
		core.workspace_view:on_mouse_up(...)
	elseif type == "mousewheel" then
		core.workspace_view:on_mouse_wheel(...)
	elseif type == "filedrop" then
		local filename, mx, my = ...
		local info = system.get_file_info(filename)

		if info and info.type == "dir" then
			local launched, launch_error = system.exec(string.format("%q %q", EXE_FILE, filename))

			if not launched then
				core.error("Failed to open folder in a new window: %s", launch_error or "unknown process error")
			end
		else
			local ok, doc = core.try(Doc.open_file, filename, {
				force_binary = false,
			})

			if ok and doc then
				local node = core.workspace_view.root_node:get_child_overlapping_point(mx, my)
				node:set_active_view(node.active_view)
				-- A drop has no chosen mode, so format plugins may ask how to present an ambiguous file
				core.workspace_view:open_doc(doc, { open_origin = "drop" })
			end
		end
	elseif type == "quit" then
		core.quit()
	end

	emit_core_hook("core.event", type, {
		type = type,
		args = args,
		did_keymap = did_keymap,
		source = "core.on_event",
	})
	return did_keymap
end

--- Process events, update UI state, and render one frame when needed
-- @return boolean redrew
function core.step()
	-- A later event pass may ask again after the user cancels the prior close
	core.quit_prompted_this_step = false

	local forwarded_cli_requests = system.poll_forwarded_cli()

	for i = 1, #forwarded_cli_requests do
		local request = forwarded_cli_requests[i]
		local wait_targets = request.request_id and {} or nil
		core.try(
			core.open_cli_args,
			request.args,
			request.working_directory,
			wait_targets
		)
		if request.request_id then
			-- Keep documents opened before a later failure attached to the waiting launcher
			cli.track_wait_request(core, request.request_id, wait_targets, false)
		end
	end

	-- handle events
	local did_keymap = false
	local mouse_moved = false
	local mouse = { x = 0, y = 0, dx = 0, dy = 0 }

	-- Keep the fifth event value used by mouse modifier snapshots
	for type, a, b, c, d, e in system.poll_event do
		if type == "mousemove" then
			mouse_moved = true
			mouse.x, mouse.y = a, b
			mouse.dx, mouse.dy = mouse.dx + c, mouse.dy + d
		elseif type == "textinput" and did_keymap then
			did_keymap = false
		else
			local _, res = core.try(core.on_event, type, a, b, c, d, e)
			did_keymap = res or did_keymap
		end

		core.redraw = true
	end

	if mouse_moved then
		core.try(core.on_event, "mousemove", mouse.x, mouse.y, mouse.dx, mouse.dy)
	end

	core.dispatch_line_change_hook()
	core_debug.step(core, config) -- Run per-frame debug sync and C-log drain

	local width, height = renderer.get_size()

	-- update
	core.workspace_view.size.x, core.workspace_view.size.y = width, height

	core.workspace_view:update()

	if not core.redraw then
		if cli.update_wait_requests(core) then
			core.quit()
		end
		return false
	end

	core.redraw = false

	-- close unreferenced docs
	for i = #core.docs, 1, -1 do
		local doc = core.docs[i]
		if #core.get_views_referencing_doc(doc) == 0 then
			emit_core_hook("doc.close", "before", {
				doc = doc,
				filename = doc.filename,
				source = "core.step",
				reason = "unreferenced",
			})
			table.remove(core.docs, i)
			core.log_quiet('Closed doc "%s"', doc:get_name())

			emit_core_hook("doc.close", "after", {
				doc = doc,
				filename = doc.filename,
				source = "core.step",
				reason = "unreferenced",
			})
		end
	end

	if cli.update_wait_requests(core) then
		core.quit()
	end

	if core.pending_quit_if_no_docs then
		local has_doc = false

		for _, view in ipairs(core.workspace_view.root_node:get_children()) do
			if view and view.doc and not (CommandView and view:is(CommandView)) then
				has_doc = true
				break
			end
		end

		if not has_doc then
			core.quit(true)
		else
			core.pending_quit_if_no_docs = false
		end
	end

	-- Update window title
	local name = core.active_view:get_name()
	local title = "Lx Alpha [" .. BUILD_DATE .. "]"
	-- Keep debug builds unmistakable beside the build date
	if DEBUG then
		title = title .. " (DEBUG)"
	end
	title = (name ~= "---") and (name .. " - " .. title) or title

	if title ~= core.window_title then
		core.window_title = title
		system.set_window_title(title)
	end

	-- Render frame
	renderer.begin_frame()
	core.clip_rect_stack[1] = { 0, 0, width, height }
	renderer.set_clip_rect(table.unpack(core.clip_rect_stack[1]))

	core.workspace_view:draw()
	renderer.end_frame()

	return true
end

--- Run background coroutines within a per-frame time budget
local function coroutine_scheduler_loop()
	while true do
		local max_time = 1 / core.get_target_frame_rate() - 0.004
		local ran_any_coroutines = false

		for k, co in pairs(core.coroutines) do
			-- run coroutine
			if co.wake < system.get_time() then
				local _, wait = assert(coroutine.resume(co.cr))
				if coroutine.status(co.cr) == "dead" then
					if type(k) == "number" then
						table.remove(core.coroutines, k)
					else
						core.coroutines[k] = nil
					end
				elseif wait then
					co.wake = system.get_time() + wait
				end
				ran_any_coroutines = true
			end

			-- stop running coroutines if we're about to hit the end of frame
			if system.get_time() - core.frame_start > max_time then
				coroutine.yield()
			end
		end

		if not ran_any_coroutines then
			coroutine.yield()
		end
	end
end

local run_coroutine_scheduler = coroutine.wrap(coroutine_scheduler_loop)

--- Run the main application loop
function core.run()
	if core.pending_exit and type(core.pending_runtime_test) ~= "table" then
		-- Dedicated non-interactive mode, e.g. '--run-test=name --exit'
		os.exit(core.pending_exit_code or 0)
	end

	local runtime_test = core.pending_runtime_test
	local bench_seconds = tonumber(os.getenv("LX_BENCH_SECONDS") or "")
	local bench_enabled = bench_seconds and bench_seconds > 0
	local bench_start = bench_enabled and system.get_time() or 0
	local bench_frames = 0

	while true do
		core.frame_start = system.get_time()
		local did_redraw = core.step()

		if did_redraw then
			bench_frames = bench_frames + 1
		end

		if runtime_test and did_redraw then
			-- Runtime tests advance only after a frame has actually reached the window
			local ok, finished, passed = pcall(runtime_test.step, runtime_test)

			if not ok then
				print(string.format("[Test] RESULT=FAIL runtime=%s", tostring(finished)))
				core.pending_exit_code = 1
				finished = true
				passed = false
			end

			if finished then
				runtime_test = nil
				core.pending_runtime_test = nil

				if core.pending_exit then
					os.exit(passed == false and 1 or (core.pending_exit_code or 0))
				end
			end
		end

		run_coroutine_scheduler()

		if not did_redraw and not system.window_has_focus() then
			system.wait_event(0.25)
		end

		local elapsed = system.get_time() - core.frame_start
		system.sleep(math.max(0, 1 / core.get_target_frame_rate() - elapsed))

		if bench_enabled then
			local total = system.get_time() - bench_start

			if total >= bench_seconds then
				local fps = bench_frames / math.max(total, 0.001)
				local msg = string.format("LX_BENCH_FPS=%.3f frames=%d seconds=%.3f", fps, bench_frames, total)
				core.log_quiet(msg)
				print(msg)
				return
			end
		end
	end
end

--- Persist crash diagnostics and dirty-file backups after fatal errors
-- @param err any error value
function core.on_error(err)
	-- Keep fatal errors with the user's other writable Lx files
	local fp = io.open(system.join_path(USER_DIR, "error.log"), "wb")

	if fp then
		fp:write("Error: " .. tostring(err) .. "\n")
		fp:write(debug.traceback(nil, 4))
		fp:close()

		-- save copy of all unsaved documents
		for _, doc in ipairs(core.docs) do
			if doc:is_dirty() and doc.filename then
				doc:save(doc.filename .. "~")
			end
		end
	end
end

return core
