-- 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 style = require("core.style")
local codepage = require("core.text.codepage")
local convert = require("core.text.convert")

local file = {}

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

-- Returns true when the saved file path targets data/user/settings.lua
local function is_user_settings_file(path)
	local target = normalize_path(USER_SETTINGS_FILE or (DATA_DIR .. "user/settings.lua"))
	local normalized = normalize_path(path)

	return normalized ~= nil and target ~= nil and normalized == target
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

--- Detect trailing EOL sequence and repeated EOF EOL count from a byte string
-- @param text string raw byte string
-- @return string sequence trailing EOL sequence or empty string
-- @return number count number of repeated trailing EOLs
local function detect_trailing_eol(text)
	local len = #text

	if len == 0 then
		return "", 0
	end

	local sequence = ""
	local count = 0
	local pos = len
	local last_byte = text:byte(pos)

	if last_byte == 10 then
		if pos > 1 and text:byte(pos - 1) == 13 then
			sequence = "\r\n"
			pos = pos - 2
		else
			sequence = "\n"
			pos = pos - 1
		end
	elseif last_byte == 13 then
		sequence = "\r"
		pos = pos - 1
	else
		return "", 0
	end

	count = 1

	if sequence == "\r\n" then
		while pos > 1 and text:byte(pos - 1) == 13 and text:byte(pos) == 10 do
			count = count + 1
			pos = pos - 2
		end
	elseif sequence == "\n" then
		while pos >= 1 and text:byte(pos) == 10 do
			count = count + 1
			pos = pos - 1
		end
	else
		while pos >= 1 and text:byte(pos) == 13 do
			count = count + 1
			pos = pos - 1
		end
	end

	return sequence, count
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

-- Ask syntax-specific hooks for an automatic codepage choice
local function detect_auto_codepage(filename, data, opts)
	-- Resolve syntax before decode so legacy formats can opt into a codepage
	local ok, syntax = pcall(require, "core.syntax")

	if ok and type(syntax) == "table" and type(syntax.get) == "function" then
		local _, detect_meta = syntax.get(filename, data or "", false)
		local detected = type(detect_meta) == "table" and detect_meta.codepage or nil

		if type(detected) == "string" and detected ~= "" then
			detected = detected:lower()

			if has_codepage_table(detected) then
				return detected
			end
		end
	end

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

	if not fallback or not has_codepage_table(fallback) then
		return nil
	end

	local probe = filetype.detect_file_type(filename)

	if probe and probe.is_binary == false and probe.encoding == "ANSI" then
		return fallback
	end

	return nil
end

-- Resolve forced decode options from load opts and config defaults
local function resolve_forced_decode_options(filename, data, opts)
	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(filename, data, opts)

			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
local function decode_document_bytes(filename, data, opts, core)
	local resolved = resolve_forced_decode_options(filename, data, opts)
	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 data:sub(1, 2) == "\255\254" then
		data, had_decode_errors = convert.utf16le_to_utf8(data)
		decode_kind = "UTF-16LE"
		loaded_encoding = "utf16le"
		loaded_bom = true
	elseif data:sub(1, 2) == "\254\255" then
		data, had_decode_errors = convert.utf16be_to_utf8(data)
		decode_kind = "UTF-16BE"
		loaded_encoding = "utf16be"
		loaded_bom = true
	elseif data:sub(1, 3) == "\239\187\191" then
		data = data:sub(4)
		decode_kind = "UTF-8 BOM"
		loaded_encoding = "utf8"
		loaded_bom = true
	else
		local hint
		local probe = filetype.detect_file_type(filename)

		if probe and probe.is_utf16 then
			if probe.utf16_hint == "utf16le_no_bom" then
				hint = "le"
			elseif probe.utf16_hint == "utf16be_no_bom" then
				hint = "be"
			end
		end

		hint = hint or convert.detect_utf16_endianness(data)

		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
file.detect_trailing_eol = detect_trailing_eol

