-- lx-bundle:true

--- Built-in document-level commands
-- Keeps shared document command registration separate from text-editor specifics
-- @module core.doc.commands

local core = require("core")
local command = require("core.command")
local config = require("core.config")
local keymap = require("core.keymap")
local open_handler = require("core.open_handler")
local open_mode = require("core.open_mode")
local zoom = require("core.ui.zoom")
local BinaryViewer = require("core.binary.viewer")
local TextEditor = require("core.text.editor")
local TextViewer = require("core.text.viewer")

local doc_commands = {}

--- Return whether one document has raw bytes available for binary views
-- @param doc table|nil document model
-- @return boolean available
local function has_raw_bytes(doc)
	return doc ~= nil and (
		type(doc.binary_source_data) == "string"
		or doc.is_binary_placeholder == true and type(doc.filename) == "string"
		or doc.is_actual_file == true and type(doc.filename) == "string"
	)
end

--- Return whether one document has a matching rendered preview
-- @param doc table document model
-- @return boolean available
local function has_preview(doc)
	local handler = open_handler.resolve({
		doc = doc,
		format = doc.format,
		kind = doc.kind,
		mode = open_mode.preview,
	})

	return handler ~= nil and type(handler.create_view) == "function"
end

--- Return whether plain text is a useful read-only mode for one document
-- @param doc table document model
-- @return boolean available
local function has_plain_view(doc)
	return doc.kind == "text"
		and doc.is_binary ~= true
		and doc.is_binary_placeholder ~= true
		and doc.is_image ~= true
end

--- Return whether one view belongs to a class without assuming every document surface does
-- @param view table|nil document view
-- @param view_class table view class
-- @return boolean matches
local function view_is(view, view_class)
	return view ~= nil and type(view.is) == "function" and view:is(view_class) or false
end

-- Read-only modes stay in their visible order and use the existing view factory
local read_only_modes = {
	{
		name = "preview",
		valid = has_preview,
		matches = function(view)
			return not core.view_can_edit(view)
				and not view_is(view, TextViewer)
				and not view_is(view, BinaryViewer)
		end,
		create = function(doc)
			return core.create_doc_view(doc, { open_mode = open_mode.preview })
		end,
	},
	{
		name = "view",
		valid = has_plain_view,
		matches = function(view)
			return view_is(view, TextViewer)
		end,
		create = function(doc)
			return core.create_doc_view(doc, { open_mode = open_mode.view })
		end,
	},
	{
		name = "bytes",
		valid = has_raw_bytes,
		matches = function(view)
			return view_is(view, BinaryViewer) and view.binary_mode == "symbols"
		end,
		create = function(doc)
			return core.create_doc_view(doc, {
				open_mode = open_mode.view,
				open_handler = "bytes",
			})
		end,
	},
	{
		name = "hex",
		valid = has_raw_bytes,
		matches = function(view)
			return view_is(view, BinaryViewer) and view.binary_mode == "hex"
		end,
		create = function(doc)
			return core.create_doc_view(doc, {
				open_mode = open_mode.view,
				open_handler = "hex",
			})
		end,
	},
}

--- Return one read-only view's mode definition
-- @param view table document view
-- @param doc table document model
-- @return table|nil mode
local function get_read_only_mode(view, doc)
	for _, mode in ipairs(read_only_modes) do
		if mode.valid(doc) and mode.matches(view) then
			return mode
		end
	end
end

--- Return the handler identity that owns one read-only mode
-- @param mode table read-only mode definition
-- @param doc table document model
-- @return string|nil handler_name
local function get_mode_handler_name(mode, doc)
	if mode.name ~= "preview" then
		return mode.name
	end

	local handler = open_handler.resolve({
		doc = doc,
		format = doc.format,
		kind = doc.kind,
		mode = open_mode.preview,
	})

	return handler and handler.name or nil
end

--- Remember the routing that created one retained read-only view
-- @param view table retained document view
-- @param mode table read-only mode definition
-- @param doc table document model
local function remember_read_only_view(view, mode, doc)
	view._retained_mode_name = mode.name
	view._retained_format = doc.format
	view._retained_handler_name = get_mode_handler_name(mode, doc)
end

--- Return whether one retained view still matches current document routing
-- @param view table retained document view
-- @param mode table expected read-only mode
-- @param doc table document model
-- @return boolean matches
local function retained_view_matches(view, mode, doc)
	return view ~= nil
		and view.doc == doc
		and mode.valid(doc)
		and mode.matches(view)
		and view._retained_mode_name == mode.name
		and view._retained_format == doc.format
		and view._retained_handler_name == get_mode_handler_name(mode, doc)
end

