-- lx-bundle:true

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

require("core.strict")

local common = require("core.common")
local config = require("core.config")
local fileops = require("core.fileops") -- Share temp-file session helpers outside bootstrap
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_font_renderer_config()

local style = require("core.style")

local hook = require("core.hook")
local command
local keymap
local cli
local WorkspaceView
local StatusView
local CommandView
local Doc
local DocView
local TextEditor
local TextViewer
local AnsiViewer
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 = {}
core.hook = hook
core.line_change_pending = setmetatable({}, { __mode = "k" })
core.project_scan_enabled = false
core.project_scan_force = false
core.project_scan_reasons = {}

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

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

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

--- Apply the shared text-doc surface mode
-- @param mode string|nil requested mode
-- @return string resolved mode
function core.set_text_view_mode(mode)
	local resolved_mode = type(mode) == "string" and mode:lower() or nil

	if resolved_mode ~= "viewer" then
		resolved_mode = "editor"
	end

	config.text_view_mode = resolved_mode

	return resolved_mode
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()
	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

	local p = core.line_change_pending[doc]

	if not p then
		p = {
			doc = doc,
			line1 = line1,
			line2 = line2,
			kinds = {},
			meta = {},
			change_id = change_id,
			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.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,
				time = p.time,
				meta = p.meta,
			}
			emit_core_hook("core.line_change", "dispatch", event)
		end
	end
end

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

--- Initialize core state, services, and startup documents
function core.init()
	command = require("core.command")
	keymap = require("core.keymap")
	cli = require("core.cli")
	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")
	AnsiViewer = require("core.ansi.viewer")
	BinaryViewer = require("core.binary.viewer")
	ImageViewer = require("core.image.viewer")
	core.zoom = require("core.zoom")
	core.zoom.init()
	core.set_text_view_mode(config.text_view_mode)

	-- Create doc UIs through a core-owned factory
	-- Text docs use TextEditor or TextViewer from the shared mode setting
	-- ANSI-marked docs use AnsiViewer
	-- Binary placeholder docs always 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)
		if doc and doc.is_image == true then
			return ImageViewer(doc)
		end

		if doc and doc.is_binary_placeholder then
			return BinaryViewer(doc)
		end

		if doc and doc.syntax_detect_meta and doc.syntax_detect_meta.ansi_viewer == true then
			return AnsiViewer(doc)
		end

		if config.text_view_mode == "viewer" then
			return TextViewer(doc, opts)
		end

		return TextEditor(doc, opts)
	end

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

	core.set_text_view_mode(core.pending_text_view_mode)

	system.chdir(startup_cli.project_dir)

	core.frame_start = 0
	core.clip_rect_stack = { { 0, 0, 0, 0 } }
	core.log_items = {}
	core.docs = {}
	core.threads = 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()

	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

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

	project.start_scan_thread()

	-- Capture cache file state before syntax registry init
	local syntax_cache_before = system.get_file_info(syntax_cache_path)

	-- Build/load syntax registry cache at startup (lazy syntax module loading remains on-demand)
	-- Tags errors emitted in this startup phase as 'startup_error'
	core._startup_error_source = "syntax"

	local got_syntax_registry_error = not core.try(function()
		require("core.syntax").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 syntax registry init
	local cache_after = system.get_file_info(syntax_cache_path)
	if core.pending_no_cache then
		local before_s = syntax_cache_before and (syntax_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),
			syntax_cache_path
		)
		print(
			string.format(
				"[Debug] no-cache cachefile state; before: %s, after: %s, path: %s",
				tostring(before_s),
				tostring(after_s),
				syntax_cache_path
			)
		)
	end
	command.add_defaults()
	core._startup_error_source = "plugins"

	-- CLI binary-open mode also selects the binary renderer startup mode
	if core.pending_binary_open_mode == "symbols" or core.pending_binary_open_mode == "hex" then
		config.binary_viewer_default_mode = core.pending_binary_open_mode
		core.log_quiet('CLI binary-open mode enabled: %s', core.pending_binary_open_mode)
	end

	local got_plugin_error = not core.load_plugins()
	core._startup_error_source = nil

	core._startup_error_source = "user"
	local got_user_error = not core.try(require, "user")
	core._startup_error_source = nil

	if not got_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 user-configured font selection loaded from data/user/settings.lua
	end

	core._startup_error_source = "project"
	local got_project_error = not project.load_module()
	core._startup_error_source = nil

	-- 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 syntax = require("core.syntax")
			local tokenizer = require("core.syntax.tokenizer")
			tokenizer.audit_subsyntax_refs({
				syntax = syntax,
				core = core,
				include_registry = true,
				log_summary = true,
				details_limit = 12,
			})
		end)
		core._startup_error_source = nil
	end

	cli.apply_request(core, startup_cli, {
		auto_create_doc = config.new_doc_auto_create_on_startup == true and #core.docs == 0,
	})

	if #cli_option_errors > 0 or got_syntax_registry_error or got_plugin_error or got_user_error or got_project_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

	-- Auto-run tests by config, or force-run via --run-tests for CLI verification
	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,
		})
	end

	core.pending_exit_code = run_tests_ok and 0 or 1
end