--- 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 file_handle = assert(io.open(filename, "rb"))
	local data = file_handle:read("*a") or ""
	file_handle:close()

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

	local decode_start_time = system.get_time()
	local decoded = decode_document_bytes(filename, data, opts, core)
	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

	-- Detect trailing EOL sequence and count to preserve final empty lines
	local init_start_time = system.get_time()
	local trailing_sequence, trailing_count = detect_trailing_eol(data)

	self:reset()
	self.filename = filename
	self.is_actual_file = true
	self.file_encoding = loaded_encoding
	self.file_bom = loaded_bom
	self.file_codepage = loaded_codepage

	doc_options.refresh_doc_encoding_labels(self)

	-- Remember original EOF EOL sequence and count for save round-trips
	self.eof_eol_sequence = trailing_sequence
	self.eof_eol_count = trailing_count
	self.lines = {}
	self.longest_line_length = 0
	self.longest_line_index = 1

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

	local parse_start_time = system.get_time()
	local has_cr = data:find("\r", 1, true) ~= nil
	local line_count = 0

	local function push_line(line)
		local logical_length = #line

		if has_cr and line:byte(-1) == 13 then
			line = line:sub(1, -2)
			self.crlf = true
			logical_length = logical_length - 1
		end

		line_count = line_count + 1
		self.lines[line_count] = line .. "\n"

		if logical_length > self.longest_line_length then
			self.longest_line_length = logical_length
			self.longest_line_index = line_count
		end
	end

	local start = 1

	while true do
		local newline_index = data:find("\n", start, true)

		if not newline_index then
			break
		end

		push_line(data:sub(start, newline_index - 1))
		start = newline_index + 1
	end

	local tail = data:sub(start)

	if tail ~= "" then
		push_line(tail)
	end

	-- Preserve all trailing EOLs by adding only missing empty final lines
	local existing_trailing = count_trailing_empty(self.lines)
	local missing = trailing_count - existing_trailing

	for _ = 1, math.max(0, missing) do
		table.insert(self.lines, "\n")
	end

	if #self.lines == 0 then
		table.insert(self.lines, "\n")
	end

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

	local syntax_start_time = system.get_time()
	self:reset_syntax()
	log_doc_timing(string.format('Doc load syntax "%s"', filename), syntax_start_time)
	log_doc_timing(string.format('Doc load total "%s"', filename), load_start_time)
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")

	return self:open(self.filename, opts)
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

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

	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
		local encoded, encode_errors, encode_error = codepage.encode(text, self.file_encoding, "?")

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

			emit_save_error(encode_error or "invalid_codepage")

			return false
		end

		if encode_errors and core then
			core.warning('Save warning: unmappable chars replaced for codepage "%s"', tostring(self.file_encoding))
		end

		bytes = encoded
	elseif type(self.file_codepage) == "string" and self.file_codepage ~= "" then
		local encoded, encode_errors, encode_error = codepage.encode(text, self.file_codepage, "?")

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

			emit_save_error(encode_error or "invalid_codepage")

			return false
		end

		if encode_errors and core then
			core.warning('Save warning: unmappable chars replaced for codepage "%s"', tostring(self.file_codepage))
		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:reset_syntax()
	self:clean()

	-- Auto-reload user runtime config when data/user/settings.lua is saved
	if is_user_settings_file(self.filename) then
		local ok, err = pcall(core.reload_module, "user.settings")

		if not ok then
			core.error('Failed to reload "%s": %s', "user.settings", tostring(err))
		else
			core.log('Reloaded "%s" after save', "user.settings")
		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

	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

	if filename ~= old_filename then
		os.remove(old_filename)
	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

	-- Always persist the selected codepage on the active document state
	doc.file_codepage = codepage_name
	config.doc_default_codepage = codepage_name
	doc_options.refresh_doc_encoding_labels(doc)

	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.file_codepage = codepage_name
		doc_options.refresh_doc_encoding_labels(doc)
		doc:set_selection(line, col)

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

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

	return true
end

return file
