-- lx-bundle:true

--- Generic pane binding for document-backed views
-- @module core.doc.view

local core = require("core")
local common = require("core.common")
local monitor = require("core.doc.monitor")
local config = require("core.config")
local keymap = require("core.keymap")
local style = require("core.style")
local View = require("core.ui.view")

local DocView = View:extend()
DocView.LaneIDs = View.LaneIDs
DocView.LaneOrders = View.LaneOrders
local FAST_SCROLL_WHEEL_MULTIPLIER = 6

--- Build one generic document pane
-- @param doc table document model
function DocView:new(doc)
	DocView.super.new(self)
	self.doc = assert(doc)
	self.scrollable = true
	self.mouse_cursor = "arrow"
end

--- Return tab label with dirty marker when needed
-- @return string name tab display name
function DocView:get_name()
	local post = self.doc:is_dirty() and "*" or ""
	local name = self.doc:get_name()
	local state = monitor.get_state and monitor.get_state(self.doc)
	local deleted_suffix = (state and state.deleted) and " [deleted]" or ""
	-- Pattern breakdown:
	-- '[^/%\\]*' captures trailing chars that are not '/' or '\'
	-- '$' anchors to end, yielding basename without directories
	return name:match("[^/%\\]*$") .. deleted_suffix .. post
end

--- Check whether scroll state is still animating toward its target
-- @return boolean scrolling true when either axis has remaining delta
function DocView:is_scrolling()
	local sx = math.abs((self.scroll.to.x or 0) - (self.scroll.x or 0))
	local sy = math.abs((self.scroll.to.y or 0) - (self.scroll.y or 0))

	return sx > 0.01 or sy > 0.01
end

--- Set a vertical edge target using the shared edge-animation setting
-- @param target_y number top or bottom scroll target
function DocView:set_vertical_edge_scroll_target(target_y)
	target_y = common.clamp(tonumber(target_y) or 0, 0, self:get_scroll_max_y())
	self.scroll.to.y = target_y

	if config.animate_vertical_edge_scroll ~= true then
		self.scroll.y = target_y
	end

	core.redraw = true
end

--- Scroll to the top or bottom edge using the shared animation setting
-- @param to_end boolean|nil true scrolls to the bottom edge
function DocView:scroll_to_vertical_edge(to_end)
	local target_y = to_end and self:get_scroll_max_y() or 0

	self:set_vertical_edge_scroll_target(target_y)
end

--- Return the default scrollable size for document panes
-- Concrete surfaces override this when they render content rows
-- @return number size scrollable height
function DocView:get_scrollable_size()
	return 0
end

--- Return one fast vertical scroll step for this document view
-- Uses a fraction of the full range while remaining at least as large as a normal wheel step
-- @param requested_fraction number|nil optional view-specific range fraction
-- @return number step scroll distance in pixels
function DocView:get_fast_scroll_step(requested_fraction)
	local max_scroll = math.max(0, self:get_scroll_max_y())
	local step_fraction = tonumber(requested_fraction)

	if requested_fraction == nil then
		step_fraction = tonumber(config.fastscroll_step_fraction)
	end

	if not step_fraction
		or step_fraction ~= step_fraction
		or step_fraction == math.huge
		or step_fraction == -math.huge
	then
		step_fraction = 0.05
	end

	step_fraction = common.clamp(step_fraction, 0, 1)
	local line_height = 1

	if type(self.get_line_height) == "function" then
		line_height = math.max(1, tonumber(self:get_line_height()) or 1)
	end

	local wheel_step = tonumber(config.mouse_wheel_scroll) or 0

	if config.mouse_wheel_scroll_lines ~= nil then
		wheel_step = line_height * math.max(0, tonumber(config.mouse_wheel_scroll_lines) or 0)
	end

	return math.max(line_height, wheel_step * FAST_SCROLL_WHEEL_MULTIPLIER, max_scroll * step_fraction)
end

--- Move instantly through a document by one configured fast-scroll step
-- Specialized document views can override this to preserve their own scroll invariants
-- @param direction number negative scrolls up and positive scrolls down
-- @return boolean handled true when the view accepted the request
function DocView:fast_scroll_vertical(direction)
	direction = tonumber(direction) or 0

	if direction == 0 then
		return false
	end

	local current_y = self.scroll.to.y or self.scroll.y or 0
	local target_y = common.clamp(
		current_y + ((direction < 0) and -1 or 1) * self:get_fast_scroll_step(),
		0,
		self:get_scroll_max_y()
	)

	self.scroll.y = target_y
	self.scroll.to.y = target_y
	core.redraw = true

	return true
end

--- Return per-view content padding for document panes
-- Views can override this to tighten or expand their draw origin
-- @return table padding with 'x' and 'y'
function DocView:get_content_padding()
	return style.padding
end

--- Return key binding contexts for this document view
-- More specific surfaces prepend their own context before the shared 'doc_view' and 'global' scopes
-- @return table contexts ordered from most specific to broadest
function DocView:get_keymap_contexts()
	return { "doc_view", "global" }
end

--- Return per-view zoom levels for deterministic zoom stepping
-- Views can override this to constrain visible zoom choices without touching global defaults
-- @return table zoom levels in percent
function DocView:get_zoom_levels()
	return config.zoom_levels or {}
end

--- Return per-view default zoom level for reset behavior
-- Standard document views treat 100 percent as neutral content zoom
-- @return number zoom level in percent
function DocView:get_default_zoom_level()
	return 100
