-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Hold one Office document until its native QUI choice is answered
-- @module plugins.officeopen.view

local core = require("core")
local style = require("core.style")
local command = require("core.command")
local Doc = require("core.doc")
local doc_options = require("core.doc.options")
local DocView = require("core.doc.view")
local open_mode = require("core.open_mode")
local window_api = require("window")
local qui = require("qui")
local Button = require("qui.button")
local Label = require("qui.label")

local OfficeOpenView = DocView:extend()

local office_readers = {
	doc = {
		module = "plugins.msoutils.doc",
		markdown_command = "doc:extract-md",
		word_wrap = true,
	},
	docx = {
		module = "plugins.msoutils.docx",
		markdown_command = "docx:extract-md",
		word_wrap = true,
	},
	xls = {
		module = "plugins.msoutils.xls",
		markdown_command = "xls:extract-md",
		tsv_command = "xls:extract-tsv",
		word_wrap = false,
	},
	xlsx = {
		module = "plugins.msoutils.xlsx",
		markdown_command = "xlsx:extract-md",
		tsv_command = "xlsx:extract-tsv",
		word_wrap = false,
	},
	xlsm = {
		module = "plugins.msoutils.xlsx",
		markdown_command = "xlsx:extract-md",
		tsv_command = "xlsx:extract-tsv",
		word_wrap = false,
	},
	odt = {
		module = "plugins.odfutils.odt",
		markdown_command = "odt:extract-md",
		word_wrap = true,
	},
	ods = {
		module = "plugins.odfutils.ods",
		markdown_command = "ods:extract-md",
		tsv_command = "ods:extract-tsv",
		word_wrap = false,
	},
	odp = {
		module = "plugins.odfutils.odp",
		markdown_command = "odp:extract-md",
		word_wrap = true,
	},
	odg = {
		module = "plugins.odfutils.odg",
		markdown_command = "odg:extract-md",
		word_wrap = true,
	},
}

--- Return a lower-case filename extension without its leading dot
-- Pattern breakdown:
-- '%.': matches the final literal dot before the extension
-- '([^%.\\/]+)': captures characters that are not dots or path separators
-- '$': requires that capture to reach the end of the path
-- @param filename string|nil source path
-- @return string|nil extension
local function get_extension(filename)
	if type(filename) ~= "string" then
		return nil
	end

	return filename:lower():match("%.([^%.\\/]+)$")
end

--- Return the Office reader key declared by a format or implied by the filename
-- @param target table|string|nil document or filename
-- @return string|nil office_type
local function get_office_type(target)
	if type(target) == "table" then
		local format_definition = target.format
		local declared_type = type(format_definition) == "table" and format_definition.office_type or nil

		if type(declared_type) == "string" and office_readers[declared_type] then
			return declared_type
		end

		return get_extension(target.filename)
	end

	return get_extension(target)
end

--- Return only the final path component for the visible prompt
-- Pattern breakdown:
-- '([^\\/]+)': captures characters that are not path separators
-- '$': requires that capture to reach the end of the path
-- @param filename string source path
-- @return string name
local function get_filename(filename)
	return filename:match("([^\\/]+)$") or filename
end

--- Find the application Window that should own the modal choice
-- @return number|nil window_id
local function get_main_window_id()
	for _, info in ipairs(window_api.list()) do
		if info.is_main then
			return info.id
		end
	end

	return nil
end

--- Replace all initial text in one new extracted document
-- @param filename string source Office path
-- @param content string extracted plain text
-- @param word_wrap boolean whether the result should wrap
-- @return table output_doc
-- @return table view_options
local function create_output_doc(filename, content, word_wrap)
	local output_doc = Doc.create(filename .. ".extracted.txt")
	local last_line = #output_doc.lines
	local last_col = #(output_doc.lines[last_line] or "") + 1

	output_doc:remove(1, 1, last_line, last_col)
	output_doc:insert(1, 1, content)
	output_doc:set_selection(1, 1)
	output_doc.save_as_required = true
	output_doc:clean()

	return output_doc, { word_wrap = word_wrap }
end

--- Return whether one document or path names a supported Office format
-- @param target table|string|nil document or source path
-- @return boolean supported
function OfficeOpenView.supports(target)
	return office_readers[get_office_type(target)] ~= nil
end

