-- lx-bundle:true

--- Text document file I/O, decode, and save helpers
-- @module core.text.file

local config = require("core.config")
local doc_options = require("core.doc.options") -- Share document metadata refresh through the doc option owner
local filetype = require("core.filetype") -- Share file probing through the file detection owner
local format_registry = require("core.format")
local open_handler = require("core.open_handler")
local open_mode = require("core.open_mode")
local style = require("core.style")
local codepage = require("core.text.codepage")
local convert = require("core.text.convert")
local native_text = require("text") -- Keep native probing and line preparation under one API

local file = {}
local DEFAULT_FORMAT_DETECTION_SAMPLE_SIZE = 4096

--- Return the bounded opening bytes used by format-related detection
-- @param data string complete document bytes
-- @return string header opening sample
local function format_detection_sample(data)
	data = data or ""
	local configured_size = tonumber(config.format_detection_sample_size)
	if not configured_size or configured_size ~= configured_size or configured_size < 1 then
		configured_size = DEFAULT_FORMAT_DETECTION_SAMPLE_SIZE
	end

	local sample_size = math.min(#data, math.floor(configured_size))

	return data:sub(1, sample_size)
end

--- Let an applicable open handler replace raw bytes and describe the generated document
-- Carries byte findings and format advice forward so decoding does not repeat detection
-- @param filename string source file path
-- @param data string complete source bytes
-- @param opts table|nil open request options
-- @return table result normalized load result
local function apply_open_handler(filename, data, opts)
	local source_probe = filetype.probe_data(data)
	local source_format_name = opts and opts.source_format_name
	local format_definition = type(source_format_name) == "string"
		and format_registry.get_by_name(source_format_name)
		or nil
	local format_metadata = nil

	if not format_definition then
		format_definition, format_metadata = format_registry.get(
			filename,
			format_detection_sample(data),
			false,
			source_probe
		)
	end
	local kind = format_definition and format_registry.get_kind(format_definition) or "text"
	local mode = open_mode.resolve_format(format_definition, kind, opts and opts.open_mode)
	local result, open_error, selected_handler = open_handler.open({
		filename = filename,
		data = data,
		probe = source_probe,
		format = format_definition,
		kind = kind,
		mode = mode,
		action = opts and opts.open_action,
		handler = opts and opts.open_handler,
	})

	if result == nil and open_error == nil then
		return {
			data = data,
			filename = filename,
			is_actual_file = true,
			probe = source_probe,
			format_metadata = format_metadata,
		}
	end
	if type(result) ~= "table" or type(result.data) ~= "string" then
		error(string.format(
			'Open handler "%s" failed for "%s": %s',
			tostring(selected_handler and selected_handler.name or "unknown"),
			filename,
			tostring(open_error or "handler returned no data")
		), 0)
	end
	if result.filename ~= nil and (type(result.filename) ~= "string" or result.filename == "") then
		error(string.format('Open handler returned an invalid filename for "%s"', filename), 0)
	end
	if result.initial_word_wrap ~= nil and type(result.initial_word_wrap) ~= "boolean" then
		error(string.format('Open handler returned invalid word wrap for "%s"', filename), 0)
	end

	result.filename = result.filename or filename
	result.is_actual_file = result.is_actual_file ~= false
	result.action_default_mode = opts and opts.open_action ~= nil and mode or nil
	-- A source-format handler setting remains the result presentation unless it handled the action
	local configured_handler = opts
		and opts.open_action ~= nil
		and open_handler.get_configured_name(format_definition)
		or nil
	result.action_default_handler = configured_handler ~= (selected_handler and selected_handler.name)
		and configured_handler
		or nil
	-- Generated text must be judged from its new bytes, not from its source container
	result.probe = filetype.probe_data(result.data)
	result.format_metadata = nil

	return result
end

local function get_core()
	return package.loaded["core"]
end

-- Emit a doc.save lifecycle hook stage via core.hook
local function emit_doc_save_stage(stage, payload)
	local core = get_core()
	local hook = core.hook

	if hook then
		hook.emit("doc.save", stage, payload)
	end
end

-- Normalizes paths for reliable equality checks across separators and case
local function normalize_path(path)
	if type(path) ~= "string" or path == "" then
		return nil
	end

	local absolute_path = system.absolute_path(path) or path
	absolute_path = absolute_path:gsub("\\", "/"):gsub("/+", "/")

	if #absolute_path > 1 and not absolute_path:match("^%a:/$") then
		absolute_path = absolute_path:gsub("/$", "")
	end

	if absolute_path:match("^%a:/") then
		absolute_path = absolute_path:lower()
	end

	return absolute_path
end

--- Return whether a path is the user init file or a Lua settings layer
-- @param path string|nil candidate file path
-- @return boolean is_settings_file
local function is_user_settings_file(path)
	local normalized = normalize_path(path)
	local init_file = normalize_path(USER_INIT_FILE)
	local settings_dir = normalize_path(USER_SETTINGS_DIR)
	if not normalized then
		return false
	end
	if init_file and normalized == init_file then
		return true
	end
	if not settings_dir then
		return false
	end

	local settings_prefix = settings_dir .. "/"

	return normalized:sub(1, #settings_prefix) == settings_prefix
		and normalized:sub(-4) == ".lua"
end

-- Resolve possible style module names from a style file path under data/styles or data/themes
local function style_modules_from_path(path)
	local normalized = normalize_path(path)

	if not normalized then
		return {}
	end

	local styles_dir = normalize_path(STYLES_DIR)
	local themes_dir = normalize_path(system.join_path(DATA_DIR, "themes", true))

	if not styles_dir and not themes_dir then
		return {}
	end

	local roots = {
		{ prefix = styles_dir, module = "styles", alias = "themes" },
		{ prefix = themes_dir, module = "themes", alias = "styles" },
	}

	local out = {}

	for index = 1, #roots do
		local root = roots[index]

		if root.prefix and normalized:sub(1, #root.prefix) == root.prefix then
			local relative_path = normalized:sub(#root.prefix + 1)

			if relative_path ~= "" and relative_path:sub(-4) == ".lua" then
				relative_path = relative_path:sub(1, -5)

				if relative_path ~= "" then
					local module_name = root.module .. "." .. relative_path:gsub("/", ".")
					out[#out + 1] = module_name

					if type(root.alias) == "string" and root.alias ~= "" then
						out[#out + 1] = root.alias .. "." .. relative_path:gsub("/", ".")
					end
				end
			end
		end
	end

	return out
end

--- Count trailing empty lines that represent EOF EOLs
-- @param lines table stored line list
-- @return number count number of trailing empty lines
local function count_trailing_empty(lines)
	local count = 0

	for index = #lines, 1, -1 do
		if lines[index] == "\n" then
			count = count + 1
		else
			break
		end
	end

	return count
end

--- Find the last non-empty line index
-- @param lines table stored line list
-- @return number line_index last non-empty line or 0
local function last_nonempty_line(lines)
	for index = #lines, 1, -1 do
		if lines[index] ~= "\n" then
			return index
		end
	end

	return 0
end

-- Returns true when a codepage table exists on disk
local function has_codepage_table(name)
	if type(name) ~= "string" or name == "" then
		return false
	end

	local path = DATA_DIR .. "cp/" .. name .. ".lua"

	return system.is_file(path) == true
end

-- Normalizes supported text encoding names and keywords
local function normalize_text_encoding_name(value)
	if type(value) ~= "string" then
		return nil
	end

	local raw = value:match("^%s*(.-)%s*$")

	if not raw or raw == "" then
		return nil
	end

	local key = raw:lower():gsub("%s+", ""):gsub("[_-]", "")

	if key == "default" then
		return { default = true }
	end

	if key == "auto" then
		return { auto = true }
	end

	if key == "utf8" then
		return { encoding = "utf8", bom = false }
	end

	if key == "utf8bom" then
		return { encoding = "utf8", bom = true }
	end

	if key == "utf16" or key == "utf16le" then
		return { encoding = "utf16le", bom = true }
	end

	if key == "utf16be" then
		return { encoding = "utf16be", bom = true }
	end

	return nil
end

-- Normalizes supported codepage names and keywords
local function normalize_codepage_name(value)
	if type(value) ~= "string" then
		return nil
	end

	local raw = value:match("^%s*(.-)%s*$")

	if not raw or raw == "" then
		return nil
	end

	local key = raw:lower()

	if key == "none" or key == "-" then
		return { none = true }
	end

	if key == "default" then
		return { default = true }
	end

	if key == "auto" then
		return { auto = true }
	end

	if has_codepage_table(key) then
		return { name = key }
	end

	return nil
end

-- Resolve default load-time text encoding from open-doc defaults
local function resolve_default_text_encoding_spec()
	local defaults = type(config.open_doc_default_options) == "table" and config.open_doc_default_options or {}
	local normalized = normalize_text_encoding_name(defaults.encoding)

	if normalized and normalized.auto then
		return nil
	end

	if normalized and normalized.encoding then
		return { encoding = normalized.encoding, bom = normalized.bom == true }
	end

	return nil
end

-- Resolve default codepage from config
local function resolve_default_codepage_name()
	local defaults = type(config.open_doc_default_options) == "table" and config.open_doc_default_options or {}
	local normalized = normalize_codepage_name(defaults.codepage)

	if normalized and (normalized.none or normalized.auto) then
		return nil
	end

	if normalized and normalized.name then
		return normalized.name
	end

	return nil
end

--- Return one supported automatic codepage proposed by format or fallback rules
-- A complete native scan is only paid for when the bounded UTF-8 result is unsure
-- @param data string complete source bytes
-- @param probe table native byte findings
-- @param format_metadata table|nil format detection advice
-- @return string|nil codepage_name
local function detect_auto_codepage(data, probe, format_metadata)
	local detected = type(format_metadata) == "table" and format_metadata.codepage or nil

	if type(detected) == "string" and detected ~= "" then
		detected = detected:lower()
	end
	if not has_codepage_table(detected) then
		detected = nil
	end

	local fallback = type(config.fallback_codepage) == "string"
		and config.fallback_codepage:lower() or nil

	if not has_codepage_table(fallback) then
		fallback = nil
	end
	if not detected and not fallback then
		return nil
	end

	local bom = type(probe) == "table" and probe.bom or nil
	local utf8 = type(probe) == "table" and probe.utf8 or nil
	local utf16 = type(probe) == "table" and probe.utf16 or nil
	local binary = type(probe) == "table" and probe.binary or nil

	-- BOM, UTF-16, and binary evidence always outrank a legacy-codepage suggestion
	if bom
		or (utf16 and utf16.status == "valid")
		or (binary and binary.status == "valid")
	then
		return nil
	end

	if not utf8 or utf8.status == "unsure" then
		-- Finish the scan before interpreting unchecked bytes as a legacy codepage
		probe = filetype.probe_data(data, math.max(#data, 1))
		bom = probe.bom
		utf8 = probe.utf8
		utf16 = probe.utf16
		binary = probe.binary
	end

	if bom
		or not utf8
		or utf8.status ~= "invalid"
		or (utf16 and utf16.status == "valid")
		or (binary and binary.status == "valid")
	then
		return nil
	end

	return detected or fallback
end

--- Resolve forced decode options from load opts and config defaults
-- @param data string complete source bytes
-- @param opts table|nil open options
-- @param probe table native byte findings
-- @param format_metadata table|nil format detection advice
-- @return table resolved options
local function resolve_forced_decode_options(data, opts, probe, format_metadata)
	opts = type(opts) == "table" and opts or {}
	local forced_encoding_spec = nil
	local forced_codepage_name = nil
	local has_explicit_codepage = false

	local encoding_opt = normalize_text_encoding_name(opts.force_encoding)

	if encoding_opt then
		if encoding_opt.default then
			forced_encoding_spec = resolve_default_text_encoding_spec()
		elseif encoding_opt.auto then
			forced_encoding_spec = nil
		else
			local forced_bom = opts.force_bom == true
			forced_encoding_spec = {
				encoding = encoding_opt.encoding,
				bom = forced_bom and true or encoding_opt.bom == true,
			}
		end
	end

	local codepage_opt = normalize_codepage_name(opts.force_codepage)

	if codepage_opt then
		has_explicit_codepage = true

		if codepage_opt.none then
			forced_codepage_name = nil
		elseif codepage_opt.default then
			forced_codepage_name = resolve_default_codepage_name()
		elseif codepage_opt.auto then
			local detected = detect_auto_codepage(data, probe, format_metadata)

			if type(detected) == "string" and has_codepage_table(detected:lower()) then
				forced_codepage_name = detected:lower()
			end
		else
			forced_codepage_name = codepage_opt.name
		end
	end

	return {
		forced_encoding_spec = forced_encoding_spec,
		forced_codepage_name = forced_codepage_name,
		has_explicit_codepage = has_explicit_codepage,
	}
end

--- Decode raw file bytes into UTF-8 text with encoding metadata
-- @param filename string source file path
-- @param data string complete source bytes
-- @param opts table|nil open options
-- @param core table live core module
-- @param probe table native byte findings
-- @param format_metadata table|nil format detection advice
-- @return table decoded result
local function decode_document_bytes(filename, data, opts, core, probe, format_metadata)
	probe = type(probe) == "table" and probe or filetype.probe_data(data)

	local resolved = resolve_forced_decode_options(data, opts, probe, format_metadata)
	local bom = type(probe.bom) == "table" and probe.bom or nil
	local bom_encoding = bom and bom.status == "valid" and bom.encoding or nil
	local utf16 = type(probe.utf16) == "table" and probe.utf16 or {}
	core.log_quiet(
		"[doc-load] decode request file=%s force_encoding=%s force_codepage=%s explicit_codepage=%s",
		tostring(filename),
		tostring(resolved.forced_encoding_spec and resolved.forced_encoding_spec.encoding or nil),
		tostring(resolved.forced_codepage_name),
		tostring(resolved.has_explicit_codepage)
	)
	local decode_kind
	local had_decode_errors = false
	local loaded_encoding = "utf8"
	local loaded_bom = false
	local loaded_codepage = nil
	local requested_codepage = type(config.doc_default_codepage) == "string" and config.doc_default_codepage or nil

	if requested_codepage and not has_codepage_table(requested_codepage) then
		requested_codepage = nil
	end

	if resolved.forced_encoding_spec then
		local encoding = resolved.forced_encoding_spec.encoding

		if encoding == "utf16le" then
			data, had_decode_errors = convert.utf16le_to_utf8(data)
			decode_kind = "Forced UTF-16LE"
			loaded_encoding = "utf16le"
			loaded_bom = resolved.forced_encoding_spec.bom == true
		elseif encoding == "utf16be" then
			data, had_decode_errors = convert.utf16be_to_utf8(data)
			decode_kind = "Forced UTF-16BE"
			loaded_encoding = "utf16be"
			loaded_bom = resolved.forced_encoding_spec.bom == true
		else
			if data:sub(1, 3) == "\239\187\191" then
				data = data:sub(4)
			end

			decode_kind = "Forced UTF-8"
			loaded_encoding = "utf8"
			loaded_bom = resolved.forced_encoding_spec.bom == true
		end
	elseif resolved.forced_codepage_name and resolved.forced_codepage_name ~= "utf8" then
		local decoded, codepage_errors, codepage_error = codepage.decode(data, resolved.forced_codepage_name, "?")

		if decoded then
			data = decoded
			had_decode_errors = codepage_errors or false
			decode_kind = "Forced codepage " .. resolved.forced_codepage_name
			loaded_encoding = "utf8"
			loaded_bom = false
			loaded_codepage = resolved.forced_codepage_name
		else
			core.error('Failed codepage decode "%s": %s', tostring(resolved.forced_codepage_name), tostring(codepage_error))
		end
	elseif bom_encoding == "utf16le" then
		data, had_decode_errors = convert.utf16le_to_utf8(data)
		decode_kind = "UTF-16LE"
		loaded_encoding = "utf16le"
		loaded_bom = true
	elseif bom_encoding == "utf16be" then
		data, had_decode_errors = convert.utf16be_to_utf8(data)
		decode_kind = "UTF-16BE"
		loaded_encoding = "utf16be"
		loaded_bom = true
	elseif bom_encoding == "utf8" then
		data = data:sub(4)
		decode_kind = "UTF-8 BOM"
		loaded_encoding = "utf8"
		loaded_bom = true
	else
		local hint = utf16.status == "valid" and utf16.endianness or nil

		if hint == "le" then
			data, had_decode_errors = convert.utf16le_to_utf8(data)
			decode_kind = "UTF-16LE no BOM"
			loaded_encoding = "utf16le"
			loaded_bom = false
		elseif hint == "be" then
			data, had_decode_errors = convert.utf16be_to_utf8(data)
			decode_kind = "UTF-16BE no BOM"
			loaded_encoding = "utf16be"
			loaded_bom = false
		else
			local use_codepage = not resolved.has_explicit_codepage
				and requested_codepage
				and requested_codepage ~= ""
				and requested_codepage ~= "utf8"

			if use_codepage then
				local decoded, codepage_errors, codepage_error = codepage.decode(data, requested_codepage, "?")

				if decoded then
					data = decoded
					had_decode_errors = codepage_errors or false
					decode_kind = "Codepage " .. requested_codepage
					loaded_encoding = "utf8"
					loaded_bom = false
					loaded_codepage = requested_codepage
				else
					core.error('Failed codepage decode "%s": %s', tostring(requested_codepage), tostring(codepage_error))
				end
			end
		end
	end

	return {
		data = data,
		decode_kind = decode_kind,
		had_decode_errors = had_decode_errors,
		loaded_encoding = loaded_encoding,
		loaded_bom = loaded_bom,
		loaded_codepage = loaded_codepage,
	}
end

-- Expose shared EOF helpers for the text document model and edit helpers
file.count_trailing_empty = count_trailing_empty
file.last_nonempty_line = last_nonempty_line

--- Load text file contents and preserve original trailing-EOL characteristics
-- @param self table text document instance
-- @param filename string file path
-- @param opts table|nil open options
function file.open(self, filename, opts)
--[[
	Encoding/BOM round-trip contract:
	- Files are edited in-memory as UTF-8 text no BOM bytes in document lines
	- On load we detect encoding and BOM, then store:
		self.file_encoding in { "utf8", "utf16le", "utf16be" }
		self.file_bom as boolean
	- UTF-8 BOM EF BB BF is stripped from in-memory text but remembered
	- UTF-16 LE/BE input is decoded to UTF-8 before line parsing, BOM presence
		and endianness are remembered
	- On save we rebuild UTF-8 text from lines, then:
		* if utf16le/utf16be: transcode UTF-8 -> target UTF-16 variant and write
		BOM only when self.file_bom == true
		* if utf8 + BOM tracked: prepend UTF-8 BOM before writing
		* otherwise write plain UTF-8 bytes
	This guarantees format + BOM presence round-trip for UTF-8 and UTF-16 files
]]
	local core = get_core()
	local warn_ms = math.max(1, tonumber(config.debug_doc_load_warn_ms) or 20)
	local debug_timings = config.debug_doc_load == true
	local load_start_time = system.get_time()

	local function log_doc_timing(label, start_time)
		local duration_ms = (system.get_time() - start_time) * 1000

		if debug_timings or duration_ms >= warn_ms then
			core.log_quiet("%s in %.1fms", label, duration_ms)
		end
	end

	local read_start_time = system.get_time()
	local data, read_error = system.read_file(filename)

	if not data then
		error(string.format('Failed to read "%s": %s', filename, read_error or "unknown read error"), 0)
	end

	log_doc_timing(string.format('Doc load read "%s"', filename), read_start_time)

	local load_result = apply_open_handler(filename, data, opts)
	data = load_result.data
	-- A raw result view needs the transformed bytes because its generated filename is not on disk
	local requested_handler = opts
		and type(opts.open_handler) == "string"
		and opts.open_handler:lower()
		or nil
	local generated_binary_source = opts
		and opts.open_action ~= nil
		and (requested_handler == "hex" or requested_handler == "bytes")
		and data
		or nil

	local decode_start_time = system.get_time()
	local decoded = decode_document_bytes(
		filename,
		data,
		opts,
		core,
		load_result.probe,
		load_result.format_metadata
	)
	data = decoded.data
	local decode_kind = decoded.decode_kind
	local had_decode_errors = decoded.had_decode_errors
	local loaded_encoding = decoded.loaded_encoding
	local loaded_bom = decoded.loaded_bom
	local loaded_codepage = decoded.loaded_codepage

	core.log_quiet(
		"[doc-load] decode result file=%s kind=%s encoding=%s bom=%s codepage=%s decode_errors=%s",
		tostring(filename),
		tostring(decode_kind),
		tostring(loaded_encoding),
		tostring(loaded_bom),
		tostring(loaded_codepage),
		tostring(had_decode_errors)
	)

	log_doc_timing(string.format('Doc load decode "%s"', filename), decode_start_time)

	if had_decode_errors then
		core.log('Opened "%s" with %s decoding invalid sequences replaced', filename, decode_kind)
	else
		core.log_quiet('Opened "%s" with %s decoding', filename, decode_kind)
	end

	-- Reset document ownership before native line construction installs the decoded content
	local init_start_time = system.get_time()

	self:reset()
	self.filename = load_result.filename
	self.source_filename = load_result.source_filename
	self.source_read_only = load_result.source_read_only == true
	self.is_actual_file = load_result.is_actual_file
	self.binary_source_data = generated_binary_source
	self.save_as_required = load_result.save_as_required == true
	self.initial_word_wrap = load_result.initial_word_wrap
	self.action_default_mode = load_result.action_default_mode
	self.action_default_handler = load_result.action_default_handler
	self.file_encoding = loaded_encoding
	self.file_bom = loaded_bom
	self.file_codepage = loaded_codepage

	doc_options.refresh_doc_encoding_labels(self)

	log_doc_timing(string.format('Doc load init "%s"', filename), init_start_time)

	-- Build canonical document rows natively and reuse its line-ending statistics
	local parse_start_time = system.get_time()
	local lines, line_stats = native_text.split_lines(data)
	self.lines = lines
	self.longest_line_length = line_stats.longest_line_length
	self.longest_line_index = line_stats.longest_line_index
	self.crlf = line_stats.eol_sequence == "\r\n"
	self.cr_only = line_stats.eol_sequence == "\r"
	self.eof_eol_sequence = line_stats.eof_eol_sequence
	self.eof_eol_count = line_stats.eof_eol_count

	core.log_quiet(
		"[doc-load] lines result file=%s bytes=%d lines=%d longest_line=%d longest_bytes=%d",
		tostring(filename),
		line_stats.input_size,
		line_stats.line_count,
		line_stats.longest_line_index,
		line_stats.longest_line_length
	)

	log_doc_timing(string.format('Doc load lines "%s"', filename), parse_start_time)

	local format_start_time = system.get_time()
	self:reset_format()
	log_doc_timing(string.format('Doc load format "%s"', filename), format_start_time)
	log_doc_timing(string.format('Doc load total "%s"', filename), load_start_time)
end

--- Return the disk path that owns the current document content
-- Generated unsaved documents continue to reload from their immutable source
-- @param self table text document instance
-- @return string filename reload source path
function file.get_reopen_filename(self)
	return self.is_actual_file and self.filename
		or self.source_filename
		or self.filename
end

--- Reopen the current text document from disk
-- @param self table text document instance
-- @param opts table|nil reopen options
function file.reopen(self, opts)
	assert(self.filename, "no filename set to reopen")
	local previous_state = {}
	local reopen_filename = self:get_reopen_filename()

	for key, value in pairs(self) do
		previous_state[key] = value
	end

	local result = table.pack(pcall(self.open, self, reopen_filename, opts))
	if not result[1] then
		-- Restore the complete live document if loading reset any state before failing
		for key in pairs(self) do
			self[key] = nil
		end
		for key, value in pairs(previous_state) do
			self[key] = value
		end

		error(result[2], 0)
	end

	-- Give replacement content a new identity so views discard caches from the old file
	self._history_branch = (previous_state._history_branch or 1) + 1
	self._cached_change_branch = nil
	self._cached_change_index = nil
	self._cached_change_id = nil
	self:clean()

	return table.unpack(result, 2, result.n)
end

--- Save text document lines to disk while preserving configured EOL behavior
-- @param self table text document instance
-- @param filename string|nil target filename override
-- @param opts table|nil reserved save options
-- @return boolean|nil ok save result
-- @return string|nil err error code when save is blocked
function file.save(self, filename, opts)
	local save_start_time = system.get_time()
	local effective_filename = filename or self.filename
	local save_as = filename ~= nil and filename ~= self.filename
	local core = get_core()
	local hook = core.hook
	local normalized_filename = hook and hook.path and hook.path.normalize
		and hook.path.normalize(effective_filename) or normalize_path(effective_filename)

	local function emit_save_error(err)
		emit_doc_save_stage("error", {
			doc = self,
			filename = effective_filename,
			normalized_filename = normalized_filename,
			save_as = save_as,
			source = "doc.save",
			ok = false,
			duration_ms = (system.get_time() - save_start_time) * 1000,
			err = err,
		})
	end

	if self.is_binary_placeholder then
		emit_save_error("save_disabled")

		return nil, "save_disabled"
	end

	if self.save_disabled then
		core.error('Save disabled for "%s"', self:get_name())
		emit_save_error("save_disabled")

		return nil, "save_disabled"
	end
	if self.source_read_only and normalize_path(self.source_filename) == normalized_filename then
		core.error('Cannot overwrite read-only source "%s"', tostring(self.source_filename))
		emit_save_error("source_read_only")

		return false, "source_read_only"
	end

	filename = filename or assert(self.filename, "no filename set to default to")
	effective_filename = filename
	normalized_filename = hook and hook.path and hook.path.normalize
		and hook.path.normalize(effective_filename) or normalize_path(effective_filename)

	emit_doc_save_stage("before", {
		doc = self,
		filename = effective_filename,
		normalized_filename = normalized_filename,
		save_as = save_as,
		source = "doc.save",
	})

	local out = {}
	local last = #self.lines
	local last_eol = self.cr_only and "\r" or ((self.crlf and "\r\n") or "\n")
	local eof_sequence = self.eof_eol_sequence ~= "" and self.eof_eol_sequence or last_eol

	for index, line in ipairs(self.lines) do
		if index == last then
			if self.eof_eol_count == 0 and line:sub(-1) == "\n" then
				-- Strip the final LF to preserve no EOL at EOF files
				line = line:sub(1, -2)
			elseif self.eof_eol_count > 0 and line == "\n" then
				-- Drop one virtual empty line to keep trailing EOL count stable
				line = ""
			end
		end

		if line:sub(-1) == "\n" then
			local head = line:sub(1, -2)
			local eol = (index == last) and eof_sequence or last_eol
			line = head .. eol
		end

		out[#out + 1] = line
	end

	local text = table.concat(out)
	local bytes = text
	local legacy_encoding

	if self.file_encoding == "utf16le" or self.file_encoding == "utf16be" then
		bytes = select(1, convert.utf8_to_utf16(text, self.file_encoding, self.file_bom))
	elseif self.file_encoding == "utf8" and self.file_bom then
		bytes = "\239\187\191" .. text
	elseif self.file_encoding ~= "utf8" then
		legacy_encoding = self.file_encoding
	elseif type(self.file_codepage) == "string" and self.file_codepage ~= "" then
		legacy_encoding = self.file_codepage
	end

	-- Legacy saves must remain lossless, so reject replacement output before writing
	if legacy_encoding then
		local encoded, encode_errors, encode_error = codepage.encode(text, legacy_encoding, "?")

		if not encoded then
			if core then
				core.error('Save failed: invalid codepage "%s" (%s)', tostring(legacy_encoding), tostring(encode_error))
			end

			emit_save_error(encode_error or "invalid_codepage")

			return false
		end

		if encode_errors then
			local error_message = string.format(
				'text contains characters that cannot be saved using codepage "%s"',
				tostring(legacy_encoding)
			)

			if core then
				core.error('Save failed for "%s": %s', filename, error_message)
			end

			emit_save_error(error_message)

			return false, error_message
		end

		bytes = encoded
	end

	local write_ok, write_error = system.write_file_atomic(filename, bytes)

	if not write_ok then
		local error_message = tostring(write_error or "file write failed")
		core.error('Save failed for "%s": %s', filename, error_message)
		emit_save_error(error_message)

		return false, error_message
	end

	self.filename = filename or self.filename
	self.is_actual_file = true
	self.save_as_required = false
	self:reset_format()
	self:clean()

	-- Reload the controlling init file so every selected settings layer is applied in order
	if is_user_settings_file(self.filename) then
		local ok, err = pcall(core.reload_module, "user")

		if not ok then
			core.error('Failed to reload "%s": %s', "user", tostring(err))
		else
			core.log('Reloaded "%s" after save', "user")
		end
	end

	-- Auto-reload saved style module when its data/styles/*.lua module is loaded
	local style_modules = style_modules_from_path(self.filename)

	if #style_modules > 0 then
		local did_reload = false
		local seen = {}

		for index = 1, #style_modules do
			local style_module = style_modules[index]

			if not seen[style_module] then
				seen[style_module] = true
				local should_reload = package.loaded[style_module] ~= nil

				if not should_reload and type(config.current_style_module) == "string" and config.current_style_module == style_module then
					should_reload = true
				end

				if should_reload then
					local ok, err = pcall(core.reload_module, style_module)

					if not ok then
						core.error('Failed to reload "%s": %s', tostring(style_module), tostring(err))
					else
						core.log('Reloaded "%s" after save', tostring(style_module))
						did_reload = true
					end
				end
			end
		end

		if not did_reload then
			core.log_quiet('[doc-save] style save detected but no loaded style module matched file=%s', tostring(self.filename))
		end
	end

	emit_doc_save_stage("after", {
		doc = self,
		filename = self.filename,
		normalized_filename = hook and hook.path and hook.path.normalize
			and hook.path.normalize(self.filename) or normalize_path(self.filename),
		save_as = save_as,
		source = "doc.save",
		ok = true,
		duration_ms = (system.get_time() - save_start_time) * 1000,
	})

	if core then
		local duration_ms = (system.get_time() - save_start_time) * 1000
		core.log('Saved "%s" in %.1fms', self.filename, duration_ms)
	end

	return true
end

--- Save the current document to its existing filename
-- @param self table text document instance
-- @return boolean|nil ok save result
-- @return string|nil err error code when save is blocked
function file.save_current(self)
	assert(self.filename, "no filename set to save_current")

	return self:save(self.filename)
end

--- Rename a document on disk and remove the previous file path
-- @param self table text document instance
-- @param filename string new filename
-- @return boolean ok rename result
-- @return string|nil err error message when rename fails
function file.rename(self, filename)
	local old_filename = self.filename

	if not old_filename then
		return false, "Cannot rename unsaved doc"
	end

	local start_time = system.get_time()
	local ok = self:save(filename)

	if not ok then
		return false
	end

	if filename ~= old_filename then
		local removed, remove_error = os.remove(old_filename)

		if not removed then
			local err = string.format(
				'Saved as "%s", but could not remove old file "%s": %s',
				filename,
				old_filename,
				remove_error or "unknown remove error"
			)
			local core = get_core()
			core.error("%s", err)

			return false, err
		end
	end

	local core = get_core()

	if core then
		local duration_ms = (system.get_time() - start_time) * 1000
		core.log('Renamed "%s" to "%s" in %.1fms', old_filename, filename, duration_ms)
	end

	return true
end

--- Build fuzzy-pick items from available codepage modules on disk
-- @return table items picker items
function file.get_codepage_suggest_items()
	-- Scan codepage files on demand so 'doc:set-codepage' always reflects current disk state
	local codepage_directory = DATA_DIR .. "cp"
	local codepage_files = system.list_dir(codepage_directory) or {}
	local codepage_names = {}
	local items = {}
	local mt = {
		__tostring = function(item)
			return item.text
		end,
	}

	for index = 1, #codepage_files do
		local codepage_file = codepage_files[index]

		-- Keep only Lua table modules in data/cp/ and strip the '.lua' suffix
		if type(codepage_file) == "string" and codepage_file:sub(-4) == ".lua" then
			codepage_names[#codepage_names + 1] = codepage_file:sub(1, -5)
		end
	end

	table.sort(codepage_names)

	for index = 1, #codepage_names do
		local name = codepage_names[index]
		items[#items + 1] = setmetatable({ text = name, info = "codepage" }, mt)
	end

	return items
end

--- Apply codepage metadata and optionally reload file-backed docs
-- @param doc table text document instance
-- @param view table|nil active text view
-- @param name string codepage name
-- @return boolean ok apply result
-- @return string|nil err error message when application fails
function file.apply_codepage(doc, view, name)
	if not name or name == "" then
		return false, "Missing codepage name"
	end

	local codepage_name = tostring(name):lower()
	local codepage_path = DATA_DIR .. "cp/" .. codepage_name .. ".lua"

	if not system.is_file(codepage_path) then
		return false, string.format('Unknown codepage "%s"', tostring(name))
	end

	if doc.filename and doc:is_dirty() then
		return false, "Save or discard changes before changing codepage"
	end

	if doc.filename then
		local line, col = doc:get_selection()
		local ok = pcall(function()
			doc:reopen({ force_codepage = codepage_name })
		end)

		if not ok then
			return false, string.format('Failed to apply codepage "%s"', tostring(codepage_name))
		end
		doc:set_selection(line, col)

		if view and view.scroll_to_line then
			view:scroll_to_line(line, false, true)
		end

		local core = get_core()

		if core then
			core.log('Reopened "%s" with codepage %s', doc.filename, codepage_name)
		end
	end

	-- Commit codepage metadata only after any required disk reload succeeds
	doc.file_codepage = codepage_name
	config.doc_default_codepage = codepage_name
	doc_options.refresh_doc_encoding_labels(doc)

	return true
end

return file
