local _, ns = ...
local SlickUI = ns.SlickUI
local Theme = SlickUI.Theme

local ExternalFrames = {
	Frames = {},
	Seen = {},
	Editing = false,
	QuickMoveFrames = {},
	QuickMoveEnabled = false,
	CinematicActive = false,
	RestoreGeneration = 0,
}
SlickUI.ExternalFrames = ExternalFrames

-- PlayerSpellsFrame owns several protected Blizzard subframes, including the
-- Hero Talent selection dialog. Anchoring that root directly to UIParent can
-- connect otherwise separate anchor families when Blizzard closes the dialog.
-- A secure, full-screen intermediary preserves the same screen coordinates
-- without joining SlickUI's saved position to Blizzard's internal anchors.
local SafePlayerSpellsAnchor = CreateFrame("Frame", "SlickUI_PlayerSpellsAnchor", nil, "SecureFrameTemplate")
SafePlayerSpellsAnchor:SetAllPoints(UIParent)

-- Reapply saved positions for protected Blizzard windows from secure execution.
-- Calling ClearAllPoints/SetPoint directly from addon Lua taints complex roots
-- such as PlayerSpellsFrame; Blizzard's talent buttons and connecting lines can
-- then reject their own anchors even though SlickUI never touches those children.
local SecurePositioner = CreateFrame("Frame", "SlickUI_ExternalFramePositioner", nil, "SecureHandlerBaseTemplate")

local function SetSavedCenter(frame, relativeFrame, x, y)
	if InCombatLockdown() then return false end
	local ok = pcall(function()
		SecurePositioner:SetFrameRef("positionFrame", frame)
		SecurePositioner:SetFrameRef("relativeFrame", relativeFrame)
		SecurePositioner:SetAttribute("positionX", x)
		SecurePositioner:SetAttribute("positionY", y)
		SecurePositioner:Execute([[
			local positionFrame = self:GetFrameRef("positionFrame")
			local relativeFrame = self:GetFrameRef("relativeFrame")
			local positionX = self:GetAttribute("positionX")
			local positionY = self:GetAttribute("positionY")
			positionFrame:ClearAllPoints()
			positionFrame:SetPoint("CENTER", relativeFrame, "CENTER", positionX, positionY)
		]])
	end)
	return ok
end

local PreferredHiddenFrames = {
	AlertFrame = true,
	AchievementFrame = true,
	AuctionHouseFrame = true,
	BankFrame = true,
	BonusRollFrame = true,
	BossBanner = true,
	CharacterFrame = true,
	CollectionsJournal = true,
	CommunitiesFrame = true,
	EncounterJournal = true,
	FriendsFrame = true,
	GroupLootContainer = true,
	GuildFrame = true,
	LootFrame = true,
	MailFrame = true,
	MerchantFrame = true,
	PlayerChoiceFrame = true,
	ProfessionsFrame = true,
	PVEFrame = true,
	PVPUIFrame = true,
	QuestLogFrame = true,
	RaidWarningFrame = true,
	ReadyCheckFrame = true,
	SpellBookFrame = true,
	PlayerSpellsFrame = true,
	TalkingHeadFrame = true,
	VehicleSeatIndicator = true,
	WeeklyRewardsFrame = true,
	WorldMapFrame = true,
}

-- Blizzard loads many major windows only when their feature addon is opened.
-- Keep a root-window registry so those frames do not depend on a broad
-- EnumerateFrames scan or on having UIParent as their immediate parent.
local KnownWindowRoots = {
	"AchievementFrame",
	"AuctionHouseFrame",
	"BankFrame",
	"BarberShopFrame",
	"BlackMarketFrame",
	"CalendarFrame",
	"CharacterFrame",
	"ClassTrainerFrame",
	"CollectionsJournal",
	"CommunitiesFrame",
	"DamageMeterFrame",
	"EncounterJournal",
	"FlightMapFrame",
	"FriendsFrame",
	"GameMenuFrame",
	"GarrisonLandingPage",
	"GarrisonMissionFrame",
	"GossipFrame",
	"GuildBankFrame",
	"GuildFrame",
	"HelpFrame",
	"InspectFrame",
	"ItemSocketingFrame",
	"MailFrame",
	"MerchantFrame",
	"OpenMailFrame",
	"PlayerSpellsFrame",
	"ProfessionsFrame",
	"PVEFrame",
	"PVPUIFrame",
	"QuestFrame",
	"QuestLogFrame",
	"SettingsPanel",
	"SpellBookFrame",
	"StableFrame",
	"TradeFrame",
	"WeeklyRewardsFrame",
	"WorldMapFrame",
}

