-- lx-bundle:true

--- Shared document option normalization and application helpers
-- Keep document option policy outside 'core.init' so other modules can require it directly
-- @module core.doc.options

local config = require("core.config")

local doc_options = {}

--- Return the live core module when it is already loaded
-- This stays lazy to avoid creating a hard load cycle from option helpers back into 'core'
-- @return table|nil core
local function get_core()
	return package.loaded["core"]
end

--- Resolve a format by its user-facing name
-- @param name string format name
-- @return table|nil format_definition
local function resolve_format_by_name(name)
	if type(name) ~= "string" or name == "" then
		return nil
	end

	local format_registry = require("core.format")
	local query = name:lower()

	if query:sub(1, 7) == "format." then
		query = query:sub(8)
	end

	if type(format_registry.get_by_name) == "function" then
		return format_registry.get_by_name(query)
	end

	return nil
end

--- Normalize one newline option value
-- @param value string|nil requested newline mode
-- @return string|nil newline
local function normalize_newline_option(value)
	if value == "lf" or value == "crlf" then
		return value
	end

	return nil
end

--- Build effective options for a new unsaved document
-- @param opts table|nil explicit overrides
-- @return table merged
function doc_options.build_new_doc_options(opts)
	local merged = {}
	local defaults = type(config.new_doc_default_options) == "table"
		and config.new_doc_default_options or {}

	if type(defaults.name) == "string" and defaults.name ~= "" then
		merged.filename = defaults.name
	end

	if type(defaults.format) == "string" and defaults.format ~= "" then
		merged.format_name = defaults.format
	end

	if type(defaults.encoding) == "string" and defaults.encoding ~= "" then
		merged.encoding = defaults.encoding
	end

	if type(defaults.codepage) == "string" and defaults.codepage ~= "" then
		merged.codepage = defaults.codepage
	end

	if type(defaults.indent_size) == "number" then
		merged.indent_size = defaults.indent_size
	end

	local default_newline = normalize_newline_option(defaults.newline)

	if default_newline then
		merged.newline = default_newline
	end

	opts = type(opts) == "table" and opts or {}

	if type(opts.filename) == "string" and opts.filename ~= "" then
		merged.filename = opts.filename
	end

	if type(opts.format_name) == "string" and opts.format_name ~= "" then
		merged.format_name = opts.format_name
	end

	if type(opts.encoding) == "string" and opts.encoding ~= "" then
		merged.encoding = opts.encoding
	end

	if type(opts.codepage) == "string" and opts.codepage ~= "" then
		merged.codepage = opts.codepage
	end

	if type(opts.indent_size) == "number" then
		merged.indent_size = opts.indent_size
	end

	local newline = normalize_newline_option(opts.newline)

	if newline then
		merged.newline = newline
	end

	return merged
end

--- Copy supported document option fields into a new table
-- @param opts table|nil source options
-- @return table cloned
function doc_options.clone_doc_options(opts)
	opts = type(opts) == "table" and opts or {}
	local cloned = {}

	if type(opts.filename) == "string" and opts.filename ~= "" then
		cloned.filename = opts.filename
	end

	if type(opts.format_name) == "string" and opts.format_name ~= "" then
		cloned.format_name = opts.format_name
	end

	if type(opts.encoding) == "string" and opts.encoding ~= "" then
		cloned.encoding = opts.encoding
	end

	if type(opts.codepage) == "string" and opts.codepage ~= "" then
		cloned.codepage = opts.codepage
	end

	if type(opts.indent_size) == "number" then
		cloned.indent_size = opts.indent_size
	end

	local newline = normalize_newline_option(opts.newline)

	if newline then
		cloned.newline = newline
	end

	return cloned
end

--- Normalize a text encoding option to internal fields
-- @param value string|nil requested encoding
-- @return table|nil normalized
function doc_options.normalize_doc_text_encoding(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, label = "UTF-8", }
	end

	if key == "utf8bom" then
		return { encoding = "utf8", bom = true, label = "UTF-8 BOM", }
	end

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

	if key == "utf16be" then
		return { encoding = "utf16be", bom = true, label = "UTF-16 BE", }
	end

	return nil
end

