-- lx-bundle:true

--- Document lifecycle creation and open helpers
-- Keep universal document orchestration under the 'core.doc' module family
-- @module core.doc.lifecycle

local doc_options = require("core.doc.options")
local dev_details = require("core.dev.details")
local filetype = require("core.file.type")
local image_support = require("core.image.support")
local config = require("core.config")
local open_handler = require("core.open_handler")
local open_mode = require("core.open_mode")

local lifecycle = {}
local DEFAULT_FORMAT_SAMPLE_BYTES = 4096

--- Build text loading options once for new and reused location requests
-- @param opts table lifecycle open options
-- @param force_load_options table|nil forced decoding options
-- @return table text_options
local function build_text_open_options(opts, force_load_options)
	local text_options = {}

	for option_name, option_value in pairs(force_load_options or {}) do
		text_options[option_name] = option_value
	end

	text_options.open_mode = opts.open_mode
	text_options.open_handler = opts.open_handler
	text_options.open_action = opts.open_action
	text_options.source_format_name = opts.source_format_name
	text_options.retain_location_source = opts.retain_location_source == true

	return text_options
end

--- Return the configured byte limit used by format detection
-- @return number maximum positive byte count
local function format_sample_size()
	local configured = tonumber(config.format_detection_sample_size)

	if not configured or configured ~= configured or configured < 1 then
		return DEFAULT_FORMAT_SAMPLE_BYTES
	end

	return math.floor(configured)
end

--- Return the live core module when it is already loaded
-- @return table|nil core
local function get_core()
	return package.loaded["core"]
end

--- Return one friendly value without raw Lua nil and boolean wording
-- @param value any value to describe
-- @return string text
local function describe_value(value)
	if value == nil then
		return "none"
	elseif type(value) == "boolean" then
		return value and "yes" or "no"
	end

	return tostring(value)
end