--- Return the first read-only mode the view factory should choose
-- @param doc table document model
-- @return table|nil mode
local function get_preferred_read_only_mode(doc)
	for _, mode in ipairs(read_only_modes) do
		if mode.valid(doc) then
			return mode
		end
	end
end

--- Share retained read-only views with a newly connected editor
-- @param reader table preferred read-only view
-- @param editor table editable view
local function connect_editor(reader, editor)
	local mode = get_read_only_mode(reader, reader.doc)
	local retained = reader._cycle_mode_views or {}

	if mode then
		retained[mode.name] = reader
		remember_read_only_view(reader, mode, reader.doc)
	end

	reader._cycle_mode_views = retained
	editor._cycle_mode_views = retained
	editor._toggle_edit_view = reader

	-- Every retained reader returns through the one edit boundary
	for _, view in pairs(retained) do
		view._cycle_mode_views = retained
		view._toggle_edit_view = editor
	end
end

--- Return whether the active document view supports core fast scrolling
-- @return boolean supported true when the view exposes the shared fast-scroll method
local function can_fast_scroll()
	local active_view = core.active_view

	return active_view ~= nil and type(active_view.fast_scroll_vertical) == "function"
end

--- Return the source line shown at the top of one document view
-- @param view table document view
-- @return number source_line
local function get_visible_source_line(view)
	if type(view.get_visible_source_line) == "function" then
		return view:get_visible_source_line(0)
	end

	if type(view.get_visible_line_range) == "function" then
		return view:get_visible_line_range()
	end

	return view.doc:get_selection()
end

--- Return an exact document edge currently shown by one text surface
-- @param view table text surface
-- @return string|nil edge 'top', 'end', or nil for an interior position
local function get_visible_text_edge(view)
	if (view.scroll.y or 0) <= 0.5 then
		return "top"
	end

	if not (
		type(view.get_caret_screen_position) == "function"
		and type(view.get_content_viewport_rect) == "function"
		and type(view.get_line_height) == "function"
	) then
		return nil
	end

	local last_line = #view.doc.lines
	local last_col = #view.doc.lines[last_line]
	local _, final_row_y = view:get_caret_screen_position(last_line, last_col)
	local _, viewport_y, _, viewport_height = view:get_content_viewport_rect()

	if final_row_y + view:get_line_height() <= viewport_y + viewport_height then
		return "end"
	end
end

--- Center one document view on a source line when its surface supports it
-- @param view table document view
-- @param source_line number source line to show
local function show_source_line(view, source_line)
	if type(view.scroll_to_source_line) == "function" then
		view:scroll_to_source_line(source_line)
		return
	end

	if type(view.scroll_to_line) == "function" then
		view:scroll_to_line(source_line, false, true)
	end
end

--- Return whether two views are the normal text editor and viewer pair
-- @param first table document view
-- @param second table document view
-- @return boolean matches
local function is_plain_text_pair(first, second)
	return view_is(first, TextEditor) and view_is(second, TextViewer)
		or view_is(first, TextViewer) and view_is(second, TextEditor)
end