local ExcludedNamePatterns = {
	"^SlickUI",
	"^AlertFrame$",
	"AlertFrame%d*$",
	"^BuffFrame$",
	"^MinimapCluster$",
	-- These are Blizzard Edit Mode systems, not independent windows. Moving
	-- them through the generic external-frame mover competes with Blizzard's
	-- saved layout and makes the Bags mover jump after reload/Edit Mode.
	"^BagsBar$",
	"^MicroButtonAndBagsBar$",
	"^MicroMenuContainer$",
	-- Blizzard owns this transient reporting helper and reanchors it internally
	-- when its scale changes. Treating it as an independent movable window can
	-- join its anchor family back to its owner and make Blizzard's SetPoint fail.
	"^ReportScreenshotModeFrame$",
	-- Blizzard Edit Mode owns the objective tracker position. Letting the
	-- generic SlickUI window mover save it creates competing position restores.
	"^ObjectiveTrackerFrame$",
	-- Blizzard Edit Mode owns these roots and may be reconciling character and
	-- specialization layouts while they are hidden. Moving them as ordinary
	-- windows can make that reconciliation consume incomplete anchor offsets.
	"^EditModeManagerFrame$",
	"^EditModeSystemSettingsDialog$",
	"^EditModePresetLayoutManager$",
	-- Chat edit boxes are anchored and sized by Blizzard as part of each chat
	-- window. Moving one independently collapses its stretch anchors and leaves
	-- a tiny detached typing field.
	"^ChatFrame%d+EditBox$",
	"^NamePlate",
	"ActionButton",
	"^PetActionBar$",
	"^MainActionBar",
	"^MainMenuBar$",
	"MultiBar",
	"^StatusTrackingBarManager$",
	"^MicroMenuContainer$",
	"^CompactRaid",
	"^CompactParty",
	"^Boss%dTargetFrame",
	"Tooltip",
	"DropDown",
	"StaticPopup",
	"SettingsPanel",
	"InterfaceOptions",
	"^UIParent",
	"^WorldFrame",
}

local function IsSlickUIFrame(frame)
	for _, registered in pairs(SlickUI.UnitFrames or {}) do
		if registered == frame then return true end
	end
	return false
end

local function IsExcludedName(name)
	for _, pattern in ipairs(ExcludedNamePatterns) do
		if string.find(name, pattern) then return true end
	end
	return false
end

local function IsForbidden(frame)
	if frame.IsForbidden then
		local ok, forbidden = pcall(frame.IsForbidden, frame)
		if not ok or forbidden then return true end
	end
	return false
end

local GetFrameCenter
local GetFrameSize
local RoundToStep
local GetSaved
local ClampCenterToSafeScreen

local ALERT_MOVER_KEY = "Blizzard Alerts"

function ExternalFrames:ApplyAlertAnchor()
	if InCombatLockdown() or self.CinematicActive then return false end
	local alertFrame = _G.AlertFrame
	local holder = self.AlertAnchor
	if not alertFrame or not holder then return false end
	-- AlertFrame is a manager for pooled achievement, loot, and reward toasts.
	-- Giving the manager a stable base anchor keeps Blizzard in charge of the
	-- individual recycled alerts while SlickUI controls only the stack origin.
	if type(alertFrame.SetBaseAnchor) == "function" then
		return pcall(alertFrame.SetBaseAnchor, alertFrame, holder)
	end
	return SetSavedCenter(alertFrame, UIParent, GetFrameCenter(holder))
end

function ExternalFrames:CreateAlertAnchor()
	if self.AlertAnchor then return self.AlertAnchor end
	local holder = CreateFrame("Frame", "SlickUI_BlizzardAlertAnchor", UIParent, "BackdropTemplate")
	holder:SetSize(320, 72)
	holder:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 250)
	holder:SetFrameStrata("FULLSCREEN_DIALOG")
	holder:SetFrameLevel(55)
	holder:SetMovable(true)
	holder:SetClampedToScreen(true)
	holder:EnableMouse(false)
	holder:RegisterForDrag("LeftButton")
	holder:SetBackdrop({ bgFile = Theme.Texture, edgeFile = Theme.Texture, edgeSize = 2 })
	holder:SetBackdropColor(1, 0.55, 0.05, 0.06)
	holder:SetBackdropBorderColor(1, 0.67, 0.12, 0.95)
	local label = Theme:CreateText(holder, 11)
	label:SetPoint("CENTER")
	label:SetText(ALERT_MOVER_KEY)
	label:SetTextColor(unpack(Theme.Colors.goldText))
	holder:SetScript("OnDragStart", function(self)
		if SlickUI.DB.locked or InCombatLockdown() then return end
		self:StartMoving()
	end)
	holder:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()
		if InCombatLockdown() then return end
		local x, y = GetFrameCenter(self)
		if not x or not y then return end
		if SlickUI.DB.editMode.snapToGrid then
			local step = math.max(8, SlickUI.DB.editMode.gridSize or 20)
			x, y = RoundToStep(x, step), RoundToStep(y, step)
		end
		SlickUI.DB.externalFrames.positions[ALERT_MOVER_KEY] = { x = x, y = y }
		self:ClearAllPoints()
		self:SetPoint("CENTER", UIParent, "CENTER", x, y)
		ExternalFrames:ApplyAlertAnchor()
	end)
	holder:Hide()
	self.AlertAnchor = holder
	self.AlertAnchorLabel = label
	return holder
end

function ExternalFrames:SyncAlertAnchor()
	local holder = self:CreateAlertAnchor()
	local saved = GetSaved(ALERT_MOVER_KEY)
	if saved then
		local x, y = ClampCenterToSafeScreen(holder, saved.x, saved.y)
		saved.x, saved.y = x, y
		holder:ClearAllPoints()
		holder:SetPoint("CENTER", UIParent, "CENTER", x, y)
	end
	return self:ApplyAlertAnchor()
