-- 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 filetype = require("core.filetype")
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

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

--- 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 physical_filename, virtual_path = system.parse_filename(filename)
	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 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")

	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")
	local cli = require("core.cli")

	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)

	core.log_quiet(
		"[open-doc] request file=%s opts={%s} effective={%s} force={%s}",
		tostring(filename),
		cli.format_doc_options(requested_doc_options),
		cli.format_doc_options(open_doc_options),
		cli.format_force_load_options(force_load_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()
				if force_binary and not is_dirty then
					local changed_to_binary = not doc.is_binary_placeholder
					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

					if changed_to_binary then
						core.log_quiet('Forced binary placeholder for "%s" via CLI mode', filename)
					end
				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()
						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
				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)
				core.log_quiet(
					"[open-doc] reuse applied file=%s encoding=%s bom=%s codepage=%s labels={enc:%s cp:%s}",
					tostring(doc.filename),
					tostring(doc.file_encoding),
					tostring(doc.file_bom),
					tostring(doc.file_codepage),
					tostring(doc.encoding_label),
					tostring(doc.codepage_label)
				)
				emit_core_hook("doc.open", "after", {
					doc = doc,
					filename = doc.filename,
					options = doc_options.clone_doc_options(open_doc_options),
					reused = true,
					source = "Doc.open_file",
				})

				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

		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

		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: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:reset_format()
		else
			doc = TextDocument()
			local text_open_options = {}

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

			text_open_options.open_mode = opts.open_mode
			text_open_options.open_handler = opts.open_handler
			text_open_options.open_action = opts.open_action
			text_open_options.source_format_name = opts.source_format_name

			local ok, load_err = pcall(function()
				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
			core.log_quiet(
				"[open-doc] loaded file=%s encoding=%s bom=%s codepage=%s labels={enc:%s cp:%s}",
				tostring(doc.filename),
				tostring(doc.file_encoding),
				tostring(doc.file_bom),
				tostring(doc.file_codepage),
				tostring(doc.encoding_label),
				tostring(doc.codepage_label)
			)
		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

		core.log_quiet(
			"[open-doc] final file=%s encoding=%s bom=%s codepage=%s labels={enc:%s cp:%s}",
			tostring(doc.filename),
			tostring(doc.file_encoding),
			tostring(doc.file_bom),
			tostring(doc.file_codepage),
			tostring(doc.encoding_label),
			tostring(doc.codepage_label)
		)

		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

		if filename then
			core.log_quiet('Opened doc "%s" in %.1fms', filename, duration_ms)
		else
			core.log_quiet("Opened new doc in %.1fms", duration_ms)
		end

		return doc
	end

	return nil
end

return lifecycle