--- Normalize a codepage option to internal fields
-- @param value string|nil requested codepage
-- @return table|nil normalized
function doc_options.normalize_doc_codepage(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 raw == "-" then
		return { none = true, }
	end

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

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

	local codepage_name = key
	local codepage_path = DATA_DIR .. "cp/" .. codepage_name .. ".lua"

	if not system.is_file(codepage_path) then
		return nil
	end

	return { encoding = codepage_name, bom = false, label = "Codepage " .. codepage_name, }
end

--- Check whether a codepage definition exists on disk
-- @param codepage_name string|nil codepage name
-- @return boolean exists
local function has_doc_codepage_table(codepage_name)
	if type(codepage_name) ~= "string" or codepage_name == "" then
		return false
	end

	local codepage_path = DATA_DIR .. "cp/" .. codepage_name .. ".lua"

	return system.is_file(codepage_path) == true
end

--- Resolve the default text encoding for open or new docs
-- @param is_new_doc boolean use new-doc defaults when true
-- @return table normalized
local function resolve_default_doc_text_encoding(is_new_doc)
	local defaults = is_new_doc and config.new_doc_default_options or config.open_doc_default_options
	defaults = type(defaults) == "table" and defaults or {}
	local normalized = doc_options.normalize_doc_text_encoding(defaults.encoding)

	if normalized then
		return normalized
	end

	if is_new_doc then
		return { encoding = "utf8", bom = false, label = "UTF-8", }
	end

	return { auto = true, }
end

--- Resolve the default codepage for open or new docs
-- @param is_new_doc boolean use new-doc defaults when true
-- @return string|nil codepage
local function resolve_default_doc_codepage(is_new_doc)
	local defaults = is_new_doc and config.new_doc_default_options or config.open_doc_default_options
	defaults = type(defaults) == "table" and defaults or {}
	local normalized = doc_options.normalize_doc_codepage(defaults.codepage)

	if normalized then
		if normalized.none then
			return nil
		end

		if normalized.auto then
			return "auto"
		end

		if normalized.encoding then
			return normalized.encoding
		end
	end

	if is_new_doc then
		return nil
	end

	return "auto"
end

--- Build a user-facing encoding label from document metadata
-- @param doc table document instance
-- @return string label
local function build_doc_encoding_label(doc)
	local base = "UTF-8"

	if doc.file_encoding == "utf16be" then
		base = "UTF-16 BE"
	elseif doc.file_encoding == "utf16le" then
		base = "UTF-16"
	elseif doc.file_encoding == "utf8" and doc.file_bom == true then
		base = "UTF-8 BOM"
	elseif type(doc.file_encoding) == "string" and doc.file_encoding ~= "" and doc.file_encoding ~= "utf8" then
		base = tostring(doc.file_encoding)
	end

	return base
end

--- Build a user-facing codepage label when available
-- @param doc table document instance
-- @return string|nil label
local function build_doc_codepage_label(doc)
	local name = type(doc.file_codepage) == "string" and doc.file_codepage or nil

	if not has_doc_codepage_table(name) then
		return nil
	end

	return "Codepage " .. name
end

--- Refresh derived encoding labels on a document
-- @param doc table document instance
function doc_options.refresh_doc_encoding_labels(doc)
	doc.encoding_label = build_doc_encoding_label(doc)
	doc.codepage_label = build_doc_codepage_label(doc)
end

--- Build force-load options from explicit document options
-- @param source_doc_options table parsed document options
-- @return table|nil force_options
function doc_options.build_doc_force_load_options(source_doc_options)
	local force_options = {}
	local has_force = false

	if type(source_doc_options.encoding) == "string" and source_doc_options.encoding ~= "" then
		local normalized_encoding = doc_options.normalize_doc_text_encoding(source_doc_options.encoding)

		if normalized_encoding then
			if normalized_encoding.default then
				force_options.force_encoding = "default"
				has_force = true
			elseif normalized_encoding.auto then
				force_options.force_encoding = "auto"
				has_force = true
			else
				force_options.force_encoding = normalized_encoding.encoding
				force_options.force_bom = normalized_encoding.bom == true
				has_force = true
			end
		end
	end

	if type(source_doc_options.codepage) == "string" and source_doc_options.codepage ~= "" then
		local normalized_codepage = doc_options.normalize_doc_codepage(source_doc_options.codepage)

		if normalized_codepage then
			if normalized_codepage.none then
				force_options.force_codepage = "none"
				has_force = true
			elseif normalized_codepage.default then
				force_options.force_codepage = "default"
				has_force = true
			elseif normalized_codepage.auto then
				force_options.force_codepage = "auto"
				has_force = true
			else
				force_options.force_codepage = normalized_codepage.encoding
				has_force = true
			end
		end
	end

	if not has_force then
		return nil
	end

	return force_options
end

--- Build effective options for file-open operations
-- @param source_doc_options table|nil explicit document options
-- @return table merged
function doc_options.build_open_doc_options(source_doc_options)
	local merged = doc_options.clone_doc_options(source_doc_options)
	local defaults = type(config.open_doc_default_options) == "table" and config.open_doc_default_options or {}

	if merged.encoding == nil and type(defaults.encoding) == "string" and defaults.encoding ~= "" then
		merged.encoding = defaults.encoding
	end

	if merged.codepage == nil and type(defaults.codepage) == "string" and defaults.codepage ~= "" then
		merged.codepage = defaults.codepage
	end

	return merged
end

--- Normalize missing-file open behavior from config
-- @param value string|nil configured behavior
-- @return string behavior
local function normalize_missing_file_open_behavior(value)
	if type(value) == "string" then
		local key = value:lower()

		if key == "log" or key == "error" or key == "new" then
			return key
		end
	end

	return "error"
end

--- Handle an open request for a missing file
-- Keep missing-file policy with other document open option behavior
-- @param filename string requested filename
-- @param load_err string|nil original load error
-- @param open_doc_options table|nil effective document options
-- @return table|nil doc
function doc_options.handle_missing_file_open(filename, load_err, open_doc_options)
	local behavior = normalize_missing_file_open_behavior(config.missing_file_open_behavior)
	local message = string.format('Missing file "%s"', tostring(filename))
	local core = get_core()

	if behavior == "new" then
		-- Missing text files should use the text document model
		local TextDocument = require("core.text.document")
		local doc = TextDocument()
		doc.filename = system.absolute_path(filename) or filename
		doc_options.apply_doc_options_to_doc(doc, open_doc_options, true)
		doc:reset_format()
		table.insert(core.docs, doc)
		core.log('%s; created new empty doc', message)
		return doc
	end

	if behavior == "log" then
		core.log_quiet_level("error", '%s (%s)', message, tostring(load_err))

		local command = require("core.command")

		if command and command.perform then
			command.perform("log:open-last-error")
		end

		return nil
	end

	core.error('%s (%s)', message, tostring(load_err))

	return nil
end

--- Apply parsed document options to a concrete document
-- @param doc table document instance
-- @param options table|nil document options
-- @param is_new_doc boolean true for new unsaved docs
function doc_options.apply_doc_options_to_doc(doc, options, is_new_doc)
	options = type(options) == "table" and options or {}

	if options.newline == "crlf" then
		doc.crlf = true
		doc.cr_only = false
	elseif options.newline == "lf" then
		doc.crlf = false
		doc.cr_only = false
	end

	if type(options.encoding) == "string" and options.encoding ~= "" then
		local normalized_encoding = doc_options.normalize_doc_text_encoding(options.encoding)

		if normalized_encoding then
			if normalized_encoding.default then
				local default_encoding = resolve_default_doc_text_encoding(is_new_doc)

				if default_encoding and default_encoding.auto then
					-- Keep current encoding metadata for automatic mode
				elseif default_encoding and default_encoding.encoding then
					doc.file_encoding = default_encoding.encoding
					doc.file_bom = default_encoding.bom == true
				end
			elseif normalized_encoding.auto then
				-- Keep current encoding metadata for automatic mode
			else
				doc.file_encoding = normalized_encoding.encoding
				doc.file_bom = normalized_encoding.bom == true
			end
		else
			local core = get_core()

			if core and core.error then
				core.error('Unknown encoding "%s"; keeping existing encoding', options.encoding)
			end
		end
	end

	if type(options.codepage) == "string" and options.codepage ~= "" then
		local normalized_codepage = doc_options.normalize_doc_codepage(options.codepage)

		if normalized_codepage and normalized_codepage.none then
			doc.file_codepage = nil
		elseif normalized_codepage and normalized_codepage.default then
			local default_codepage = resolve_default_doc_codepage(is_new_doc)

			if default_codepage == "auto" then
				-- Keep current codepage metadata for automatic mode
			else
				doc.file_codepage = default_codepage
			end
		elseif normalized_codepage and normalized_codepage.auto then
			-- Keep current codepage metadata for automatic mode
		elseif normalized_codepage then
			doc.file_codepage = normalized_codepage.encoding
		else
			local core = get_core()

			if core and core.error then
				core.error('Unknown codepage "%s"; keeping existing codepage', options.codepage)
			end
		end
	end

	doc_options.refresh_doc_encoding_labels(doc)

	if type(options.indent_size) == "number" then
		local indent_size = math.floor(options.indent_size)

		if indent_size >= 1 then
			-- Keep file-specific indentation from changing every open document
			doc.indent_size = indent_size
		end
	end

	if type(options.format_name) == "string" and options.format_name ~= "" then
		local format_definition = resolve_format_by_name(options.format_name)

		if format_definition then
			-- Explicit document options follow the same lifetime as a picker choice
			doc.format_override = format_definition
			doc.format_detect_meta = nil
			doc.format = format_definition
			doc.kind = require("core.format").get_kind(format_definition)
			if doc.highlighter and type(doc.highlighter.reset) == "function" then
				doc.highlighter:reset()
			end
		else
			local core = get_core()

			if core and core.warning then
				core.warning('Unknown format "%s"; keeping existing format', options.format_name)
			end

			if is_new_doc and doc.filename then
				doc:reset_format()
			end
		end
	elseif is_new_doc and doc.filename then
		doc:reset_format()
	end
end

return doc_options