end

local function GetMinimapSystem(layout)
	local minimapSystem = Enum and Enum.EditModeSystem and Enum.EditModeSystem.Minimap or 2
	for _, systemInfo in ipairs(layout and layout.systems or {}) do
		if systemInfo.system == minimapSystem then return systemInfo end
	end
end

function ExternalFrames:CommitMinimapProxy(x, y)
	if InCombatLockdown() or self.CinematicActive then
		SlickUI:Print("The minimap cannot be moved during combat or a cinematic.")
		return false
	end
	if not C_EditMode or not C_EditMode.GetLayouts or not C_EditMode.SaveLayouts
		or not C_EditMode.SetActiveLayout or not EditModePresetLayoutManager then
		SlickUI:Print("Blizzard Edit Mode is not ready yet. Try again in a moment.")
		return false
	end

	local ok, saveInfo = pcall(C_EditMode.GetLayouts)
	if not ok or type(saveInfo) ~= "table" or type(saveInfo.layouts) ~= "table" then
		SlickUI:Print("Could not read the active Blizzard Edit Mode layout.")
		return false
	end
	local presets = EditModePresetLayoutManager:GetCopyOfPresetLayouts()
	local layouts = {}
	for _, layout in ipairs(presets or {}) do layouts[#layouts + 1] = layout end
	for _, layout in ipairs(saveInfo.layouts) do layouts[#layouts + 1] = layout end
	saveInfo.layouts = layouts

	local activeLayout = saveInfo.layouts[saveInfo.activeLayout]
	local presetType = Enum and Enum.EditModeLayoutType and Enum.EditModeLayoutType.Preset or 0
	if not activeLayout or activeLayout.layoutType == presetType then
		SlickUI:Print("The active Blizzard layout is read-only. Select or create a custom Edit Mode layout first.")
		return false
	end
	local systemInfo = GetMinimapSystem(activeLayout)
	if not systemInfo or type(systemInfo.anchorInfo) ~= "table" then
		SlickUI:Print("The minimap is missing from the active Blizzard Edit Mode layout.")
		return false
	end

	systemInfo.isInDefaultPosition = false
	systemInfo.anchorInfo.point = "CENTER"
	systemInfo.anchorInfo.relativeTo = "UIParent"
	systemInfo.anchorInfo.relativePoint = "CENTER"
	systemInfo.anchorInfo.offsetX = x
	systemInfo.anchorInfo.offsetY = y

	local saved = pcall(C_EditMode.SaveLayouts, saveInfo)
	if not saved then
		SlickUI:Print("Blizzard rejected the minimap layout change; nothing was applied.")
		return false
	end
	pcall(C_EditMode.SetActiveLayout, saveInfo.activeLayout)
	SlickUI.DB.externalFrames.minimapProxy.x = x
	SlickUI.DB.externalFrames.minimapProxy.y = y
	return true
end

function ExternalFrames:CreateMinimapProxy()
	if self.MinimapProxy then return self.MinimapProxy end
	local proxy = CreateFrame("Frame", "SlickUIMinimapEditModeProxy", UIParent, "BackdropTemplate")
	proxy:SetFrameStrata("FULLSCREEN_DIALOG")
	proxy:SetFrameLevel(60)
	proxy:SetMovable(true)
	proxy:SetClampedToScreen(true)
	proxy:EnableMouse(true)
	proxy:RegisterForDrag("LeftButton")
	proxy:SetBackdrop({ bgFile = Theme.Texture, edgeFile = Theme.Texture, edgeSize = 2 })
	proxy:SetBackdropColor(0.08, 0.35, 0.85, 0.24)
	proxy:SetBackdropBorderColor(0.25, 0.65, 1, 1)
	local label = Theme:CreateText(proxy, 11)
	label:SetPoint("CENTER")
	label:SetText("Minimap\nBlizzard Edit Mode")
	label:SetJustifyH("CENTER")
	label:SetTextColor(0.45, 0.78, 1)
	proxy:SetScript("OnDragStart", function(self)
		if SlickUI.DB.locked or InCombatLockdown() then return end
		self:StartMoving()
	end)
	proxy:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()
		if InCombatLockdown() then return end
		local x, y = self:GetCenter()
		local px, py = UIParent:GetCenter()
		if not x or not y or not px or not py then return end
		x, y = x - px, y - py
		if SlickUI.DB.editMode.snapToGrid then
			local step = math.max(8, SlickUI.DB.editMode.gridSize or 20)
			x, y = RoundToStep(x, step), RoundToStep(y, step)
			self:ClearAllPoints()
			self:SetPoint("CENTER", UIParent, "CENTER", x, y)
		end
		ExternalFrames:CommitMinimapProxy(x, y)
	end)
	proxy:Hide()
	self.MinimapProxy = proxy
	return proxy
end

function ExternalFrames:SyncMinimapProxy()
	local proxy = self:CreateMinimapProxy()
	local minimap = _G.MinimapCluster
	if not minimap then return false end
	local width, height = GetFrameSize(minimap)
	proxy:SetSize(width, height)
	local x, y = GetFrameCenter(minimap)
	if not x or not y then return false end
	proxy:ClearAllPoints()
	proxy:SetPoint("CENTER", UIParent, "CENTER", x, y)
	return true
end

local function IsNestedAlertFrame(name)
	-- Blizzard reward, achievement, loot, and similar toast frames are pooled
	-- beneath AlertFrame instead of being direct children of UIParent. Their
	-- generated names conventionally end in AlertFrame plus an optional index.
	-- Treat only that narrow family as movable nested windows so the general
	-- frame scan does not expose every child control in the interface.
	return type(name) == "string" and name:match("AlertFrame%d*$") ~= nil
end

local function IsCandidate(frame)
	if not frame or frame == UIParent or frame == WorldFrame or IsForbidden(frame) then return false end
	if not frame.GetName or not frame.GetParent or not frame.GetWidth or not frame.GetHeight then return false end
	local name = frame:GetName()
	-- Midnight may return a protected proxy instead of a readable string for
	-- some Blizzard-owned objects. Never pass those values to string APIs.
	if type(name) ~= "string" or name == "" or IsExcludedName(name) then return false end
	if (frame:GetParent() ~= UIParent and not IsNestedAlertFrame(name)) or IsSlickUIFrame(frame) then return false end
	local width, height = frame:GetWidth(), frame:GetHeight()
	if type(width) ~= "number" or type(height) ~= "number" or width < 24 or height < 18 then return false end
	if width > UIParent:GetWidth() * 0.96 or height > UIParent:GetHeight() * 0.96 then return false end
	return true
end

local function IsKnownRootCandidate(frame)
	if not frame or frame == UIParent or frame == WorldFrame or IsForbidden(frame) then return false end
	if not frame.GetWidth or not frame.GetHeight or IsSlickUIFrame(frame) then return false end
	local width, height = frame:GetWidth(), frame:GetHeight()
	if type(width) ~= "number" or type(height) ~= "number" or width < 24 or height < 18 then return false end
	local parent = frame
	for _ = 1, 12 do
		if parent == UIParent then return true end
		if not parent or not parent.GetParent then break end
		parent = parent:GetParent()
	end
	return false
end

local function ResolveGlobalPath(path)
	local value = _G
	for key in string.gmatch(path, "([^.]+)") do
		if type(value) ~= "table" then return nil end
		value = value[key]
		if value == nil then return nil end
	end
	return value
end

GetSaved = function(name)
	SlickUI.DB.externalFrames.positions = SlickUI.DB.externalFrames.positions or {}
	return SlickUI.DB.externalFrames.positions[name]
end

GetFrameCenter = function(frame)
	local centerX, centerY = frame:GetCenter()
	local parentX, parentY = UIParent:GetCenter()
	if not centerX or not centerY or not parentX or not parentY then return nil end
	return centerX - parentX, centerY - parentY
end

GetFrameSize = function(frame)
	local scale = frame:GetEffectiveScale() / UIParent:GetEffectiveScale()
	return math.max(40, frame:GetWidth() * scale), math.max(24, frame:GetHeight() * scale)
end

ClampCenterToSafeScreen = function(frame, x, y)
	local parentWidth, parentHeight = UIParent:GetWidth(), UIParent:GetHeight()
	if type(parentWidth) ~= "number" or type(parentHeight) ~= "number" then return x, y end
	local frameWidth, frameHeight = GetFrameSize(frame)
	local padding = 4
	local maximumX = math.max(0, (parentWidth - frameWidth) / 2 - padding)
	local maximumY = math.max(0, (parentHeight - frameHeight) / 2 - padding)
	x = math.max(-maximumX, math.min(maximumX, tonumber(x) or 0))
	y = math.max(-maximumY, math.min(maximumY, tonumber(y) or 0))
	return x, y
end

RoundToStep = function(value, step)
	return math.floor(value / step + 0.5) * step
end

function ExternalFrames:ApplySavedPosition(frame, name)
	if InCombatLockdown() or self.CinematicActive then return false end
	local saved = GetSaved(name)
	if not saved then return false end
	local relativeFrame = name == "PlayerSpellsFrame" and SafePlayerSpellsAnchor or UIParent
	local x, y = saved.x, saved.y
	if relativeFrame == UIParent then
		x, y = ClampCenterToSafeScreen(frame, x, y)
		-- Persist the corrected position so repeated login layout passes converge
		-- on one stable location instead of restoring an off-screen edge first.
		saved.x, saved.y = x, y
	end
	return SetSavedCenter(frame, relativeFrame, x, y)
end

local function SaveCurrentPosition(frame, name)
	if ExternalFrames.CinematicActive then return false end
	local x, y = GetFrameCenter(frame)
	if not x or not y then return false end
	x, y = ClampCenterToSafeScreen(frame, x, y)
	SlickUI.DB.externalFrames.positions[name] = { x = x, y = y }
	return true
end

function ExternalFrames:ActivateQuickMoveEntry(entry)
	if not entry or not entry.frame then return false end
	local frame = entry.frame
	pcall(frame.SetMovable, frame, true)
	pcall(frame.SetClampedToScreen, frame, true)
	if entry.dragHandle then
		entry.dragHandle:EnableMouse(self.QuickMoveEnabled and not self.Editing)
		entry.dragHandle:SetShown(self.QuickMoveEnabled and not self.Editing)
	end
	return true
end

local function StopQuickMove(entry)
	if not entry or not entry.moving then return end
	pcall(entry.frame.StopMovingOrSizing, entry.frame)
	entry.moving = false
	if not InCombatLockdown() and SaveCurrentPosition(entry.frame, entry.name) then
		ExternalFrames:ApplySavedPosition(entry.frame, entry.name)
	end
end

function ExternalFrames:CreateQuickMoveHandle(entry)
	if entry.dragHandle then return entry.dragHandle end
	local frame = entry.frame
	-- Use a plain invisible drag layer. PanelDragBarTemplate creates decorative
	-- child grips/end caps which remain visible when stretched over a full frame.
	local handle = CreateFrame("Frame", nil, frame)
	handle:SetParent(frame)
	handle:SetAllPoints(frame)
	handle:SetFrameLevel(frame:GetFrameLevel() + 1)
	handle:RegisterForDrag("LeftButton")
	if handle.SetPropagateMouseMotion then handle:SetPropagateMouseMotion(true) end
	if handle.SetPropagateMouseClicks then handle:SetPropagateMouseClicks(true) end
	handle:EnableMouse(false)
	handle:SetScript("OnDragStart", function()
		if not ExternalFrames.QuickMoveEnabled or ExternalFrames.Editing or InCombatLockdown() then return end
		local current = ExternalFrames.QuickMoveFrames[entry.name]
		if not current or current.frame ~= frame then return end
		pcall(frame.SetMovable, frame, true)
		pcall(frame.StartMoving, frame)
		current.moving = true
	end)
	handle:SetScript("OnDragStop", function()
		StopQuickMove(ExternalFrames.QuickMoveFrames[entry.name])
	end)
	entry.dragHandle = handle
	return handle
end

function ExternalFrames:PrepareQuickMoveFrame(frame, registeredName)
	if registeredName then
		if not IsKnownRootCandidate(frame) then return nil end
	elseif not IsCandidate(frame) then
		return nil
	end
	local name = registeredName or frame:GetName()
	local existing = self.QuickMoveFrames[name]
	if existing then
		if existing.frame ~= frame then
			if existing.dragHandle then existing.dragHandle:Hide() end
			existing.frame = frame
			existing.dragHandle = nil
			self:CreateQuickMoveHandle(existing)
		end
		existing.frame = frame
		self:ActivateQuickMoveEntry(existing)
		return existing
	end

	local mouseEnabled
	if frame.IsMouseEnabled then
		local ok, enabled = pcall(frame.IsMouseEnabled, frame)
		if ok then mouseEnabled = enabled == true end
	end
	local movable
	if frame.IsMovable then
		local ok, enabled = pcall(frame.IsMovable, frame)
		if ok then movable = enabled == true end
	end

	local entry = {
		frame = frame,
		name = name,
		originalMouseEnabled = mouseEnabled,
		originalMovable = movable,
		moving = false,
	}
	self.QuickMoveFrames[name] = entry
	self:CreateQuickMoveHandle(entry)
	self:ActivateQuickMoveEntry(entry)
	if frame.HookScript then
		frame:HookScript("OnShow", function(shownFrame)
			if InCombatLockdown() then return end
			C_Timer.After(0, function()
				ExternalFrames:ApplySavedPosition(shownFrame, name)
				ExternalFrames:ActivateQuickMoveEntry(entry)
			end)
		end)
	end
	return entry
end

function ExternalFrames:RegisterKnownQuickMoveFrames()
	if not self.QuickMoveEnabled or InCombatLockdown() then return end
	for _, path in ipairs(KnownWindowRoots) do
		local frame = ResolveGlobalPath(path)
		if frame then self:PrepareQuickMoveFrame(frame, path) end
	end
	-- Previously discovered addon windows remain supported without rescanning
	-- every frame in the UI. Saved names resolve directly at negligible cost.
	for name in pairs(SlickUI.DB.externalFrames.positions or {}) do
		local frame = type(name) == "string" and ResolveGlobalPath(name)
		if frame and IsKnownRootCandidate(frame) then self:PrepareQuickMoveFrame(frame, name) end
	end
end

function ExternalFrames:ScanQuickMoveFrames()
	if not self.QuickMoveEnabled or InCombatLockdown() then return end
	self:RegisterKnownQuickMoveFrames()
	local frame = EnumerateFrames()
	while frame do
		if IsCandidate(frame) and frame:IsShown() then self:PrepareQuickMoveFrame(frame) end
		frame = EnumerateFrames(frame)
	end
end

function ExternalFrames:IsQuickMoveEnabled()
	return self.QuickMoveEnabled == true
end

function ExternalFrames:SetQuickMoveEnabled(enabled)
	if InCombatLockdown() then
		SlickUI:Print("Window Move cannot be changed during combat.")
		return false
	end
	enabled = enabled == true
	self.QuickMoveEnabled = enabled
	SlickUI.DB.externalFrames.quickMoveEnabled = enabled
	if self.QuickMoveScanner then self.QuickMoveScanner:Cancel(); self.QuickMoveScanner = nil end

	if enabled then
		-- Direct-move mode must be effectively idle during play. Register known
		-- roots and saved addon windows once; ADDON_LOADED registers later roots.
		-- The old full EnumerateFrames ticker caused severe periodic FPS drops.
		self:RegisterKnownQuickMoveFrames()
		SlickUI:Print("Window Move enabled. Drag a supported Blizzard window directly to reposition it.")
	else
		for _, entry in pairs(self.QuickMoveFrames) do
			StopQuickMove(entry)
			if entry.dragHandle then entry.dragHandle:EnableMouse(false); entry.dragHandle:Hide() end
			if entry.originalMovable ~= nil then pcall(entry.frame.SetMovable, entry.frame, entry.originalMovable) end
		end
		SlickUI:Print("Window Move disabled. Window positions remain saved.")
	end
	if SlickUI.InformationBars then SlickUI.InformationBars:UpdateBlocks() end
	return true
end

function ExternalFrames:ToggleQuickMove()
	return self:SetQuickMoveEnabled(not self.QuickMoveEnabled)
end

function ExternalFrames:SyncOverlay(entry, useSaved)
	local frame, overlay = entry.frame, entry.overlay
	if not frame or not overlay or overlay.SlickDragging then return end
	local width, height = GetFrameSize(frame)
	overlay:SetSize(width, height)
	local saved = useSaved and GetSaved(entry.name)
	local x, y
	if saved then
		x, y = saved.x, saved.y
	else
		x, y = GetFrameCenter(frame)
	end
	if not x or not y then return end
	overlay:ClearAllPoints()
	overlay:SetPoint("CENTER", UIParent, "CENTER", x, y)
end

function ExternalFrames:CreateOverlay(frame)
	local name = frame:GetName()
	local existing = self.Frames[name]
	if existing then
		existing.frame = frame
		return existing
	end

	local overlay = CreateFrame("Frame", "SlickUIExternalMover_" .. name, UIParent, "BackdropTemplate")
	overlay:SetFrameStrata("FULLSCREEN_DIALOG")
	overlay:SetFrameLevel(50)
	overlay:SetMovable(true)
	overlay:SetClampedToScreen(true)
	-- Edit overlays must never own mouse input during normal play. Mouse input
	-- is enabled only while SlickUI Edit Mode is actively displaying them.
	overlay:EnableMouse(false)
	overlay:RegisterForDrag("LeftButton")
	overlay:SetBackdrop({ bgFile = Theme.Texture, edgeFile = Theme.Texture, edgeSize = 2 })
	-- Borders and labels identify the mover; a faint fill avoids expensive GPU
	-- overdraw when many large external frames overlap in edit mode.
	overlay:SetBackdropColor(1, 0.55, 0.05, 0.06)
	overlay:SetBackdropBorderColor(1, 0.67, 0.12, 0.95)

	local label = Theme:CreateText(overlay, 10)
	label:SetPoint("CENTER")
	label:SetPoint("LEFT", 4, 0)
	label:SetPoint("RIGHT", -4, 0)
	label:SetJustifyH("CENTER")
	label:SetWordWrap(false)
	label:SetText(name)
	label:SetTextColor(unpack(Theme.Colors.goldText))

	local originalPoints = {}
	for index = 1, frame:GetNumPoints() do
		local point, relativeTo, relativePoint, x, y = frame:GetPoint(index)
		originalPoints[#originalPoints + 1] = {
			point = point,
			relativeTo = relativeTo,
			relativePoint = relativePoint,
			x = x,
			y = y,
		}
	end
	local entry = { name = name, frame = frame, overlay = overlay, label = label, originalPoints = originalPoints }
	self.Frames[name] = entry
	overlay:SetScript("OnDragStart", function(self)
		if SlickUI.DB.locked or InCombatLockdown() then return end
		self.SlickDragging = true
		self:StartMoving()
	end)
	overlay:SetScript("OnDragStop", function(self)
		self:StopMovingOrSizing()
		self.SlickDragging = false
		if InCombatLockdown() then return end
		local x, y = self:GetCenter()
		local parentX, parentY = UIParent:GetCenter()
		if not x or not y or not parentX or not parentY then return end
		x, y = x - parentX, y - parentY
		if SlickUI.DB.editMode.snapToGrid then
			local step = math.max(8, SlickUI.DB.editMode.gridSize or 20)
			x, y = RoundToStep(x, step), RoundToStep(y, step)
			self:ClearAllPoints()
			self:SetPoint("CENTER", UIParent, "CENTER", x, y)
		end
		SlickUI.DB.externalFrames.positions[name] = { x = x, y = y }
		ExternalFrames:ApplySavedPosition(frame, name)
	end)
	frame:HookScript("OnShow", function(shownFrame)
		if not InCombatLockdown() then
			C_Timer.After(0, function()
				ExternalFrames:ApplySavedPosition(shownFrame, name)
				local current = ExternalFrames.Frames[name]
				if ExternalFrames.Editing and current then ExternalFrames:SyncOverlay(current, true) end
			end)
		end
	end)
	self:SyncOverlay(entry, true)
	overlay:Hide()
	return entry
end

function ExternalFrames:ScanVisibleFrames()
	if not self.Editing or InCombatLockdown() then return end
	local frame = EnumerateFrames()
	while frame do
		if IsCandidate(frame) and frame:IsShown() then
			local name = frame:GetName()
			self.Seen[name] = true
			local entry = self:CreateOverlay(frame)
			self:ApplySavedPosition(frame, name)
			self:SyncOverlay(entry, true)
			entry.label:SetText(name)
			local visible = not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible(name)
			entry.overlay:EnableMouse(visible)
			entry.overlay:SetShown(visible)
		end
		frame = EnumerateFrames(frame)
	end
end

function ExternalFrames:ApplyKnownPositions()
	if InCombatLockdown() or self.CinematicActive then return end
	-- Saved entries use global frame names. Resolve only those frames instead of
	-- enumerating the entire UI, which can exceed WoW's script budget after a
	-- cinematic when many Blizzard and addon frames are created simultaneously.
	for name in pairs(SlickUI.DB.externalFrames.positions or {}) do
		local frame = ResolveGlobalPath(name)
		if frame and (IsCandidate(frame) or IsKnownRootCandidate(frame)) then
			self:CreateOverlay(frame)
			self:ApplySavedPosition(frame, name)
		end
	end
end

function ExternalFrames:GetHiddenFrames()
	local result = {}
	local frame = EnumerateFrames()
	while frame do
		if IsCandidate(frame) and not frame:IsShown() then
			local name = frame:GetName()
			if PreferredHiddenFrames[name] or self.Seen[name] or GetSaved(name) then
				result[#result + 1] = frame
			end
		end
		frame = EnumerateFrames(frame)
	end
	table.sort(result, function(a, b) return a:GetName() < b:GetName() end)
	return result
end

function ExternalFrames:ShowHiddenPlaceholder(frame)
	if not frame or InCombatLockdown() then return end
	local entry = self:CreateOverlay(frame)
	self:SyncOverlay(entry, true)
	entry.label:SetText(entry.name .. " (hidden)")
	local visible = not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible(entry.name)
	entry.overlay:EnableMouse(visible)
	entry.overlay:SetShown(visible)
end

function ExternalFrames:CreateHiddenChooser()
	if self.HiddenButton then return self.HiddenButton end
	local button = CreateFrame("Button", "SlickUIHiddenFrameChooserButton", UIParent, "UIPanelButtonTemplate")
	button:SetSize(160, 30)
	button:SetPoint("LEFT", SlickUI.Movers:CreateVisibilityButton(), "RIGHT", 10, 0)
	button:SetFrameStrata("TOOLTIP")
	button:SetFrameLevel(1000)
	button:SetText("Hidden Frames")
	if button:GetFontString() then Theme:SetFont(button:GetFontString(), 10) end
	local menuFrame = CreateFrame("Frame", "SlickUIHiddenFrameChooserMenu", UIParent, "UIDropDownMenuTemplate")
	UIDropDownMenu_Initialize(menuFrame, function(_, level)
		local frames = ExternalFrames:GetHiddenFrames()
		local title = UIDropDownMenu_CreateInfo()
		title.text = "Loaded hidden frames"
		title.isTitle = true
		title.notCheckable = true
		UIDropDownMenu_AddButton(title, level)
		if #frames == 0 then
			local empty = UIDropDownMenu_CreateInfo()
			empty.text = "No supported hidden frames loaded"
			empty.disabled = true
			empty.notCheckable = true
			UIDropDownMenu_AddButton(empty, level)
		else
			for index, hiddenFrame in ipairs(frames) do
				if index > 35 then
					local more = UIDropDownMenu_CreateInfo()
					more.text = "Open a frame once to make it discoverable."
					more.disabled = true
					more.notCheckable = true
					UIDropDownMenu_AddButton(more, level)
					break
				end
				local selectedFrame = hiddenFrame
				local info = UIDropDownMenu_CreateInfo()
				info.text = selectedFrame:GetName()
				info.notCheckable = true
				info.func = function() ExternalFrames:ShowHiddenPlaceholder(selectedFrame) end
				UIDropDownMenu_AddButton(info, level)
			end
		end
	end, "MENU")
	button:SetScript("OnClick", function(self)
		ToggleDropDownMenu(1, nil, menuFrame, self, 0, 0)
	end)
	button:Hide()
	self.HiddenButton = button
	return button
end

function ExternalFrames:BeginEditMode()
	if InCombatLockdown() then return end
	if self.QuickMoveEnabled then self:SetQuickMoveEnabled(false) end
	self.Editing = true
	self:CreateHiddenChooser():Show()
	self:ScanVisibleFrames()
	self:SyncAlertAnchor()
	self.AlertAnchor:EnableMouse(not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible(ALERT_MOVER_KEY))
	self.AlertAnchor:SetShown(not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible(ALERT_MOVER_KEY))
	if self:SyncMinimapProxy() then
		self.MinimapProxy:SetShown(not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible("MinimapCluster"))
	end
	for name, entry in pairs(self.Frames) do
		if GetSaved(name) and not entry.frame:IsShown() then
			self:SyncOverlay(entry, true)
			entry.label:SetText(name .. " (hidden)")
			local visible = not SlickUI.Movers or SlickUI.Movers:IsMoverOverlayVisible(name)
			entry.overlay:EnableMouse(visible)
			entry.overlay:SetShown(visible)
		end
	end
	-- Discover visible frames once when edit mode opens. Re-enumerating every
	-- frame once per second caused a severe, continuous FPS drop on busy UIs.
	-- Existing OnShow hooks keep registered frames synchronized, while the
	-- Hidden Frames chooser remains available for loaded hidden windows.
	if self.Scanner then self.Scanner:Cancel(); self.Scanner = nil end
end

function ExternalFrames:EndEditMode()
	self.Editing = false
	if self.Scanner then self.Scanner:Cancel(); self.Scanner = nil end
	if self.HiddenButton then self.HiddenButton:Hide() end
	if self.MinimapProxy then self.MinimapProxy:Hide() end
	if self.AlertAnchor then self.AlertAnchor:EnableMouse(false); self.AlertAnchor:Hide() end
	for _, entry in pairs(self.Frames) do
		entry.overlay:Hide()
		entry.overlay:EnableMouse(false)
	end
end

function ExternalFrames:ResetPositions()
	if InCombatLockdown() then return end
	SlickUI.DB.externalFrames.positions = {}
	if self.AlertAnchor then
		self.AlertAnchor:ClearAllPoints()
		self.AlertAnchor:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 250)
		self:ApplyAlertAnchor()
	end
	for _, entry in pairs(self.Frames) do
		pcall(function()
			entry.frame:ClearAllPoints()
			for _, savedPoint in ipairs(entry.originalPoints) do
				entry.frame:SetPoint(savedPoint.point, savedPoint.relativeTo, savedPoint.relativePoint, savedPoint.x, savedPoint.y)
			end
		end)
		entry.label:SetText(entry.name)
		self:SyncOverlay(entry, false)
	end
end

function ExternalFrames:Initialize()
	-- Remove positions saved by older builds before this internal Blizzard
	-- helper was excluded. ApplyKnownPositions must never restore that anchor.
	SlickUI.DB.externalFrames.positions.ReportScreenshotModeFrame = nil
	-- Blizzard Edit Mode owns the relationship between these roots. Old saved
	-- positions can form an anchor cycle when Edit Mode restores its layout.
	SlickUI.DB.externalFrames.positions.BuffFrame = nil
	SlickUI.DB.externalFrames.positions.MinimapCluster = nil
	SlickUI.DB.externalFrames.positions.BagsBar = nil
	SlickUI.DB.externalFrames.positions.MicroButtonAndBagsBar = nil
	SlickUI.DB.externalFrames.positions.MicroMenuContainer = nil
	SlickUI.DB.externalFrames.positions.ObjectiveTrackerFrame = nil
	SlickUI.DB.externalFrames.positions.EditModeManagerFrame = nil
	SlickUI.DB.externalFrames.positions.EditModeSystemSettingsDialog = nil
	SlickUI.DB.externalFrames.positions.EditModePresetLayoutManager = nil
	for name in pairs(SlickUI.DB.externalFrames.positions) do
		if type(name) == "string" and name:match("^ChatFrame%d+EditBox$") then
			SlickUI.DB.externalFrames.positions[name] = nil
		end
	end
	self:CreateMinimapProxy()
	self:CreateAlertAnchor()
	self:CreateHiddenChooser()
	self.EventFrame = CreateFrame("Frame")
	self.EventFrame:RegisterEvent("ADDON_LOADED")
	self.EventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
	self.EventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
	self.EventFrame:RegisterEvent("CINEMATIC_START")
	self.EventFrame:RegisterEvent("CINEMATIC_STOP")
	self.EventFrame:SetScript("OnEvent", function(_, event)
		if event == "CINEMATIC_START" then
			ExternalFrames.CinematicActive = true
			ExternalFrames.RestoreGeneration = ExternalFrames.RestoreGeneration + 1
			return
		elseif event == "CINEMATIC_STOP" then
			ExternalFrames.RestoreGeneration = ExternalFrames.RestoreGeneration + 1
			local generation = ExternalFrames.RestoreGeneration
			C_Timer.After(0.75, function()
				if generation ~= ExternalFrames.RestoreGeneration then return end
				ExternalFrames.CinematicActive = false
				ExternalFrames:ApplyKnownPositions()
				if SlickUI.Movers and SlickUI.Movers.ApplySavedPositions then SlickUI.Movers:ApplySavedPositions() end
			end)
			return
		end
		if event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_REGEN_ENABLED" then
			C_Timer.After(0.5, function()
				ExternalFrames:ApplyKnownPositions()
				ExternalFrames:SyncAlertAnchor()
			end)
		end
		if event == "ADDON_LOADED" and ExternalFrames.QuickMoveEnabled then
			C_Timer.After(0, function() ExternalFrames:RegisterKnownQuickMoveFrames() end)
			C_Timer.After(0.25, function() ExternalFrames:RegisterKnownQuickMoveFrames() end)
		end
		if event == "PLAYER_REGEN_ENABLED" and ExternalFrames.QuickMoveEnabled then
			C_Timer.After(0, function() ExternalFrames:RegisterKnownQuickMoveFrames() end)
		end
	end)
	C_Timer.After(2, function()
		self:ApplyKnownPositions()
		self:SyncAlertAnchor()
	end)
	if SlickUI.DB.externalFrames.quickMoveEnabled then
		C_Timer.After(2, function() ExternalFrames:SetQuickMoveEnabled(true) end)
	end
end

SlickUI:RegisterModule("ExternalFrames", ExternalFrames)