--- Capture the exact text position currently resting at the viewport top
-- @param view table text editor or viewer
-- @return table anchor
local function capture_text_view(view)
	local saved = view._toggle_text_anchor
	local scroll_x = view.scroll.x or 0
	local scroll_y = view.scroll.y or 0
	local target_x = view.scroll.to.x or scroll_x
	local target_y = view.scroll.to.y or scroll_y

	-- Reuse the original logical anchor until this view actually moves
	if saved
		and saved.scroll_x == scroll_x
		and saved.scroll_y == scroll_y
		and saved.target_x == target_x
		and saved.target_y == target_y
	then
		return saved.anchor
	end

	local line_height = view:get_line_height()
	local row = math.max(1, math.floor(scroll_y / line_height) + 1)
	local wrapped = view:is_wrap_enabled()

	if wrapped then
		row = math.min(row, math.max(1, view:get_visual_row_count()))
	end

	local line = math.min(row, #view.doc.lines)
	local col = view:get_x_offset_col(line, scroll_x)

	if wrapped then
		local row_info = view:get_visual_row_info(row)

		if row_info then
			line = row_info.line
			col = row_info.start_col
		end
	end

	return {
		line = line,
		col = col,
		row_offset = scroll_y - (row - 1) * line_height,
		scroll_x = scroll_x,
		wrapped = wrapped,
	}
end

--- Restore one exact text viewport anchor without moving the editor cursor
-- @param view table text editor or viewer
-- @param anchor table captured text position
local function restore_text_view(view, anchor)
	local line, col = view.doc:sanitize_position(anchor.line, anchor.col)
	local row = line

	if view:is_wrap_enabled() then
		row = view:get_visual_row_for_position(line, col)
	end

	local scroll_y = (row - 1) * view:get_line_height() + (anchor.row_offset or 0)
	local scroll_x = 0

	if not view:is_wrap_enabled() then
		scroll_x = anchor.wrapped and view:get_col_x_offset(line, col) or (anchor.scroll_x or 0)
	end

	view.scroll.x = scroll_x
	view.scroll.y = scroll_y
	view.scroll.to.x = scroll_x
	view.scroll.to.y = scroll_y

	-- Let a new viewer measure the visible lines before its horizontal clamp
	if type(view.measure_visible_scroll_width_lines) == "function" then
		view:measure_visible_scroll_width_lines()
	end

	view:clamp_scroll_position()
	view._toggle_text_anchor = {
		anchor = anchor,
		scroll_x = view.scroll.x,
		scroll_y = view.scroll.y,
		target_x = view.scroll.to.x,
		target_y = view.scroll.to.y,
	}
end

--- Swap the current tab between its live editable and read-only views
-- @return boolean toggled true when the opposite edit state became visible
local function toggle_edit()
	local active_view = core.active_view
	local active_doc = active_view and active_view.doc

	if not active_doc then
		return false
	end

	local can_edit = core.view_can_edit(active_view)
	local active_node = core.workspace_view:get_active_node()

	if not active_node or active_node.locked then
		return false
	end

	local opposite_view = active_view._toggle_edit_view
	local created_opposite = false
	local preferred_mode

	if can_edit and opposite_view and opposite_view._retained_format == active_doc.format then
		for _, mode in ipairs(read_only_modes) do
			if mode.name == opposite_view._retained_mode_name and mode.valid(active_doc) then
				preferred_mode = mode
				break
			end
		end
	end
	if can_edit and not preferred_mode then
		preferred_mode = get_preferred_read_only_mode(active_doc)
	end

	if opposite_view and can_edit
		and (not preferred_mode or not retained_view_matches(opposite_view, preferred_mode, active_doc))
	then
		local retained = active_view._cycle_mode_views

		if retained and opposite_view._retained_mode_name then
			retained[opposite_view._retained_mode_name] = nil
		end

		active_view._toggle_edit_view = nil
		opposite_view = nil
	end

	if not opposite_view then
		-- Editable documents prefer their rendered preview; core falls back to plain view
		local requested_mode = can_edit and open_mode.preview or open_mode.edit
		opposite_view = core.create_doc_view(active_doc, { open_mode = requested_mode })

		-- Binary, image, and other read-only documents may have no editable surface
		if core.view_can_edit(opposite_view) == can_edit then
			return false
		end

		-- Keep both views alive without adding a second visible tab or pane
		if can_edit then
			connect_editor(opposite_view, active_view)
		else
			connect_editor(active_view, opposite_view)
		end
		created_opposite = true
	end

	local active_index = active_node:get_view_idx(active_view)

	if not active_index or opposite_view.doc ~= active_doc then
		return false
	end

	-- Follow either what the editor shows or its cursor, according to user preference
	local source_edge
	local source_line
	local text_anchor
	local plain_text_pair = is_plain_text_pair(active_view, opposite_view)
	local follow_cursor = can_edit and config.toggle_edit_source_position == "cursor"

	if plain_text_pair and not follow_cursor then
		text_anchor = capture_text_view(active_view)
	elseif can_edit then
		if not follow_cursor
			and type(opposite_view.scroll_to_source_line) == "function"
			and type(opposite_view.scroll_to_vertical_edge) == "function"
		then
			source_edge = get_visible_text_edge(active_view)
		end

		if not source_edge then
			source_line = follow_cursor and active_doc:get_selection() or get_visible_source_line(active_view)
		end
	end

	if not can_edit and created_opposite and not plain_text_pair then
		-- A new editor has no saved place yet, so initialize it from the reader once
		source_line = get_visible_source_line(active_view)
		active_doc:set_selection(source_line, 1)
	end

	active_node.views[active_index] = opposite_view
	active_node:set_active_view(opposite_view)
	core.workspace_view.root_node:update_layout()

	if source_edge then
		opposite_view._toggle_text_anchor = nil
		opposite_view:scroll_to_vertical_edge(source_edge == "end")
	elseif text_anchor then
		restore_text_view(opposite_view, text_anchor)
	elseif source_line then
		opposite_view._toggle_text_anchor = nil
		show_source_line(opposite_view, source_line)
	end

	core.redraw = true

	return true
end

--- Cycle within the active edit or read-only group
-- Editable mode has no alternative yet
-- @return boolean changed true when another retained view became visible
local function cycle_mode()
	local active_view = core.active_view
	local active_doc = active_view and active_view.doc

	if not active_doc or core.view_can_edit(active_view) then
		return false
	end

	local active_node = core.workspace_view:get_active_node()
	local active_index = active_node and active_node:get_view_idx(active_view)

	if not active_index or active_node.locked then
		return false
	end

	local valid_modes = {}
	local current_index

	for _, mode in ipairs(read_only_modes) do
		if mode.valid(active_doc) then
			valid_modes[#valid_modes + 1] = mode

			if mode.matches(active_view) then
				current_index = #valid_modes
			end
		end
	end

	if #valid_modes < 2 or not current_index then
		return false
	end

	local target_mode = valid_modes[current_index % #valid_modes + 1]
	local retained = active_view._cycle_mode_views or {}
	local current_mode = valid_modes[current_index]
	local target_view = retained[target_mode.name]
	local source_line
	local byte_offset

	if target_view and not retained_view_matches(target_view, target_mode, active_doc) then
		retained[target_mode.name] = nil
		target_view = nil
	end

	retained[current_mode.name] = active_view
	remember_read_only_view(active_view, current_mode, active_doc)
	active_view._cycle_mode_views = retained

	if not target_view then
		-- New text-like modes follow source lines; binary modes follow exact bytes
		if view_is(active_view, BinaryViewer) then
			byte_offset = active_view:get_top_byte_offset(active_view.binary_mode)
		else
			source_line = get_visible_source_line(active_view)
		end

		target_view = target_mode.create(active_doc)

		if target_view.doc ~= active_doc or not target_mode.matches(target_view) then
			return false
		end

		retained[target_mode.name] = target_view
		remember_read_only_view(target_view, target_mode, active_doc)
		target_view._cycle_mode_views = retained

		local editor = active_view._toggle_edit_view

		if editor and core.view_can_edit(editor) then
			target_view._toggle_edit_view = editor
		end

		if byte_offset ~= nil and view_is(target_view, BinaryViewer) then
			target_view:sync_scroll_between_modes(
				target_view.binary_mode,
				target_view.binary_mode,
				byte_offset
			)
		elseif source_line and not view_is(target_view, BinaryViewer) then
			show_source_line(target_view, source_line)
		end
	end

	active_node.views[active_index] = target_view
	active_node:set_active_view(target_view)
	core.workspace_view.root_node:update_layout()
	core.redraw = true

	return true
end

command.register({
	-- Toggle the active tab between live editable and read-only views
	["doc:toggle-edit"] = {
		requires = { "doc" },
		perform = toggle_edit,
	},

	-- Cycle only within the active edit or read-only mode group
	["doc:cycle-mode"] = {
		requires = { "doc" },
		perform = cycle_mode,
	},

	["doc:fastscroll-up"] = {
		requires = { "doc" },
		predicate = can_fast_scroll,
		perform = function()
			return core.active_view:fast_scroll_vertical(-1)
		end,
	},

	["doc:fastscroll-down"] = {
		requires = { "doc" },
		predicate = can_fast_scroll,
		perform = function()
			return core.active_view:fast_scroll_vertical(1)
		end,
	},

	["doc:mouse-pan"] = {
		requires = { "doc" },
		perform = function(context)
			local view = core.active_view

			if not view or type(view.start_mouse_pan) ~= "function" or type(context) ~= "table" then
				return false
			end

			return view:start_mouse_pan(context.button, context.mouse_x, context.mouse_y)
		end,
	},

	["zoom:reset"] = function(context)
		-- Report false when no visible document can receive content zoom
		return zoom.reset(context)
	end,

	["zoom:out"] = function(context)
		-- Keep hidden workspaces from accumulating content zoom state
		return zoom.step(-1, context)
	end,

	["zoom:in"] = function(context)
		-- Keep hidden workspaces from accumulating content zoom state
		return zoom.step(1, context)
	end,
})

keymap.bind("core", {
	doc_view = {
		-- Switch this tab to its opposite edit state
		["f9"] = "doc:toggle-edit",
		["ctrl+alt+e"] = "doc:toggle-edit",
		-- Cycle this tab without crossing its edit boundary
		["alt+m"] = "doc:cycle-mode",

		-- Mouse buttons select the shared pan action through ordinary context bindings
		["mclick"] = "doc:mouse-pan",
		-- Keep shared document zoom available to every document surface
		["ctrl+0"] = "zoom:reset",
		["ctrl+-"] = "zoom:out",
		["ctrl+="] = "zoom:in",
		["ctrl+keypad +"] = "zoom:in",
		["ctrl+keypad -"] = "zoom:out",
		["ctrl+keypad *"] = "zoom:reset",
		["ctrl+wheelup"] = "zoom:in",
		["ctrl+wheeldown"] = "zoom:out",

		-- Fast scrolling belongs to document views rather than the whole application
		["ctrl+alt+wheelup"] = "doc:fastscroll-up",
		["ctrl+alt+wheeldown"] = "doc:fastscroll-down",
	},
})

return doc_commands
