-- lx-bundle:true

--- Manage live status bar items and built-in registrations
-- @module core.status.items

local core = require("core")
local config = require("core.config")
local style = require("core.style")
local DocView = require("core.doc.view")
local byte_format = require("core.format.bytes")

local items = {
	_list = {},
	_by_id = {},
	_next_sequence = 0,
	_defaults_registered = false,
}

local bytes_per_megabyte = 1024 * 1024
local memory_usage_enabled = true
local memory_usage_refresh_interval_seconds = 0.5
local memory_usage_smoothing_enabled = true
local memory_usage_smoothing_alpha = 0.35
local content_size_refresh_delay_seconds = 0.5

-- Weak document keys let closed tabs release their delayed display state
local content_size_display_states = setmetatable({}, { __mode = "k" })

local memory_usage_state = {
	next_update_time = 0,
	process_working_set_bytes = 0,
	process_display_bytes = 0,
	process_error = nil,
	lua_bytes = 0,
	lua_display_bytes = 0,
	smoothing_initialized = false,
}

local always_true = function()
	return true
end

local known_requirement_tokens = {
	doc = true,
	select = true,
	edit = true,
	cursor = true,
	command_view = true,
	log_view = true,
	binary_viewer = true,
	image_viewer = true,
	text_editor = true,
	text_viewer = true,
}

local item_methods = {}

--- Resolve one view class lazily and cache the result
-- @param cache_key string cache entry key
-- @param module_name string module path to load
-- @return table|nil view class or nil when unavailable
local function get_cached_view_class(cache_key, module_name)
	items._view_class_cache = items._view_class_cache or {}
	if items._view_class_cache[cache_key] ~= nil then
		return items._view_class_cache[cache_key]
	end

	local ok, class_or_error = pcall(require, module_name)
	items._view_class_cache[cache_key] = ok and class_or_error or false

	return items._view_class_cache[cache_key] or nil
end

--- Return one normalized item side
-- @param side string|nil requested side
-- @return string normalized side
local function normalize_side(side)
	if side == "left" or side == "right" then
		return side
	end

	return "right"
end