--- Append one document state without hiding any opening metadata
-- @param lines table report lines
-- @param heading string section heading
-- @param state table document state
local function append_document_state(lines, heading, state)
	lines[#lines + 1] = heading .. ":"
	lines[#lines + 1] = "Encoding: " .. describe_value(state.file_encoding)
	lines[#lines + 1] = "BOM: " .. describe_value(state.file_bom)
	lines[#lines + 1] = "Codepage: " .. describe_value(state.file_codepage)
	lines[#lines + 1] = "Encoding label: " .. describe_value(state.encoding_label)
	lines[#lines + 1] = "Codepage label: " .. describe_value(state.codepage_label)
end

--- Describe the final result while keeping its format visible
-- @param doc table opened document
-- @param action string opening result
-- @return string result
local function describe_open_result(doc, action)
	local details = { action }
	local format_definition = type(doc.format) == "table" and doc.format or nil
	local format_name = format_definition
		and (format_definition.shortname or format_definition.name)
		or nil

	if format_name then
		details[#details + 1] = tostring(format_name)
	elseif type(doc.kind) == "string" and doc.kind ~= "" then
		details[#details + 1] = doc.kind
	end

	return table.concat(details, ", ")
end

--- Report every available document-opening detail in one readable entry
-- @param doc table opened document
-- @param requested_filename string|nil original requested filename
-- @param action string opening result
-- @param start_time number report start time
-- @param requested_options table|nil options supplied by caller
-- @param effective_options table|nil resolved document options
-- @param force_options table|nil forced loading options
-- @param load_details table|nil text loading facts and timings
local function report_document_open(
	doc,
	requested_filename,
	action,
	start_time,
	requested_options,
	effective_options,
	force_options,
	load_details
)
	if not dev_details.is_enabled() then
		return
	end

	local cli = require("core.cli")
	local lines = { tostring(doc.filename or requested_filename or "untitled") }

	if requested_options or effective_options or force_options then
		lines[#lines + 1] = "Request:"
		lines[#lines + 1] = "Options: " .. cli.format_doc_options(requested_options)
		lines[#lines + 1] = "Effective: " .. cli.format_doc_options(effective_options)
		lines[#lines + 1] = "Forced loading: " .. cli.format_force_load_options(force_options)
	end

	if load_details and load_details.decode_request then
		local request = load_details.decode_request
		lines[#lines + 1] = "Decode request:"
		lines[#lines + 1] = "Forced encoding: " .. describe_value(request.forced_encoding)
		lines[#lines + 1] = "Forced codepage: " .. describe_value(request.forced_codepage)
		lines[#lines + 1] = "Explicit codepage: " .. describe_value(request.explicit_codepage)
	end

	if load_details and load_details.decode_result then
		local result = load_details.decode_result
		lines[#lines + 1] = "Decode result:"
		lines[#lines + 1] = "Method: " .. describe_value(result.kind)
		lines[#lines + 1] = "Encoding: " .. describe_value(result.encoding)
		lines[#lines + 1] = "BOM: " .. describe_value(result.bom)
		lines[#lines + 1] = "Codepage: " .. describe_value(result.codepage)
		lines[#lines + 1] = "Errors: " .. describe_value(result.errors)
	end

	if load_details and load_details.content then
		local content = load_details.content
		lines[#lines + 1] = "Content:"
		lines[#lines + 1] = string.format("Bytes: %d", content.bytes)
		lines[#lines + 1] = string.format("Lines: %d", content.lines)
		lines[#lines + 1] = string.format("Longest line: %d", content.longest_line)
		lines[#lines + 1] = string.format("Longest line bytes: %d", content.longest_bytes)
	end

	append_document_state(lines, load_details and "Loaded" or "Document", load_details and load_details.loaded or doc)
	append_document_state(lines, "Final", doc)

	if load_details and load_details.timings then
		lines[#lines + 1] = "Timings:"
		for index = 1, #load_details.timings do
			local timing = load_details.timings[index]
			lines[#lines + 1] = string.format("%s: %.1f ms", timing.name, timing.duration_ms)
		end
	end

	lines[#lines + 1] = "Open total: " .. string.format("%.1f ms", (system.get_time() - start_time) * 1000)
	lines[#lines + 1] = "Result:"
	lines[#lines + 1] = describe_open_result(doc, action)
	dev_details.report("Open document", lines)
end

--- Resolve one document filename while keeping its optional virtual path out of filesystem calls
-- @param filename string document filename
-- @return string|nil absolute_filename complete document identity
-- @return string|nil physical_filename absolute on-disk filename
-- @return string|nil virtual_path path inside the physical file
local function resolve_filenames(filename)
	if type(filename) ~= "string" or filename == "" then
		return nil, nil, nil
	end

	local parsed = system.parse_file_reference(filename, false)
	local physical_filename = parsed.filename
	local virtual_path = parsed.virtual_path
	local absolute_physical_filename = system.absolute_path(physical_filename)

	if not absolute_physical_filename then
		return nil, physical_filename, virtual_path
	end

	return system.format_filename(absolute_physical_filename, virtual_path),
		absolute_physical_filename,
		virtual_path
end

--- Emit one grouped core hook stage when hook bus is available
-- @param event_name string hook event name
-- @param stage string hook stage name
-- @param payload table|nil event payload
local function emit_core_hook(event_name, stage, payload)
	local core = get_core()

	if core and core.hook then
		core.hook.emit(event_name, stage, payload)
	end
end

--- Create a new text document and apply startup options
-- Supports optional synthetic filenames without opening from disk
-- @param filename string|nil synthetic filename
-- @param opts table|nil document options
-- @param source string hook source label
-- @return table doc
local function create_text_doc(filename, opts, source)
	local core = get_core()
	local TextDocument = require("core.text.document")
	local start_time = system.get_time()
	local options = doc_options.build_new_doc_options(opts)
	local doc = TextDocument()

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

	if type(options.filename) == "string" and options.filename ~= "" then
		doc.filename = options.filename
	end

	emit_core_hook("doc.new", "before", {
		doc = doc,
		options = doc_options.clone_doc_options(options),
		source = source,
	})

	local ok, err = pcall(function()
		doc_options.apply_doc_options_to_doc(doc, options, true)
		table.insert(core.docs, doc)
	end)

	if not ok then
		emit_core_hook("doc.new", "error", {
			doc = doc,
			options = doc_options.clone_doc_options(options),
			source = source,
			err = err,
		})
		error(err)
	end

	emit_core_hook("doc.new", "after", {
		doc = doc,
		options = doc_options.clone_doc_options(options),
		source = source,
	})

	core.log_quiet("Opened new doc with startup options")
	report_document_open(doc, filename, "created", start_time)

	return doc
end

--- Return the opening decision and its unchanged native byte probe
-- Honors force-binary requests and DOS text-art hints
-- @param filename string|nil file path
-- @param opts table|nil detect options
-- @return table detection
-- @return string|nil err
function lifecycle.detect_type(filename, opts)
	opts = type(opts) == "table" and opts or {}

	if type(filename) ~= "string" or filename == "" then
		return nil, "missing_filename"
	end

	local absolute_filename, physical_filename, virtual_path = resolve_filenames(filename)
	absolute_filename = absolute_filename or filename
	physical_filename = physical_filename or filename
	local probe, header_data, err = filetype.probe_path(physical_filename)
	local is_image, image_extension = image_support.is_supported(physical_filename)

	if not probe then
		return nil, err
	end

	local utf16 = type(probe.utf16) == "table" and probe.utf16 or {}
	local binary = type(probe.binary) == "table" and probe.binary or {}
	local force_binary = opts.force_binary == true
	local is_binary = force_binary or (
		binary.status == "valid" and utf16.status ~= "valid"
	)
	local format_registry = require("core.format")
	local forced_format = type(opts.format_name) == "string" and format_registry.get_by_name(opts.format_name) or nil
	local detected_format = forced_format
	local detect_meta = nil

	if not force_binary and not forced_format then
		local header = header_data:sub(1, format_sample_size())
		detected_format, detect_meta = format_registry.get(
			physical_filename,
			header,
			false,
			probe
		)

		if is_binary and type(detect_meta) == "table" and detect_meta.force_text_open == true then
			is_binary = false
		end
	end

	local format_kind = detected_format and format_registry.get_kind(detected_format) or nil
	local resolved_kind = format_kind or (is_binary and "binary" or "text")
	local resolved_mode = open_mode.resolve_format(detected_format, resolved_kind, opts.open_mode)
	local requested_handler = opts.open_handler

	-- An indexed image is already the requested result, so skip its multi-image preview handler
	if virtual_path ~= nil and format_kind == "image" and requested_handler == nil then
		requested_handler = "image"
	end

	local selected_handler = open_handler.resolve({
		filename = absolute_filename,
		data = header_data,
		probe = probe,
		format = detected_format,
		kind = resolved_kind,
		mode = resolved_mode,
		action = opts.open_action,
		handler = requested_handler,
	})

	if not force_binary then
		if format_kind == "image" then
			is_image = true
			image_extension = detected_format.image_extension or image_extension
			is_binary = false
		elseif format_kind == "office" then
			is_binary = true
		elseif forced_format and (format_kind == "text" or format_kind == "ansiart") then
			is_binary = false
		elseif is_image == true then
			is_binary = false
		end
	end

	-- A selected raw handler deliberately bypasses text, image, and generated-document loading
	if selected_handler and selected_handler.raw then
		is_binary = true
		is_image = false
	end

	local opens_as_image = is_image == true and not force_binary
	local opens_as_raw = selected_handler and selected_handler.raw == true

	return {
		probe = probe,
		kind = (force_binary or opens_as_raw) and "binary"
			or (opens_as_image and "image" or format_kind or (is_binary and "binary" or "text")),
		binary = is_binary,
		image = opens_as_image,
		image_extension = image_extension,
		format = detected_format,
		format_metadata = detect_meta,
		handler = selected_handler,
	}
end

--- Create a new text document through the universal document lifecycle
-- @param filename string|nil synthetic filename
-- @param opts table|nil create options
-- @return table|nil doc
function lifecycle.create(filename, opts)
	return create_text_doc(filename, opts, "Doc.create")
end

--- Open or reuse a document through the universal document lifecycle
-- @param filename string file path
-- @param opts table|nil open options
-- @return table|nil doc
function lifecycle.open_file(filename, opts)
	local core = get_core()
	local Doc = require("core.doc")
	local TextDocument = require("core.text.document")

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

	if type(filename) ~= "string" or filename == "" then
		return nil, "missing_filename"
	end

	local start_time = system.get_time()
	local absolute_filename, physical_filename, virtual_path = resolve_filenames(filename)
	local save_disabled_reason = nil
	local force_binary = opts.force_binary == true
	local requested_doc_options = doc_options.clone_doc_options(opts.doc_options)
	local open_doc_options = doc_options.build_open_doc_options(requested_doc_options)
	local force_load_options = doc_options.build_doc_force_load_options(open_doc_options)

	emit_core_hook("doc.open", "before", {
		filename = filename,
		options = doc_options.clone_doc_options(open_doc_options),
		force_options = force_load_options,
		source = "Doc.open_file",
	})

	if absolute_filename then
		local missing_target = system.is_file(physical_filename or filename) ~= true

		for _, doc in ipairs(core.docs) do
			local doc_filename = doc.filename and select(1, resolve_filenames(doc.filename)) or nil
			local source_filename = doc.source_filename
				and select(1, resolve_filenames(doc.source_filename)) or nil
			local matches_filename = doc_filename and absolute_filename == doc_filename
			local matches_source = source_filename and absolute_filename == source_filename

			if matches_filename or matches_source then
				local is_dirty = type(doc.is_dirty) == "function" and doc:is_dirty()
				local reloaded = false
				local load_details
				if force_binary and not is_dirty then
					doc.is_binary = true
					doc.is_binary_placeholder = true
					doc.is_image = false
					doc.image_extension = nil
					doc.kind = "binary"
					doc.save_disabled = true
					doc.save_disabled_reason = "binary mode forced by CLI"
					doc.encoding_label = "Binary"
					doc.codepage_label = nil
					-- A forced binary open deliberately bypasses detected-format handlers
					doc.format = nil
					doc.format_detect_meta = nil
				elseif force_binary and is_dirty then
					core.log('Kept edited document "%s" in text mode instead of discarding its live state', filename)
				end

				if force_load_options and type(doc.reopen) == "function" and type(doc.is_dirty) == "function" and not is_dirty then
					-- Generated documents reload from their source instead of their unsaved display name
					local reopen_filename = type(doc.get_reopen_filename) == "function"
						and doc:get_reopen_filename() or doc.filename

					local _, physical_reopen_filename = resolve_filenames(reopen_filename)
					if system.is_file(physical_reopen_filename or reopen_filename) ~= true then
						return doc_options.handle_missing_file_open(reopen_filename, "file not found", open_doc_options)
					end

					local ok, load_err = pcall(function()
						load_details = doc:reopen(force_load_options)
					end)

					if not ok then
						core.error('%s', tostring(load_err))
						emit_core_hook("doc.open", "error", {
							doc = doc,
							filename = doc.filename,
							options = doc_options.clone_doc_options(open_doc_options),
							force_options = force_load_options,
							source = "Doc.open_file",
							err = load_err,
						})

						return nil, tostring(load_err)
					end

					reloaded = true
				end

				if force_binary and not is_dirty then
					-- Reopen options may redetect the format, so forced binary mode clears it last
					doc.format = nil
					doc.format_detect_meta = nil
				end

				doc_options.apply_doc_options_to_doc(doc, open_doc_options, false)

				if opts.retain_location_source == true and type(doc.read_location_source) == "function" then
					if is_dirty then
						doc._location_source_data = nil
						doc._location_source_error = "cannot resolve source-byte locations in an edited document"
					else
						doc._location_source_data, doc._location_source_error = doc:read_location_source(
							filename,
							build_text_open_options(opts, force_load_options)
						)
					end
				end
				emit_core_hook("doc.open", "after", {
					doc = doc,
					filename = doc.filename,
					options = doc_options.clone_doc_options(open_doc_options),
					reused = true,
					reloaded = reloaded,
					source = "Doc.open_file",
				})
				report_document_open(
					doc,
					filename,
					reloaded and "reloaded" or "reused",
					start_time,
					requested_doc_options,
					open_doc_options,
					force_load_options,
					load_details
				)

				return doc
			end
		end

		if missing_target then
			local opened = doc_options.handle_missing_file_open(absolute_filename or filename, "file not found", open_doc_options)

			if opened then
				emit_core_hook("doc.open", "after", {
					doc = opened,
					filename = opened.filename or absolute_filename or filename,
					options = doc_options.clone_doc_options(open_doc_options),
					reused = false,
					source = "Doc.open_file",
				})
			else
				emit_core_hook("doc.open", "error", {
					filename = absolute_filename or filename,
					options = doc_options.clone_doc_options(open_doc_options),
					force_options = force_load_options,
					source = "Doc.open_file",
					err = "file not found",
				})
			end

			return opened
		end

		local detection = lifecycle.detect_type(absolute_filename, {
			force_binary = force_binary,
			format_name = opts.source_format_name or open_doc_options.format_name,
			open_mode = opts.open_mode,
			open_handler = opts.open_handler,
			open_action = opts.open_action,
		})
		local is_binary = detection and detection.binary == true
		local is_image = detection and detection.image == true
		local file_info = (is_binary or is_image)
			and system.get_file_info(physical_filename or filename)
			or nil
		local source_size = file_info and tonumber(file_info.size) or 0

		if is_binary then
			save_disabled_reason = force_binary and "binary mode forced by CLI" or "binary file"
		elseif is_image then
			save_disabled_reason = "image file"
		end

		local doc
		local load_details

		if is_binary then
			doc = Doc()
			doc.filename = absolute_filename or filename
			doc.is_actual_file = true
			doc.is_binary = true
			doc.is_binary_placeholder = true
			doc.kind = detection and detection.kind or "binary"
			doc.save_disabled = true
			doc.save_disabled_reason = save_disabled_reason or "binary file"
			doc.encoding_label = "Binary"
			doc.codepage_label = nil
			doc.content_size_bytes = source_size
			doc.content_size_change_time = system.get_time()
			doc.content_size_known = file_info ~= nil
			doc:reset_format()

			if not force_binary then
				-- Ordinary binary documents retain their format for handler selection
				doc.format = detection and detection.format or nil
				doc.format_detect_meta = detection and detection.format_metadata or nil
			end
		elseif is_image then
			doc = Doc()
			doc.filename = absolute_filename or filename
			doc.is_actual_file = true
			doc.is_image = true
			doc.kind = "image"
			doc.image_extension = detection and detection.image_extension or nil
			doc.save_disabled = true
			doc.save_disabled_reason = save_disabled_reason or "image file"
			doc.format = detection and detection.format or nil
			doc.format_detect_meta = detection and detection.format_metadata or nil
			doc.action_default_handler = virtual_path ~= nil
				and detection and detection.handler and detection.handler.name
				or nil
			doc.content_size_bytes = source_size
			doc.content_size_change_time = system.get_time()
			doc.content_size_known = file_info ~= nil
			doc:reset_format()
		else
			doc = TextDocument()
			local text_open_options = build_text_open_options(opts, force_load_options)

			local ok, load_err = pcall(function()
				load_details = doc:open(filename, text_open_options)
			end)

			if not ok then
				emit_core_hook("doc.open", "error", {
					doc = doc,
					filename = filename,
					options = doc_options.clone_doc_options(open_doc_options),
					force_options = force_load_options,
					source = "Doc.open_file",
					err = load_err,
				})
				error(load_err)
			end

			doc.is_binary = false
		end

		doc:reset_format()

		if save_disabled_reason and not doc.save_disabled then
			doc.save_disabled = true
			doc.save_disabled_reason = save_disabled_reason
			core.log('Opened "%s" with saving disabled (%s)', filename, save_disabled_reason)
		end

		doc_options.apply_doc_options_to_doc(doc, open_doc_options, false)

		if opts.open_action ~= nil
			and opts.result_format_name ~= nil
			and doc.is_binary_placeholder ~= true
		then
			-- Text actions finish during loading, so their final format can be applied now
			doc_options.apply_doc_options_to_doc(doc, {
				format_name = opts.result_format_name,
			}, false)
		end

		table.insert(core.docs, doc)

		emit_core_hook("doc.open", "after", {
			doc = doc,
			filename = doc.filename,
			options = doc_options.clone_doc_options(open_doc_options),
			reused = false,
			source = "Doc.open_file",
		})

		local duration_ms = (system.get_time() - start_time) * 1000
		core.log_quiet('Opened doc "%s" in %.1fms', filename, duration_ms)
		report_document_open(
			doc,
			filename,
			"opened",
			start_time,
			requested_doc_options,
			open_doc_options,
			force_load_options,
			load_details
		)

		return doc
	end

	return nil
end

return lifecycle