end

--- Return per-view intrinsic zoom multiplier
-- Special surfaces can expose built-in enlargement without changing the shared zoom baseline
-- @return number zoom multiplier
function DocView:get_zoom_multiplier()
	return 1
end

--- Apply one per-view intrinsic zoom multiplier
-- Special views can react to visible zoom changes without forcing shared text zoom math
-- @param _zoom_multiplier number requested zoom multiplier
-- @param _context table|nil command input details
function DocView:set_zoom_multiplier(_zoom_multiplier, _context)
end

--- Reset zoom from a control-modified middle click when this view supports content zoom
-- Shared document panes use this before starting any middle-button pan gesture
-- @param button string mouse button
-- @return boolean handled true when the reset consumed the click
function DocView:try_middle_click_zoom_reset(button)
	if button ~= "middle" or not keymap.modkeys["ctrl"] then
		return false
	end

	local zoom = package.loaded["core.ui.zoom"] or require("core.ui.zoom")

	return zoom.reset() == true
end

--- Handle shared document mouse presses after specialized view interactions
-- Control-middle resets zoom while ordinary pan presses use 'doc:mouse-pan'
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @param clicks number click count
-- @return boolean|nil handled
function DocView:on_mouse_down(button, x, y, clicks)
	if self:try_middle_click_zoom_reset(button) then
		return true
	end

	local handled = DocView.super.on_mouse_down(self, button, x, y, clicks)

	if handled then
		return handled
	end

	return handled
end

--- Pan document content while the middle mouse button is held
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean|nil handled
function DocView:on_mouse_move(x, y, dx, dy)
	return DocView.super.on_mouse_move(self, x, y, dx, dy)
end

--- Finish shared document panning when its active button is released
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean|nil handled
function DocView:on_mouse_up(button, x, y)
	return DocView.super.on_mouse_up(self, button, x, y)
end

--- Clear shared document panning when the pointer leaves the view
-- @return boolean|nil handled
function DocView:on_mouse_left(...)
	return DocView.super.on_mouse_left(self, ...)
end

--- Reload a clean read-only document after its file changes outside Lx
-- Specialized views can preserve their visible position through the optional capture and restore hooks
-- @return boolean reloaded true when one reload happened
function DocView:reload_stale_doc_if_needed()
	if self.can_edit ~= false then
		return false
	end

	local state = monitor.get_state and monitor.get_state(self.doc)

	if not state or not state.stale or state.deleted then
		return false
	end

	if self.doc.is_dirty and self.doc:is_dirty() then
		return false
	end

	local reload_state

	if type(self.capture_reload_view_state) == "function" then
		reload_state = self:capture_reload_view_state()
	end

	local reloaded = monitor.reload_doc(self.doc, {
		force = true,
		silent_success = true,
		source = "viewer_auto",
	})

	if reloaded and type(self.restore_reload_view_state) == "function" then
		self:restore_reload_view_state(reload_state)
	end

	return reloaded == true
end

--- Update shared document behavior
function DocView:update()
	self:reload_stale_doc_if_needed()

	return DocView.super.update(self)
end

--- Save one dirty document and close only after the save succeeds
-- @param doc table document model
-- @param do_close function callback executed after a successful save
local function save_before_close(doc, do_close)
	local has_filename = type(doc.filename) == "string" and doc.filename ~= ""

	if has_filename and doc.save_as_required ~= true then
		if doc:save_current() then
			do_close()
		end

		return
	end

	core.command_view:set_text(has_filename and doc.filename or "")
	core.command_view:enter({
		caption = "Save As and Close",
		submit = function(filename)
			if type(filename) ~= "string" or filename == "" then
				return
			end

			if doc:save(filename) then
				do_close()
			end
		end,
		suggest = common.path_suggest,
	})
end

--- Request close confirmation and optional save for dirty documents
-- @param do_close function callback executed when close is approved
function DocView:try_close(do_close)
	if self.doc:is_dirty() and #core.get_views_referencing_doc(self.doc) == 1 then
		-- Use selectable menu options for unsaved changes prompt
		--- Handle unsaved-changes choice submit callback
		-- @param _ any unused placeholder
		-- @param item table|nil selected menu item
		core.command_view:enter({
			caption = "You have unsaved changes",
			submit = function(_, item)
				local choice = item and item.text or ""
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[cC]' accepts uppercase and lowercase C
				if choice:match("^[cC]") then
					do_close()
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[sS]' accepts uppercase and lowercase S
				elseif choice:match("^[sS]") then
					save_before_close(self.doc, do_close)
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[kK]' accepts uppercase and lowercase K
				elseif choice:match("^[kK]") then
					return
				end
			end,
			--- Filter unsaved-changes menu items by typed prefix
			-- @param text string current command input
			-- @return table items filtered item labels
			suggest = function(text)
				local items = {}
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[^cC]' matches any non C/c first char
				-- find returns nil when first char is C/c, so option stays visible
				if not text:find("^[^cC]") then
					table.insert(items, "Close without Saving")
				end
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[^sS]' matches any non S/s first char
				if not text:find("^[^sS]") then
					table.insert(items, "Save and Close")
				end
				-- Pattern breakdown:
				-- '^' anchors at start
				-- '[^kK]' matches any non K/k first char
				if not text:find("^[^kK]") then
					table.insert(items, "Keep Editing")
				end

				return items
			end,
		})
	else
		do_close()
	end
end

return DocView