--- Return the ordered user choices for one supported Office document
-- Spreadsheet formats include the additional TSV extraction action
-- @param target table|string document or filename
-- @return table actions
function OfficeOpenView.get_choice_actions(target)
	local reader_info = office_readers[get_office_type(target)]
	local actions = {
		{ result = "preview_markdown", caption = "Preview md" },
		{ result = "extract_text", caption = "Extract text" },
	}

	if reader_info and reader_info.tsv_command then
		actions[#actions + 1] = { result = "extract_tsv", caption = "Extract tsv" }
	end

	actions[#actions + 1] = { result = "view_binary", caption = "View binary" }
	actions[#actions + 1] = { result = "cancel", caption = "Cancel" }

	return actions
end

--- Create the short-lived document view shown behind the native choice
-- @param doc table binary Office document
-- @param opts table|nil original view options
function OfficeOpenView:new(doc, opts)
	OfficeOpenView.super.new(self, doc)
	self.can_select = false
	self.can_edit = false
	self.original_options = opts
	self.automatic_extract = opts and opts.open_action == "extract"
	self.choice_requested = false
	self.choice_opened = false
end

--- Replace this holding view through the normal workspace path
-- @param doc table replacement document
-- @param opts table|nil replacement view options
-- @return table|nil view
function OfficeOpenView:replace(doc, opts)
	return core.workspace_view:replace_doc_view(self, doc, opts)
end

--- Continue into the existing binary viewer without reopening the file
-- @return boolean opened
function OfficeOpenView:show_hex()
	self.doc.office_open_choice_finished = true

	return self:replace(self.doc, {
		open_mode = open_mode.view,
		open_handler = "hex",
	}) ~= nil
end

--- Extract plain text and replace this holding view with the result
-- Failed extraction keeps the file available in the ordinary binary viewer
-- @return boolean opened
function OfficeOpenView:extract_text()
	local reader_info = office_readers[get_office_type(self.doc)]
	local reader_loaded, reader = pcall(require, reader_info and reader_info.module or "")
	if not reader_loaded then
		core.error("Office text extractor could not load: %s", tostring(reader))
		return self:show_hex()
	end

	local content
	local extract_error
	if reader and type(reader.extract_text) == "function" then
		local extracted, content_or_error, returned_error = pcall(
			reader.extract_text,
			self.doc.filename
		)

		if extracted then
			content = content_or_error
			extract_error = returned_error
		else
			extract_error = content_or_error
		end
	end

	if not content then
		core.error("Office text extraction failed: %s", tostring(extract_error or "reader unavailable"))
		return self:show_hex()
	end

	local output_doc, view_options = create_output_doc(
		self.doc.filename,
		content,
		reader_info.word_wrap
	)
	local result_options = self.original_options or {}
	local result_handler = result_options.result_open_handler

	if result_options.result_format_name then
		-- The explicit final format belongs to extracted content, not its Office container
		doc_options.apply_doc_options_to_doc(output_doc, {
			format_name = result_options.result_format_name,
		}, false)
	end

	-- Result-format settings win before the normal editable extraction fallback
	output_doc.action_default_mode = open_mode.edit
	view_options.open_mode = result_options.result_open_mode
	view_options.open_handler = result_handler

	-- Raw result handlers read generated bytes directly because no extracted file exists yet
	if result_handler == "hex" or result_handler == "bytes" then
		output_doc.binary_source_data = content
	end

	local view = self:replace(output_doc, view_options)
	if not view then
		core.error("Office text extraction succeeded, but its document could not be displayed")
		return self:show_hex()
	end

	core.log('Extracted "%s" as text', self.doc.filename)

	return true
end

--- Run one existing Office extraction command into this holding view
-- Markdown is forced into its visual preview while TSV remains editable
-- @param result string dialog extraction result
-- @return boolean opened
function OfficeOpenView:run_extract_command(result)
	local reader_info = office_readers[get_office_type(self.doc)]
	local command_name = result == "preview_markdown"
		and reader_info and reader_info.markdown_command
		or result == "extract_tsv" and reader_info and reader_info.tsv_command
		or nil

	if not command_name then
		return false
	end

	local requested_mode = result == "preview_markdown" and open_mode.preview or open_mode.edit

	return command.perform(command_name, {
		replace_view = self,
		open_mode = requested_mode,
	})
end

--- Close this unanswered holding view after Cancel or native Window closure
function OfficeOpenView:cancel()
	local node = core.workspace_view.root_node:get_node_for_view(self)

	if not node then
		return
	end
	if node.active_view ~= self then
		node:set_active_view(self)
	end

	node:close_active_view(core.workspace_view.root_node)
	core.redraw = true
end

--- Apply the completed native choice after the modal Form has closed
-- @param result string completion result
function OfficeOpenView:finish(result)
	self.choice_form = nil

	if result == "preview_markdown" or result == "extract_tsv" then
		if not self:run_extract_command(result) then
			self:show_hex()
		end
	elseif result == "extract_text" then
		self:extract_text()
	elseif result == "view_binary" then
		self:show_hex()
	else
		self:cancel()
	end
end

--- Build and show the owner-modal QUI Form for this document
function OfficeOpenView:open_choice()
	if self.choice_opened then
		return
	end
	if not core.workspace_view.root_node:get_node_for_view(self) then
		return
	end

	self.choice_opened = true
	local owner_window_id = get_main_window_id()
	if not owner_window_id then
		core.error("Office file choice requires the main application Window")
		self:show_hex()
		return
	end

	local message = Label {
		name = "message",
		text = "This is a binary Office file. How should Lx open it?",
		placement = {
			horizontal = "left",
			vertical = "center",
		},
	}
	local filename = Label {
		name = "filename",
		text = get_filename(self.doc.filename),
		placement = {
			horizontal = "left",
			vertical = "center",
		},
	}
	local action_specs = OfficeOpenView.get_choice_actions(self.doc)
	local action_captions = {
		preview_markdown = "&Preview md",
		extract_text = "&Extract text",
		extract_tsv = "Extract &tsv",
		view_binary = "View &binary",
		cancel = "&Cancel",
	}
	local action_buttons = {}
	local action_layout = {
		gap = 8,

		qui.pane {
			name = "action_filler",
			length = "weighted",
			weight = 1,
		},
	}
	local choice_form
	local form_error
	local finish

	for index = 1, #action_specs do
		local spec = action_specs[index]
		local button = Button {
			name = spec.result,
			caption = action_captions[spec.result] or spec.caption,
			dialog_action = index == 1 and "default" or (spec.result == "cancel" and "cancel" or nil),
			placement = {
				horizontal = "right",
				vertical = "center",
			},
		}
		action_buttons[index] = button
		action_layout[#action_layout + 1] = qui.pane {
			name = spec.result .. "_action",
			length = "content",
			controls = { button },
		}
	end

	choice_form, form_error = qui.form {
		name = "Open Office File",
		type = "dialog",
		initial_focus = action_buttons[1],
		on_complete = function(_, result)
			self:finish(result)
		end,
		layout = qui.rows {
			gap = 8,
			padding_left = 18,
			padding_top = 18,
			padding_right = 18,
			padding_bottom = 18,

			qui.pane {
				name = "message_pane",
				length = "content",
				controls = { message },
			},

			qui.pane {
				name = "filename_pane",
				length = "content",
				controls = { filename },
			},

			qui.pane {
				name = "content_filler",
				length = "weighted",
				weight = 1,
			},

			qui.pane {
				name = "actions",
				length = "content",
				layout = qui.cols(action_layout),
			},
		},
	}

	if not choice_form then
		core.error("Office file choice Form failed: %s", tostring(form_error))
		self:show_hex()
		return
	end

	--- Close the Form through one checked path shared by every action Button
	-- @param result string completion result
	finish = function(result)
		local closed, close_error = choice_form:close(result)
		if not closed then
			core.error("Office file choice could not close: %s", tostring(close_error))
			return false
		end

		return true
	end

	for index = 1, #action_specs do
		local result = action_specs[index].result
		action_buttons[index].on_click = function()
			return finish(result)
		end
	end

	local window_id, window_error = choice_form:open_window {
		title = "Open Office File",
		width = #action_specs == 5 and 610 or 520,
		height = 144,
		owner_id = owner_window_id,
		modality = "owner_modal",
	}
	if not window_id then
		if choice_form:is_valid() then
			choice_form:destroy()
		end
		core.error("Office file choice Window failed: %s", tostring(window_error))
		self:show_hex()
		return
	end

	self.choice_form = choice_form
end

--- Open the choice only after this holding view has entered the workspace tree
-- This avoids changing tabs while the shared factory is still constructing one
function OfficeOpenView:update()
	OfficeOpenView.super.update(self)

	if self.choice_requested then
		return
	end

	self.choice_requested = true
	local accepted, defer_error = qui.defer(function()
		if self.automatic_extract then
			self:extract_text()
		else
			self:open_choice()
		end
	end)
	if not accepted then
		core.error("Office file choice could not be scheduled: %s", tostring(defer_error))
		self:show_hex()
	end
end

--- Draw a quiet holding message during the brief native Window handoff
function OfficeOpenView:draw()
	self:draw_background(style.background)

	-- Keep the former holding message nearby while the unanswered view remains deliberately blank
	-- local message = "Choose how to open this Office file"
	-- local text_width = renderer.measure_text(style.font, message)
	-- local text_height = style.font:get_height()
	-- local x = self.position.x + math.max(0, (self.size.x - text_width) / 2)
	-- local y = self.position.y + math.max(0, (self.size.y - text_height) / 2)
	-- renderer.draw_text(style.font, message, x, y, style.text)
end

return OfficeOpenView