--- Apply one forwarded CLI argv list to the running session
-- @param args table argv list
-- @return boolean changed
function core.open_cli_args(args)
	if type(args) ~= "table" then
		return false
	end

	local saved_binary_open_mode = core.pending_binary_open_mode
	local saved_text_view_mode = core.pending_text_view_mode
	local saved_activate_pane = core.pending_activate_pane
	local saved_activate_tab = core.pending_activate_tab

	core.pending_binary_open_mode = nil
	core.pending_text_view_mode = nil
	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,
		}
	)

	local runtime_binary_open_mode = core.pending_binary_open_mode
	local runtime_text_view_mode = core.pending_text_view_mode
	local runtime_activate_pane = core.pending_activate_pane
	local runtime_activate_tab = core.pending_activate_tab

	core.pending_binary_open_mode = saved_binary_open_mode
	core.pending_text_view_mode = saved_text_view_mode
	core.pending_activate_pane = saved_activate_pane
	core.pending_activate_tab = saved_activate_tab

	core.set_text_view_mode(runtime_text_view_mode or config.text_view_mode)

	return cli.apply_request(core, parsed, {
		focus_window = true,
		binary_open_mode = runtime_binary_open_mode,
		activate_pane = runtime_activate_pane,
		activate_tab = runtime_activate_tab,
	})
end

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

--- Attempt graceful shutdown and prompt for unsaved documents
-- @param force boolean|nil exit immediately when true
function core.quit(force)
	if force then
		fileops.delete_temp_files()
		os.exit()
	end

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

	core.quit(true)
end

--- Load plugins and tag syntaxes registered by each module
-- @return boolean no_errors
function core.load_plugins()
	local no_errors = true
	local syntax = require("core.syntax")
	local modules = {}
	local bundled_plugins = rawget(_G, "BUNDLED_PLUGINS")

	if 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 = #syntax.items
		local t0 = system.get_time()
		local ok = core.try(function()
			local loaded, err = 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 syntaxes registered by this plugin with stable module id
			for i = before + 1, #syntax.items do
				syntax.items[i]._module_name = modname
			end
			core.log_quiet("Loaded plugin %q in %.1fms", modname, dt_ms)
			loaded_count = loaded_count + 1
		else
			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)
	local old = package.loaded[name]
	package.loaded[name] = nil
	local new = require(name)

	if type(old) == "table" then
		for k, v in pairs(new) do
			old[k] = v
		end

		package.loaded[name] = old
	end
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
			prev._view_selection = { prev.doc:get_selection() }
		end

		core.last_active_view = prev
		core.active_view = view

		if core.view_can_select(view) and view.doc then
			if 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 through the Lite XL alias
-- @return table|nil view
function core.get_view()
	return core.active_view
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 thread entry
-- @param weak_ref any|nil weak-reference key
function core.add_thread(f, weak_ref)
	local key = weak_ref or #core.threads + 1

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

	core.threads[key] = { cr = coroutine.create(fn), wake = 0 }
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

--- Resolve lx boot log path for current executable
-- @return string path
local function get_boot_log_path()
	local exe_file = tostring(EXE_FILE or "")
	local exe_dir = tostring(EXE_DIR or ".")
	-- Pattern breakdown:
	--   ([^/\\]+)$ captures the last path component after the final slash or backslash
	local exe_name = exe_file:match("([^/\\]+)$") or "lx"
	-- Pattern breakdown:
	--   %.[^%.]+$ strips the final file extension including the leading dot
	local base_name = exe_name:gsub("%.[^%.]+$", "")

	if base_name == "" then
		base_name = "lx"
	end

	return string.format("%s/%sboot.log", exe_dir, base_name)
end

--- Append one diagnostic line to boot log file
-- @param line string
local function append_boot_log_line(line)
	local path = get_boot_log_path()
	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_boot_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.warn 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 == "textinput" then
		core.workspace_view:on_text_input(...)
	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
			system.exec(string.format("%q %q", EXE_FILE, filename))
		else
			local ok, doc = core.try(Doc.open_file, filename)

			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)
				core.workspace_view:open_doc(doc)
			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

--- Build a date string compatible with C++ __DATE__
-- @return string date
local function cpp_date()
	local months = {
		"Jan", "Feb", "Mar", "Apr", "May", "Jun",
		"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
	}
	local t = os.date("*t")

	local m = months[t.month]
	local d = string.format("%2d", t.day) -- space-padded
	local y = t.year

	return string.format("%s %s %d", m, d, y)
end

--- Process events, update UI state, and render one frame when needed
-- @return boolean redrew
function core.step()
	local forwarded_cli_batches = system.poll_forwarded_cli()

	for i = 1, #forwarded_cli_batches do
		core.try(core.open_cli_args, forwarded_cli_batches[i])
	end

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

	for type, a, b, c, d 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)
			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
		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 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 [" .. cpp_date() .. "]"
	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 run_threads_loop()
	while true do
		local max_time = 1 / config.fps - 0.004
		local ran_any_threads = false

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

			-- stop running threads 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_threads then
			coroutine.yield()
		end
	end
end

local run_threads = coroutine.wrap(run_threads_loop)

--- Run the main application loop
function core.run()
	if core.pending_exit then
		-- Dedicated non-interactive mode, e.g. --run-tests --exit
		os.exit(core.pending_exit_code or 0)
	end

	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

		run_threads()

		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 / config.fps - 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)
	-- write error to file
	local fp = io.open(EXE_DIR .. "/error.txt", "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