--- Convert one scalar-or-list field into a flat string list
-- @param value string|table|nil raw anchor or requires value
-- @return table normalized string list
local function normalize_string_list(value)
	local normalized = {}
	if type(value) == "string" and value ~= "" then
		normalized[1] = value
		return normalized
	end

	if type(value) ~= "table" then
		return normalized
	end

	for _, entry in ipairs(value) do
		if type(entry) == "string" and entry ~= "" then
			normalized[#normalized + 1] = entry
		end
	end

	return normalized
end

--- Build one snapshot of active-view state for item predicates
-- @return table context snapshot
local function build_context()
	local active_view = core.active_view
	local is_doc_view = active_view
		and active_view ~= core.command_view
		and active_view.is
		and active_view:is(DocView)
		or false

	local context = {
		active_view = active_view,
		doc = active_view and active_view.doc or nil,
		is_doc_view = is_doc_view,
		can_select = active_view and core.view_can_select(active_view) or false,
		can_edit = active_view and core.view_can_edit(active_view) or false,
		has_cursor = active_view and core.view_has_cursor(active_view) or false,
	}

	context.flags = {
		doc = context.doc ~= nil,
		select = context.doc ~= nil and context.can_select,
		edit = context.doc ~= nil and context.can_edit,
		cursor = context.doc ~= nil and context.has_cursor,
		command_view = active_view == core.command_view,
		log_view = false,
		binary_viewer = false,
		image_viewer = false,
		text_editor = false,
		text_viewer = false,
	}

	local log_view_class = get_cached_view_class("log_view", "core.log.view")
	if active_view and log_view_class then
		context.flags.log_view = active_view:is(log_view_class)
	end

	local binary_viewer_class = get_cached_view_class("binary_viewer", "core.binary.viewer")
	if active_view and binary_viewer_class then
		context.flags.binary_viewer = active_view:is(binary_viewer_class)
	end

	local image_viewer_class = get_cached_view_class("image_viewer", "core.image.viewer")
	if active_view and image_viewer_class then
		context.flags.image_viewer = active_view:is(image_viewer_class)
	end

	local text_editor_class = get_cached_view_class("text_editor", "core.text.editor")
	if active_view and text_editor_class then
		context.flags.text_editor = active_view:is(text_editor_class)
	end

	local text_viewer_class = get_cached_view_class("text_viewer", "core.text.viewer")
	if active_view and text_viewer_class then
		context.flags.text_viewer = active_view:is(text_viewer_class)
	end

	return context
end

--- Resolve one predicate specification into a callable predicate
-- @param predicate function|table|string|nil predicate specification
-- @return function predicate function
local function resolve_predicate(predicate)
	if predicate == nil then
		return always_true
	end

	if type(predicate) == "function" then
		return predicate
	end

	if type(predicate) == "table" then
		local class = predicate
		return function(context)
			local active_view = context and context.active_view or core.active_view
			return active_view and active_view.is and active_view:is(class) or false
		end
	end

	if type(predicate) == "string" then
		if known_requirement_tokens[predicate] then
			return function(context)
				return context and context.flags and context.flags[predicate] == true or false
			end
		end

		return function(context)
			local ok, resolved = pcall(require, predicate)
			if not ok then
				return false
			end

			if type(resolved) == "function" then
				local call_ok, result = pcall(resolved, context)
				return call_ok and result == true or false
			end

			if type(resolved) == "table" then
				local active_view = context and context.active_view or core.active_view
				return active_view and active_view.is and active_view:is(resolved) or false
			end

			return false
		end
	end

	error("status item predicate must be a function, table, string, or nil")
end

--- Copy one patch table into an item while preserving its metatable
-- @param item table status item to update
-- @param patch table field patch
-- @return nil
local function apply_patch_fields(item, patch)
	for key, value in pairs(patch) do
		item[key] = value
	end

	item.side = normalize_side(item.side)
	item.order = tonumber(item.order) or 0
	item.priority = tonumber(item.priority) or 0
	item.visible = item.visible ~= false
	item.requires = normalize_string_list(item.requires)
	item.before = normalize_string_list(item.before)
	item.after = normalize_string_list(item.after)
	item._predicate = resolve_predicate(item.predicate)
end

--- Check whether one item passes all explicit requirement tokens
-- @param item table status item
-- @param context table current status context
-- @return boolean true when every requirement passes
local function item_requirements_pass(item, context)
	for _, token in ipairs(item.requires or {}) do
		if not (context.flags and context.flags[token]) then
			return false
		end
	end

	return true
end

--- Check whether one item is currently visible
-- @param item table status item
-- @param context table current status context
-- @return boolean true when the item should render
local function item_is_visible(item, context)
	if item.visible == false then
		return false
	end

	if not item_requirements_pass(item, context) then
		return false
	end

	local ok, result = pcall(item._predicate or always_true, context, item)
	return ok and result ~= false or false
end

--- Return the current token list for one item
-- @param item table status item
-- @param context table current status context
-- @return table token list
local function get_item_tokens(item, context)
	if type(item.get_tokens) == "function" then
		local ok, tokens = pcall(item.get_tokens, context, item)
		if ok and type(tokens) == "table" then
			return tokens
		end
		return {}
	end

	if type(item.tokens) == "table" then
		return item.tokens
	end

	if item.text ~= nil then
		return { item.text }
	end

	return {}
end

--- Return the current foreground and background colors for one item
-- @param item table status item
-- @param context table current status context
-- @return table|nil foreground color or nil
-- @return table|nil background color or nil
local function get_item_colors(item, context)
	local foreground = item.fg
	local background = item.bg

	if type(item.get_color) == "function" then
		local ok, next_foreground, next_background = pcall(item.get_color, context, item)
		if ok then
			if next_foreground ~= nil then
				foreground = next_foreground
			end
			if next_background ~= nil then
				background = next_background
			end
		end
	end

	return foreground, background
end

--- Compare two items by their coarse order and registration sequence
-- @param left table first item
-- @param right table second item
-- @return boolean true when left sorts before right
local function compare_base_order(left, right)
	if left.order ~= right.order then
		return left.order < right.order
	end

	if left._sequence ~= right._sequence then
		return left._sequence < right._sequence
	end

	return tostring(left.id) < tostring(right.id)
end

--- Insert one ready item while preserving base sort order
-- @param ready table ready queue
-- @param item table item to insert
-- @return nil
local function insert_ready_item(ready, item)
	local insert_at = #ready + 1
	for index, existing in ipairs(ready) do
		if compare_base_order(item, existing) then
			insert_at = index
			break
		end
	end

	table.insert(ready, insert_at, item)
end

--- Add one dependency edge when both endpoints exist on the same side
-- @param edges table adjacency map
-- @param indegree table indegree map
-- @param from_id string source item id
-- @param to_id string destination item id
-- @return nil
local function add_dependency_edge(edges, indegree, from_id, to_id)
	if from_id == to_id then
		return
	end

	edges[from_id] = edges[from_id] or {}
	if edges[from_id][to_id] then
		return
	end

	edges[from_id][to_id] = true
	indegree[to_id] = (indegree[to_id] or 0) + 1
end

--- Resolve one side into a stable order with before and after anchors
-- @param side string left or right
-- @return table ordered items for that side
local function resolve_side_order(side)
	local side_items = {}
	for _, item in ipairs(items._list) do
		if item.side == side then
			side_items[#side_items + 1] = item
		end
	end

	table.sort(side_items, compare_base_order)
	if #side_items <= 1 then
		return side_items
	end

	local by_id = {}
	local indegree = {}
	local edges = {}
	for _, item in ipairs(side_items) do
		by_id[item.id] = item
		indegree[item.id] = 0
	end

	for _, item in ipairs(side_items) do
		for _, anchor_id in ipairs(item.after or {}) do
			if by_id[anchor_id] then
				add_dependency_edge(edges, indegree, anchor_id, item.id)
			end
		end

		for _, anchor_id in ipairs(item.before or {}) do
			if by_id[anchor_id] then
				add_dependency_edge(edges, indegree, item.id, anchor_id)
			end
		end
	end

	local ready = {}
	for _, item in ipairs(side_items) do
		if indegree[item.id] == 0 then
			insert_ready_item(ready, item)
		end
	end

	local resolved = {}
	while #ready > 0 do
		local item = table.remove(ready, 1)
		resolved[#resolved + 1] = item

		local next_items = edges[item.id]
		if next_items then
			local next_ids = {}
			for next_id in pairs(next_items) do
				next_ids[#next_ids + 1] = next_id
			end

			table.sort(next_ids, function(left_id, right_id)
				return compare_base_order(by_id[left_id], by_id[right_id])
			end)

			for _, next_id in ipairs(next_ids) do
				indegree[next_id] = indegree[next_id] - 1
				if indegree[next_id] == 0 then
					insert_ready_item(ready, by_id[next_id])
				end
			end
		end
	end

	if #resolved ~= #side_items then
		return side_items
	end

	return resolved
end

--- Append one token list into another token list
-- @param destination table token list to append into
-- @param source table token list to append
-- @return nil
local function append_tokens(destination, source)
	for _, value in ipairs(source) do
		destination[#destination + 1] = value
	end
end

--- Resolve one item separator into styled token content
-- @param item table status item
-- @param opts table render options with separator strings
-- @return table separator tokens
local function get_separator_tokens(item, opts)
	local separator = item.separator
	if separator == nil then
		separator = "separator"
	end

	if separator == false then
		return {}
	end

	if separator == "separator" then
		return { opts.separator or "    " }
	end

	if separator == "separator2" then
		return { style.dim, opts.separator2 or "  |  " }
	end

	if type(separator) == "table" then
		return separator
	end

	return { tostring(separator) }
end

--- Resolve and build one side into status-bar entries
-- @param side string left or right
-- @param context table current status context
-- @param opts table render options with separator strings
-- @return table resolved entry list
local function build_side_entries(side, context, opts)
	local resolved_items = resolve_side_order(side)
	local entries = {}

	for _, item in ipairs(resolved_items) do
		item.active = false
		item.cached_tokens = {}
		item.cached_fg = nil
		item.cached_bg = nil

			if item_is_visible(item, context) then
				local item_tokens = get_item_tokens(item, context)
				if #item_tokens > 0 then
					local foreground, background = get_item_colors(item, context)
					item.active = true
					item.cached_tokens = item_tokens
					item.cached_fg = foreground
					item.cached_bg = background
					entries[#entries + 1] = {
						item = item,
						id = item.id,
						side = item.side,
						order = item.order,
						priority = item.priority,
						sequence = item._sequence,
						tokens = item_tokens,
						separator_tokens = get_separator_tokens(item, opts),
						fg = foreground,
						bg = background,
						command = item.command,
						on_click = item.on_click,
						on_hover = item.on_hover,
					}
				end
			end
	end

	return entries
end

--- Formats a byte value as fixed megabyte text
-- @param bytes number|nil memory size in bytes
-- @param decimals number digits after the decimal separator
-- @return string formatted megabyte string
local function format_megabytes(bytes, decimals)
	local value = tonumber(bytes) or 0
	if value < 0 then
		value = 0
	end

	if decimals > 0 then
		return string.format("%.1f", value / bytes_per_megabyte)
	end

	return math.floor(value / bytes_per_megabyte + 0.5)
end

--- Updates display values using optional low-pass smoothing
-- @return nil
local function update_memory_display_values()
	if memory_usage_smoothing_enabled == false then
		memory_usage_state.process_display_bytes = memory_usage_state.process_working_set_bytes
		memory_usage_state.lua_display_bytes = memory_usage_state.lua_bytes
		memory_usage_state.smoothing_initialized = true
		return
	end

	local alpha = tonumber(memory_usage_smoothing_alpha) or 0.35
	if alpha < 0 then
		alpha = 0
	elseif alpha > 1 then
		alpha = 1
	end

	if not memory_usage_state.smoothing_initialized then
		memory_usage_state.process_display_bytes = memory_usage_state.process_working_set_bytes
		memory_usage_state.lua_display_bytes = memory_usage_state.lua_bytes
		memory_usage_state.smoothing_initialized = true
		return
	end

	memory_usage_state.process_display_bytes = memory_usage_state.process_display_bytes
		+ alpha * (memory_usage_state.process_working_set_bytes - memory_usage_state.process_display_bytes)

	memory_usage_state.lua_display_bytes = memory_usage_state.lua_display_bytes
		+ alpha * (memory_usage_state.lua_bytes - memory_usage_state.lua_display_bytes)
end

--- Refresh the cached memory usage values
-- @return nil
local function refresh_memory_usage_state()
	local now = system.get_time()
	local interval = tonumber(memory_usage_refresh_interval_seconds) or 0.5
	if interval < 0 then
		interval = 0
	end

	if now < memory_usage_state.next_update_time then
		return
	end

	memory_usage_state.next_update_time = now + interval

	local usage = system.get_memory_usage_bytes()
	memory_usage_state.process_working_set_bytes = tonumber(usage.working_set_bytes) or 0
	memory_usage_state.process_error = usage.error
	memory_usage_state.lua_bytes = (tonumber(collectgarbage("count")) or 0) * 1024
	update_memory_display_values()
end

--- Formats the current document encoding label
-- @param encoding_label string|nil raw encoding label
-- @return string normalized encoding label
local function format_encoding_label(encoding_label)
	return encoding_label or "UTF-8"
end

--- Formats the current codepage label for status-bar display
-- @param codepage_label string|nil raw codepage label
-- @return string|nil normalized codepage label
local function format_codepage_label(codepage_label)
	if type(codepage_label) ~= "string" or codepage_label == "" then
		return nil
	end

	local codepage_name = codepage_label:match("^Codepage%s+(.+)$")
	if codepage_name and codepage_name ~= "" then
		return string.upper(codepage_name)
	end

	return codepage_label
end

--- Return the active document view when the context is doc-backed
-- @param context table current status context
-- @return table|nil active document view
local function get_active_doc_view(context)
	if context and context.is_doc_view then
		return context.active_view
	end

	return nil
end

--- Keep text-editor-only metadata out of viewer-oriented doc surfaces
-- @param context table current status context
-- @return boolean is_text_editor_doc true when the active view is a TextEditor doc view
local function is_text_editor_doc_view(context)
	return context and context.is_doc_view and context.flags and context.flags.text_editor == true or false
end

--- Return delayed, preformatted content-size text for one document
-- @param doc table document model
-- @return table display state
local function get_content_size_display_state(doc)
	local current_size = math.max(0, math.floor((tonumber(doc.content_size_bytes) or 0) + 0.5))
	local state = content_size_display_states[doc]

	if not state then
		state = {
			displayed_size = current_size,
			pending_size = current_size,
			pending_change_time = doc.content_size_change_time,
			short_text = byte_format.short(current_size),
			full_text = byte_format.full(current_size),
		}
		content_size_display_states[doc] = state

		return state
	end

	if state.pending_size ~= current_size
		or state.pending_change_time ~= doc.content_size_change_time
	then
		state.pending_size = current_size
		state.pending_change_time = doc.content_size_change_time
	end

	if state.displayed_size ~= state.pending_size then
		local change_time = tonumber(state.pending_change_time) or system.get_time()
		if system.get_time() >= change_time + content_size_refresh_delay_seconds then
			state.displayed_size = state.pending_size
			state.short_text = byte_format.short(state.displayed_size)
			state.full_text = byte_format.full(state.displayed_size)
		end
	end

	return state
end

--- Keep wrap controls scoped to text surfaces that support wrapped rows
-- @param context table current status context
-- @return boolean is_toggleable_text_doc true when the active view can toggle wrap
local function is_toggleable_text_doc_view(context)
	return context
		and context.is_doc_view
		and context.flags
		and (context.flags.text_editor == true or context.flags.text_viewer == true)
		or false
end

--- Return the active image format label when image metadata is available
-- @param context table current status context
-- @return string|nil format_name
local function get_image_format(context)
	local view = get_active_doc_view(context)
	if not view or not context.flags or context.flags.image_viewer ~= true
		or type(view.get_image_format) ~= "function" then
		return nil
	end

	return view:get_image_format()
end

--- Return whether the active image uses vector or pixel graphics
-- @param context table current status context
-- @return string|nil graphics_type
local function get_image_graphics_type(context)
	local view = get_active_doc_view(context)
	if not view or not context.flags or context.flags.image_viewer ~= true
		or type(view.get_image_graphics_type) ~= "function" then
		return nil
	end

	return view:get_image_graphics_type()
end

--- Return the active image's decoded raster depth when available
-- @param context table current status context
-- @return integer|nil bits_per_pixel
local function get_image_bits_per_pixel(context)
	local view = get_active_doc_view(context)
	if not view or not context.flags or context.flags.image_viewer ~= true
		or type(view.get_image_bits_per_pixel) ~= "function" then
		return nil
	end

	return view:get_image_bits_per_pixel()
end

--- Return the active image's intrinsic and displayed dimensions
-- @param context table current status context
-- @return number|nil source_width
-- @return number|nil source_height
-- @return number|nil displayed_width
-- @return number|nil displayed_height
local function get_image_dimensions(context)
	local view = get_active_doc_view(context)
	if not view or not context.flags or context.flags.image_viewer ~= true
		or type(view.get_image_dimensions) ~= "function"
		or type(view.get_displayed_image_dimensions) ~= "function" then
		return nil, nil, nil, nil
	end

	local source_width, source_height = view:get_image_dimensions()
	local displayed_width, displayed_height = view:get_displayed_image_dimensions()

	return source_width, source_height, displayed_width, displayed_height
end

--- Register one item only when its id is still unused
-- @param spec table item specification
-- @return nil
local function register_default_item(spec)
	if not items._by_id[spec.id] then
		items.register(spec)
	end
end

--- Register the built-in left-side document items
-- @return nil
local function register_default_doc_left_items()
	register_default_item({
		id = "doc_file",
		side = "left",
		order = 100,
		priority = 1000,
		separator = false,
		predicate = function(context)
			return context.is_doc_view
		end,
		get_tokens = function(context)
			local doc_view = get_active_doc_view(context)
			if not doc_view then
				return {}
			end

			return {
				doc_view.doc:is_dirty() and style.accent or style.text,
				style.icon_font,
				"f",
				style.dim,
				style.font,
				context.separator2,
				context.doc.filename and style.text or style.dim,
				context.doc:get_name(),
			}
		end,
	})

	register_default_item({
		id = "doc_cursor",
		side = "left",
		order = 200,
		priority = 950,
		separator = "separator2",
		predicate = function(context)
			return context.is_doc_view and context.can_select
		end,
		get_tokens = function(context)
			local doc_view = get_active_doc_view(context)
			if not doc_view or type(context.doc.get_selection) ~= "function" then
				return {}
			end

			local line, col = context.doc:get_selection()
			local selection_suffix = ""
			if context.doc.has_selection and context.doc:has_selection() then
				local line1, col1, line2, col2 = context.doc:get_selection(true)
				local selection_length = #context.doc:get_text(line1, col1, line2, col2)
				selection_suffix = " (" .. col1 .. "+" .. selection_length .. ")"
			end

			return {
				style.text,
				"@",
				line,
				col > config.line_limit and style.accent or style.text,
				":",
				col,
				style.text,
				selection_suffix,
				string.format("   %d%%", math.floor((line / math.max(#context.doc.lines, 1) * 100) + 0.5)),
			}
		end,
	})
end

--- Resolve the effective indentation type, size, and size source for display
-- @param doc table|nil active text document
-- @return string type_label 'Spaces' or 'Tabs'
-- @return number indent_size effective indentation size
-- @return boolean automatic_size true when the size has no document override
-- @return number automatic_indent_size detected or configured automatic size
local function get_indent_display_state(doc)
	local indent_size = doc and type(doc.get_indent_size) == "function"
		and doc:get_indent_size()
		or math.max(1, math.floor(tonumber(config.indent_size) or 4))
	local automatic_indent_size = doc and type(doc.get_automatic_indent_size) == "function"
		and doc:get_automatic_indent_size()
		or math.max(1, math.floor(tonumber(config.indent_size) or 4))
	local indent_type = doc and type(doc.get_indent_type) == "function"
		and doc:get_indent_type()
		or config.indent_type
	local type_label = indent_type == "tabs" and "Tabs" or "Spaces"

	return type_label, indent_size, doc == nil or doc.indent_size == nil, automatic_indent_size
end

--- Dispatch indentation status gestures without opening a picker
-- @param button string canonical mouse button name
-- @param _x number pointer x position
-- @param _y number pointer y position
-- @param _clicks number click count
-- @param _entry table resolved status entry
-- @param context table current status context
-- @return any result command result
local function handle_indent_status_click(button, _x, _y, _clicks, _entry, context)
	local command = require("core.command")

	if button == "middle" then
		return command.perform("doc:detect-indent")
	end

	if button ~= "left" then
		return nil
	end

	local keymap = require("core.keymap")
	local active_doc = context and context.doc

	if keymap.modkeys["alt"] and active_doc then
		local type_command = active_doc:get_indent_type() == "tabs"
			and "doc:use-spaces" or "doc:use-tabs"

		return command.perform(type_command)
	end

	return command.perform("doc:cycle-indent-size")
end

--- Register the built-in right-side document items
-- @return nil
local function register_default_doc_right_items()
	register_default_item({
		id = "doc_info_icon",
		side = "right",
		order = 100,
		priority = 50,
		separator = false,
		predicate = function(context)
			return context.is_doc_view
		end,
		get_tokens = function()
			return {
				style.icon_font,
				"g",
				style.font,
			}
		end,
	})

	register_default_item({
		id = "doc_lines",
		side = "right",
		order = 200,
		priority = 200,
		separator = "separator2",
		tooltip = "Total line count in the active text document",
		predicate = function(context)
			-- Keep line counts on editable text docs only
			return is_text_editor_doc_view(context)
		end,
		get_tokens = function(context)
			return {
				style.text,
				#context.doc.lines,
				" lines",
			}
		end,
	})

	register_default_item({
		id = "doc_size",
		side = "right",
		order = 250,
		priority = 100,
		separator = "separator",
		get_tooltip = function(context)
			return get_content_size_display_state(context.doc).full_text
		end,
		predicate = function(context)
			-- Every document surface shares the same document-owned size estimate
			return context.is_doc_view and context.doc ~= nil
		end,
		get_tokens = function(context)
			return {
				style.text,
				get_content_size_display_state(context.doc).short_text,
			}
		end,
	})

	register_default_item({
		id = "doc_indent",
		side = "right",
		order = 300,
		priority = 500,
		separator = "separator",
		on_click = handle_indent_status_click,
		get_tooltip = function(context)
			local type_label, indent_size, _, automatic_indent_size =
				get_indent_display_state(context.doc)
			local size_cycle = table.concat(config.indent_size_cycle, ", ")
			-- Show the alternate type without repeating both choices
			local alternate_type = type_label == "Tabs" and "spaces" or "tabs"

			return string.format(
				"Indentation: %d %s · Click to cycle: Auto (%d), %s · Alt-click: %s",
				indent_size,
				type_label:lower(),
				automatic_indent_size,
				size_cycle,
				alternate_type
			)
		end,
		predicate = function(context)
			-- Keep indentation metadata on editable text docs only
			return is_text_editor_doc_view(context)
		end,
		get_tokens = function(context)
			local type_label, indent_size, automatic_size = get_indent_display_state(context.doc)

			return {
				type_label,
				": ",
				indent_size,
				automatic_size and "*" or "",
			}
		end,
	})

	register_default_item({
		id = "doc_encoding",
		side = "right",
		order = 400,
		priority = 650,
		separator = "separator",
		tooltip = "Text encoding used by the active document",
		predicate = function(context)
			-- Keep encoding metadata on editable text docs only
			return is_text_editor_doc_view(context)
		end,
		get_tokens = function(context)
			-- A legacy codepage is the file encoding, while UTF-8 is only the decoded editor text
			local encoding = format_codepage_label(context.doc.codepage_label)
				or format_encoding_label(context.doc.encoding_label)

			return {
				encoding,
			}
		end,
	})

	register_default_item({
		id = "doc_line_endings",
		side = "right",
		order = 600,
		priority = 700,
		separator = "separator",
		command = "doc:toggle-line-ending",
		get_tooltip = function(context)
			local line_ending_name = context.doc.cr_only and "CR"
				or (context.doc.crlf and "CRLF" or "LF")
			return "Line endings: " .. line_ending_name .. "  Click to toggle"
		end,
		predicate = function(context)
			-- Keep line-ending metadata on editable text docs only
			return is_text_editor_doc_view(context)
		end,
		get_tokens = function(context)
			return {
				context.doc.cr_only and "CR" or (context.doc.crlf and "CRLF" or "LF"),
			}
		end,
	})

	register_default_item({
		id = "doc_wrap",
		side = "right",
		order = 650,
		priority = 675,
		separator = "separator",
		command = "doc:toggle-word-wrap",
		get_tooltip = function(context)
			local doc_view = get_active_doc_view(context)
			local wrap_name = doc_view and doc_view.wrap_enabled == true and "Wrap" or "No Wrap"
			return "Wrap mode: " .. wrap_name .. "  Click to toggle"
		end,
		predicate = function(context)
			return is_toggleable_text_doc_view(context)
		end,
		get_tokens = function(context)
			local doc_view = get_active_doc_view(context)
			return {
				doc_view and doc_view.wrap_enabled == true and "Wrap" or "No Wrap",
			}
		end,
	})

	register_default_item({
		id = "doc_format",
		side = "right",
		order = 700,
		priority = 350,
		separator = "separator",
		get_tooltip = function(context)
			if context.flags and context.flags.image_viewer == true then
				return get_image_graphics_type(context) == "vector" and "Vector graphics" or "Pixel graphics"
			end

			return "Detected or selected document format"
		end,
		predicate = function(context)
			return context.is_doc_view
		end,
		get_tokens = function(context)
			-- A special view may provide its own short format name
			local active_view = context.active_view
			if active_view and type(active_view.get_format_status_name) == "function" then
				local format_name = active_view:get_format_status_name()

				if type(format_name) == "string" and format_name ~= "" then
					return { format_name }
				end
			end

			-- Keep binary docs from falling back to the plain-text format label
			if context.flags and context.flags.binary_viewer == true then
				return { "Binary" }
			end

			-- Keep image docs from borrowing the plain-text format fallback
			if context.flags and context.flags.image_viewer == true then
				return { get_image_format(context) or "Image" }
			end

			local format_name = "Plain Text"
			if context.doc.format then
				format_name = context.doc.format.shortname or context.doc.format.name or format_name
			end

			return { format_name }
		end,
	})

	register_default_item({
		id = "image_dimensions",
		side = "right",
		order = 750,
		priority = 325,
		separator = "separator",
		predicate = function(context)
			local source_width, source_height = get_image_dimensions(context)
			return source_width ~= nil and source_width > 0 and source_height ~= nil and source_height > 0
		end,
		get_tooltip = function(context)
			local source_width, source_height, displayed_width, displayed_height = get_image_dimensions(context)
			if not source_width or not source_height then
				return nil
			end

			if not displayed_width or displayed_width <= 0 or not displayed_height or displayed_height <= 0 then
				return string.format("%d\u{00D7}%d", source_width, source_height)
			end

			if displayed_width == source_width and displayed_height == source_height then
				return string.format("%d\u{00D7}%d (displayed as-is)", source_width, source_height)
			end

			return string.format(
				"%d\u{00D7}%d (displayed as %d\u{00D7}%d)",
				source_width,
				source_height,
				displayed_width,
				displayed_height
			)
		end,
		get_tokens = function(context)
			local source_width, source_height = get_image_dimensions(context)
			if not source_width or not source_height then
				return {}
			end

			return { string.format("%d\u{00D7}%d", source_width, source_height) }
		end,
	})

	register_default_item({
		id = "image_depth",
		side = "right",
		order = 775,
		priority = 300,
		separator = "separator",
		predicate = function(context)
			local bits_per_pixel = get_image_bits_per_pixel(context)
			return bits_per_pixel ~= nil and bits_per_pixel > 0
		end,
		get_tooltip = function(context)
			local bits_per_pixel = get_image_bits_per_pixel(context)
			return bits_per_pixel and string.format("%d bits per pixel", bits_per_pixel) or nil
		end,
		get_tokens = function(context)
			local bits_per_pixel = get_image_bits_per_pixel(context)
			return bits_per_pixel and { string.format("%d bpp", bits_per_pixel) } or {}
		end,
	})
end

--- Register the shared memory and zoom items
-- @return nil
local function register_default_shared_items()
	register_default_item({
		id = "memory_usage",
		side = "right",
		order = 800,
		priority = 150,
		separator = "separator",
		tooltip = "Lua GC memory and process working set in megabytes",
		get_tokens = function()
			if memory_usage_enabled == false then
				return {}
			end

			refresh_memory_usage_state()

			local memory_tokens = {
				style.dim,
				"Mem ",
				style.text,
				format_megabytes(memory_usage_state.lua_display_bytes, 0),
				style.dim,
				"-",
			}

			if memory_usage_state.process_error then
				memory_tokens[#memory_tokens + 1] = style.accent
				memory_tokens[#memory_tokens + 1] = "n/a"
			else
				memory_tokens[#memory_tokens + 1] = style.text
				memory_tokens[#memory_tokens + 1] = format_megabytes(memory_usage_state.process_display_bytes, 1)
			end

			memory_tokens[#memory_tokens + 1] = style.dim
			memory_tokens[#memory_tokens + 1] = " MB"

			return memory_tokens
		end,
	})

	register_default_item({
		id = "zoom",
		side = "right",
		order = 900,
		priority = 100,
		separator = "separator",
		tooltip = "Current content zoom level for the active document view",
		predicate = function(context)
			return context.is_doc_view
		end,
		get_tokens = function()
			local zoom = core.zoom
			if not zoom or type(zoom.get_zoom) ~= "function" then
				return {}
			end

			local current_zoom = zoom.get_zoom()
			local default_zoom = zoom.get_default_zoom and zoom.get_default_zoom() or 1
			local text = string.format(config.zoom_status_format or "%.0f%%", current_zoom * 100)

			if math.abs(current_zoom - default_zoom) > 1e-6 then
				return {
					{ text = text, bold = true },
				}
			end

			return { text }
		end,
	})
end

--- Register the built-in workspace items used outside document views
-- @return nil
local function register_default_workspace_items()
	register_default_item({
		id = "workspace_info_icon",
		side = "right",
		order = 100,
		priority = 50,
		separator = false,
		predicate = function(context)
			return not context.is_doc_view
		end,
		get_tokens = function()
			return {
				style.icon_font,
				"g",
				style.font,
			}
		end,
	})

	register_default_item({
		id = "workspace_counts",
		side = "right",
		order = 200,
		priority = 300,
		separator = "separator2",
		predicate = function(context)
			return not context.is_doc_view
		end,
		get_tokens = function()
			return {
				style.dim,
				#core.docs,
				style.text,
				" / ",
				#core.project_files,
				" files",
			}
		end,
	})
end

--- Ensure the built-in status items have been registered once
-- @return nil
function items.ensure_defaults_registered()
	if items._defaults_registered then
		return
	end

	items._defaults_registered = true

	register_default_doc_left_items()
	register_default_doc_right_items()
	register_default_shared_items()
	register_default_workspace_items()
end

--- Register one new live status item
-- @param spec table item specification
-- @return table registered item
function items.register(spec)
	if not items._defaults_registered then
		items.ensure_defaults_registered()
	end

	assert(type(spec) == "table", "status item spec table expected")
	assert(type(spec.id) == "string" and spec.id ~= "", "status item id string expected")
	assert(items._by_id[spec.id] == nil, "status item already exists: " .. spec.id)

	items._next_sequence = items._next_sequence + 1

	local item = setmetatable({
		id = spec.id,
		side = spec.side,
		order = spec.order,
		priority = spec.priority,
		before = spec.before,
		after = spec.after,
		requires = spec.requires,
		predicate = spec.predicate,
		visible = spec.visible,
		separator = spec.separator,
		get_tokens = spec.get_tokens,
		get_color = spec.get_color,
		tokens = spec.tokens,
		text = spec.text,
		fg = spec.fg,
		bg = spec.bg,
		command = spec.command,
		tooltip = spec.tooltip,
		get_tooltip = spec.get_tooltip,
		on_click = spec.on_click,
		on_hover = spec.on_hover,
		active = false,
		cached_tokens = {},
		_sequence = items._next_sequence,
	}, { __index = item_methods })

	apply_patch_fields(item, {})

	items._list[#items._list + 1] = item
	items._by_id[item.id] = item

	return item
end

--- Return one registered status item by id
-- @param id string item id
-- @return table|nil registered item or nil
function items.get_item(id)
	items.ensure_defaults_registered()

	return items._by_id[id]
end

--- Return the full registered item list
-- @return table registered item list
function items.get_items_list()
	items.ensure_defaults_registered()
	return items._list
end

--- Update one registered item in place
-- @param id string item id
-- @param patch table field patch
-- @return table|nil updated item or nil
function items.update(id, patch)
	local item = items._by_id[id]
	if not item or type(patch) ~= "table" then
		return item
	end

	local normalized_patch = {}
	for key, value in pairs(patch) do
		normalized_patch[key] = value
	end

	if normalized_patch.id and normalized_patch.id ~= id then
		assert(items._by_id[normalized_patch.id] == nil, "status item already exists: " .. normalized_patch.id)
		items._by_id[id] = nil
		items._by_id[normalized_patch.id] = item
	end

	apply_patch_fields(item, normalized_patch)

	return item
end

--- Set one item's visible flag directly
-- @param id string item id
-- @param visible boolean desired visible flag
-- @return table|nil updated item or nil
function items.set_visible(id, visible)
	return items.update(id, { visible = visible ~= false })
end

--- Remove one registered status item
-- @param id string item id
-- @return table|nil removed item or nil
function items.remove(id)
	local item = items._by_id[id]
	if not item then
		return nil
	end

	items._by_id[id] = nil
	for index, candidate in ipairs(items._list) do
		if candidate == item then
			table.remove(items._list, index)
			break
		end
	end

	return item
end

--- Resolve the current registry into left and right entry lists
-- @param opts table render options with separator strings
-- @return table left entry list
-- @return table right entry list
-- @return table context snapshot
function items.resolve(opts)
	items.ensure_defaults_registered()

	local context = build_context()
	opts = opts or {}
	context.separator = opts.separator or "    "
	context.separator2 = opts.separator2 or "  |  "

	return build_side_entries("left", context, opts), build_side_entries("right", context, opts), context
end

--- Show one item by id
-- @return nil
function item_methods:show()
	items.set_visible(self.id, true)
end

--- Hide one item by id
-- @return nil
function item_methods:hide()
	items.set_visible(self.id, false)
end

--- Apply one item patch in place
-- @param patch table field patch
-- @return table|nil updated item or nil
function item_methods:update(patch)
	return items.update(self.id, patch)
end

return items
