-- KiraUI-Plugin_EditMode.lua
-- Unlock mode: drag every enabled KiraUI display at once.
--
-- Each module already has its own preview-and-right-drag, but that only helps
-- one display at a time and only while its options page is open. This puts all
-- of them on screen together, each in a labelled orange box so you can tell a
-- KiraUI element from the rest of your UI at a glance, and lets you drag them
-- with the left mouse button.
--
-- WHY AN OVERLAY RATHER THAN THE MODULES' OWN DRAG
--
-- The modules bind RIGHT-drag to move, because right-drag does not collide with
-- clicking things underneath. Here the right button is wanted for "open this
-- element's settings", so edit mode puts a transparent overlay ON TOP of each
-- element and handles both buttons itself. The element's own handlers are never
-- touched, so nothing has to be undone when edit mode ends.
--
-- CHANGES ARE PROVISIONAL
--
-- Every position is snapshotted on entry. Leaving without confirming restores
-- the snapshot, so experimenting costs nothing - Reset puts everything back
-- without leaving, and closing with unsaved moves asks first.

local addonName, ns = ...
local _G = _G

local CreateFrame = CreateFrame
local UIParent = UIParent
local pairs, ipairs, type, pcall = pairs, ipairs, type, pcall
local mfloor = math.floor

ns.hasEditMode = true

local ORANGE = { 1, 0.49, 0.04 }

----------------------------------------------------------------------
-- The registry
--
-- One row per movable display. Everything edit mode needs to know lives here
-- rather than being scattered across fifteen module files:
--   frame   global frame name the module creates
--   db      KiraUIChangesDB sub-table holding its x/y
--   page    options page key, for the right-click jump
--   apply   ns.<name> to re-run after a move
--   preview ns.<name> that forces the display visible (optional - several
--           elements only appear in combat or on a ready check, and you cannot
--           place something you cannot see)
----------------------------------------------------------------------
local MOVABLES = {
	{ label = 'Bloodlust ready',  frame = 'KiraUIBloodlust',       db = 'bloodlust',      page = 'bloodlust',   apply = 'BLApply',    preview = 'BLSetPreview' },
	{ label = 'Buff Watch',       frame = 'KiraUIBuffWatchAnchor', db = 'buffWatch',      page = 'buffwatch',   apply = 'BWApply',    preview = 'BWSetPreview' },
	{ label = 'Combat Text',      frame = 'KiraUICombatText',      db = 'combatText',     page = 'combattext',  apply = 'CTApply',    preview = 'CTSetPreview' },
	{ label = 'Control Undead',   frame = 'KiraUIControlUndead',   db = 'controlUndead',  page = 'class_dk',    apply = 'CUApply',    preview = 'CUSetPreview' },
	{ label = 'Crosshair',        frame = 'KiraUICrosshair',       db = 'crosshair',      page = 'crosshair',   apply = 'CHApply' },
	{ label = 'Trinkets',         frame = 'KiraUITrinkets',        db = 'trinkets',       page = 'trinkets',    apply = 'TRApply' },
	{ label = 'Externals',        frame = 'KiraUIExternals',       db = 'externals',      page = 'externals',   apply = 'ExtApply',   preview = 'ExtSetPreview' },
	{ label = 'Fiery Brand',      frame = 'KiraUIFieryBrand',      db = 'fieryBrand',     page = 'class_veng',  apply = 'FBApply',    preview = 'FBSetPreview' },
	{ label = 'Potion ready',     frame = 'KiraUIPotion',          db = 'potion',         page = 'potion',      apply = 'PTApply',    preview = 'PTSetPreview' },
	{ label = 'Raid Markers',     frame = 'KiraUIRaidMarkers',     db = 'raidMarkers',    page = 'raidmarkers', apply = 'RMApply',    preview = 'RMSetPreview' },
	{ label = 'Ready Check',      frame = 'KiraUIReadyCheckBar',   db = 'readyCheck',     page = 'readycheck',  apply = 'RCApply',    preview = 'RCSetPreview' },
	{ label = 'Recuperate',       frame = 'KiraUIRecuperate',      db = 'recuperate',     page = 'recup',       apply = 'RecupApply', preview = 'RecupSetPreview' },
	{ label = 'Secondary Stats',  frame = 'KiraUISecondaryStats',  db = 'secondaryStats', page = 'secstats',    apply = 'SSApply' },
	{ label = 'WoG bar',          frame = 'KiraUIShiningLight',    db = 'shiningLight',   page = 'class_prot',  apply = 'SLApply',    preview = 'SLSetPreview', xKey = 'barX', yKey = 'barY' },
	{ label = 'Alter Time',       frame = 'KiraUIAlterTimeHP',     db = 'mageAlterTime',  page = 'class_mage',  apply = 'MageApply',  preview = 'MageSetPreview' },

	-- These three live in the core file and keep their coordinates in a nested
	-- `pos` table rather than flat x/y, hence nested = true.
	{ label = 'Cooldown Pulse',   frame = 'KiraUICooldownPulse',   db = 'cdPulse', nested = true, page = 'cd',      apply = 'ApplyCdPulseSettings', preview = 'SetCdPreview' },
	{ label = 'Spell History',    frame = 'KiraUISpellHistory',    db = 'history', nested = true, page = 'history', apply = 'ApplyHistorySettings', preview = 'SetHistoryPreview' },
	{ label = 'Instance Reset',   frame = 'KiraUIResetButton',     db = 'reset',   nested = true, page = 'reset',   apply = 'ApplyResetSettings' },
}

local function Cfg(m)
	local db = _G.KiraUIChangesDB
	return db and db[m.db]
end

local function IsOn(m)
	local c = Cfg(m)
	return c and c.enable and true or false
end

-- Two settings shapes exist in this addon: most modules store x/y directly on
-- their config, the three core displays keep them in a nested `pos` table.
-- Everything below goes through these so the difference stops mattering.
local function PosTable(m)
	local c = Cfg(m)
	if not c then return nil end
	if m.nested then
		c.pos = c.pos or {}
		return c.pos
	end
	return c
end

-- Most modules keep their position in plain `x`/`y`, but not all: the WoG bar
-- uses barX/barY because that config once held an icon position in x/y too.
-- A row can name its own keys rather than have edit mode guess - guessing is
-- what made dragging the bar write to keys nothing read, so SLApply snapped it
-- straight back and the move looked like it had been ignored.
local function XKey(m) return m.xKey or 'x' end
local function YKey(m) return m.yKey or 'y' end

local function GetXY(m)
	local t = PosTable(m)
	if not t then return 0, 0 end
	return t[XKey(m)] or 0, t[YKey(m)] or 0
end

local function SetXY(m, x, y)
	local t = PosTable(m)
	if not t then return end
	t[XKey(m)], t[YKey(m)] = x, y
end

local function Frm(m)
	local f = _G[m.frame]
	-- a module that never built its frame simply has nothing to place
	if type(f) == 'table' and f.GetObjectType then return f end
	return nil
end

-- Anchor a frame the module never positioned.
--
-- Class-gated modules build their frame on every character but only POSITION
-- it on the right class - Fiery Brand does this. Off-class the frame exists,
-- reports shown, and has no centre, so there was nothing to draw a box around.
-- Rather than hide it (you still want to lay your UI out from any character,
-- the way the Monk and Mage displays already allow), edit mode places it from
-- its own saved coordinates. The module re-applies the same values the moment
-- you log onto a character that can use it, so this changes nothing else.
-- Remembers which frames WE anchored. Only those get re-anchored on a move:
-- a module that positions itself must keep doing so, including the ones that
-- attach to another frame rather than to screen coordinates - forcing those to
-- UIParent centre would silently break attach mode.
local placedBySelf = {}

local function EnsurePlaced(m, f)
	if not f or f:GetCenter() then return end
	local x, y = GetXY(m)
	local ok = pcall(function()
		f:ClearAllPoints()
		f:SetPoint('CENTER', UIParent, 'CENTER', x, y)
	end)
	if ok then placedBySelf[m.db] = true end
	return ok
end

-- Move a frame we own the placement of. No-op for everything else.
local function RePlaceIfOurs(m, f)
	if not f or not placedBySelf[m.db] then return end
	local x, y = GetXY(m)
	pcall(function()
		f:ClearAllPoints()
		f:SetPoint('CENTER', UIParent, 'CENTER', x, y)
	end)
end

----------------------------------------------------------------------
-- State
----------------------------------------------------------------------
local active, dirty = false, false
local snapshot = {}     -- db key -> { x, y }
local overlays = {}     -- db key -> overlay frame
local panel                     -- the little control window
local selected                  -- the overlay currently clicked, or nil
local ApplyOne, Rebuild, Stop, Select, Nudge, RefreshReadout

local function TakeSnapshot()
	snapshot = {}
	for _, m in ipairs(MOVABLES) do
		if Cfg(m) then
			local x, y = GetXY(m)
			snapshot[m.db] = { x = x, y = y }
		end
	end
	dirty = false
end

local function RestoreSnapshot()
	for _, m in ipairs(MOVABLES) do
		local c, snap = Cfg(m), snapshot[m.db]
		if c and snap then
			SetXY(m, snap.x, snap.y)
			ApplyOne(m)
		end
	end
	dirty = false
end

function ApplyOne(m)
	local f = m.apply and ns[m.apply]
	if type(f) == 'function' then pcall(f) end
end

----------------------------------------------------------------------
-- Overlays
----------------------------------------------------------------------
-- Selected and unselected looks. Selection has to be obvious at a glance,
-- because the nudge arrows and the arrow keys both act on it - moving the
-- wrong element by 10px is easy to do and annoying to undo.
local UNSEL = { fill = 0.10, edge = 0.55 }
local SEL   = { fill = 0.28, edge = 1.00 }

local function Paint(o)
	local isSel = (selected == o)
	local look = isSel and SEL or UNSEL
	o.bg:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], look.fill)
	for _, t in ipairs(o.edges) do
		t:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], look.edge)
		t:SetHeight(t.horiz and (isSel and 2 or 1) or t:GetHeight())
		if not t.horiz then t:SetWidth(isSel and 2 or 1) end
	end
	o.label:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])
	o.tools:SetShown(isSel)
end

-- Move by whole pixels and write it straight back to the module's settings, so
-- the number under the arrows is always the number that will be saved.
function Nudge(o, dx, dy)
	if not Cfg(o.spec) then return end
	local x, y = GetXY(o.spec)
	SetXY(o.spec, x + dx, y + dy)
	dirty = true
	ApplyOne(o.spec)
	-- off-class the module's apply is a no-op, so a frame WE anchored has to be
	-- moved here or the arrows would change the numbers but not the box
	RePlaceIfOurs(o.spec, Frm(o.spec))
	RefreshReadout(o)
	Rebuild()
end

function RefreshReadout(o)
	if not Cfg(o.spec) then return end
	local x, y = GetXY(o.spec)
	o.readout:SetFormattedText('X %d   Y %d', x, y)
end

function Select(o)
	local prev = selected
	selected = o
	if prev and prev ~= o then Paint(prev) end
	if o then Paint(o) RefreshReadout(o) end
end

local function MakeOverlay(m)
	local o = CreateFrame('Frame', nil, UIParent)
	o:SetFrameStrata('FULLSCREEN_DIALOG')
	o:EnableMouse(true)
	o:RegisterForDrag('LeftButton')
	o.spec = m

	local bg = o:CreateTexture(nil, 'BACKGROUND')
	bg:SetAllPoints()
	o.bg = bg

	-- an orange frame, so a KiraUI element is obvious against the rest of the
	-- UI even when it sits on top of something busy
	local edges = {}
	for i = 1, 4 do
		local t = o:CreateTexture(nil, 'OVERLAY')
		edges[i] = t
	end
	edges[1].horiz, edges[2].horiz = true, true
	edges[1]:SetPoint('TOPLEFT') edges[1]:SetPoint('TOPRIGHT') edges[1]:SetHeight(1)
	edges[2]:SetPoint('BOTTOMLEFT') edges[2]:SetPoint('BOTTOMRIGHT') edges[2]:SetHeight(1)
	edges[3]:SetPoint('TOPLEFT') edges[3]:SetPoint('BOTTOMLEFT') edges[3]:SetWidth(1)
	edges[4]:SetPoint('TOPRIGHT') edges[4]:SetPoint('BOTTOMRIGHT') edges[4]:SetWidth(1)
	o.edges = edges

	local label = o:CreateFontString(nil, 'OVERLAY')
	label:SetFont(ns.GetFont(), 11, 'OUTLINE')
	label:SetPoint('BOTTOM', o, 'TOP', 0, 2)
	label:SetText(m.label)
	o.label = label

	----------------------------------------------------------------
	-- Tools: only on the SELECTED element. A nudge cluster and the live
	-- coordinates, attached to the element itself rather than to the control
	-- panel, so your eye never has to leave the thing you are placing.
	----------------------------------------------------------------
	local tools = CreateFrame('Frame', nil, o)
	tools:SetFrameStrata('FULLSCREEN_DIALOG')
	tools:SetFrameLevel(o:GetFrameLevel() + 10)
	tools:SetSize(96, 46)
	tools:SetPoint('TOP', o, 'BOTTOM', 0, -6)
	tools:Hide()
	o.tools = tools

	local tbg = tools:CreateTexture(nil, 'BACKGROUND')
	tbg:SetAllPoints()
	tbg:SetColorTexture(0.055, 0.055, 0.065, 0.95)

	local readout = tools:CreateFontString(nil, 'OVERLAY')
	readout:SetFont(ns.GetFont(), 11, 'OUTLINE')
	readout:SetPoint('BOTTOM', tools, 'BOTTOM', 0, 4)
	readout:SetTextColor(1, 1, 1)
	o.readout = readout

	-- Shift = 10px, matching the nudge arrows already on the options pages.
	local function Arrow(rot, ox, oy, dx, dy)
		local b = CreateFrame('Button', nil, tools)
		b:SetSize(18, 16)
		b:SetPoint('TOP', tools, 'TOP', ox, oy)
		local t = b:CreateTexture(nil, 'ARTWORK')
		t:SetAllPoints()
		t:SetTexture([[Interface\ChatFrame\ChatFrameExpandArrow]])
		t:SetRotation(rot)
		b:SetScript('OnClick', function()
			local step = _G.IsShiftKeyDown and _G.IsShiftKeyDown() and 10 or 1
			Nudge(o, dx * step, dy * step)
		end)
		b:SetScript('OnEnter', function() t:SetVertexColor(1, 0.82, 0) end)
		b:SetScript('OnLeave', function() t:SetVertexColor(1, 1, 1) end)
		return b
	end
	-- source art points RIGHT; rotate from there
	Arrow(math.pi / 2,  0, -2,  0,  1)   -- up
	Arrow(-math.pi / 2, 0, -20, 0, -1)   -- down
	Arrow(math.pi,     -20, -11, -1, 0)  -- left
	Arrow(0,            20, -11,  1, 0)  -- right

	o:SetScript('OnEnter', function(self)
		if selected ~= self then
			self.bg:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.20)
		end
		_G.GameTooltip:SetOwner(self, 'ANCHOR_RIGHT')
		_G.GameTooltip:AddLine(m.label, 1, 1, 1)
		_G.GameTooltip:AddLine('Left-click to select, drag to move', 0.8, 0.8, 0.8)
		_G.GameTooltip:AddLine('Arrow keys nudge 1px (Shift = 10)', 0.8, 0.8, 0.8)
		_G.GameTooltip:AddLine('Right-click for settings', 0.8, 0.8, 0.8)
		_G.GameTooltip:Show()
	end)
	o:SetScript('OnLeave', function(self)
		Paint(self)
		_G.GameTooltip:Hide()
	end)

	o:SetScript('OnMouseDown', function(self, button)
		if button == 'LeftButton' then Select(self) end
	end)

	o:SetScript('OnDragStart', function(self)
		local f = Frm(self.spec)
		if not f then return end
		Select(self)
		self.moving = f
		f:SetMovable(true)
		f:StartMoving()
	end)
	o:SetScript('OnDragStop', function(self)
		local f = self.moving
		self.moving = nil
		if not f then return end
		f:StopMovingOrSizing()
		local cx, cy = f:GetCenter()
		local ux, uy = UIParent:GetCenter()
		if Cfg(self.spec) and cx and ux then
			SetXY(self.spec, mfloor(cx - ux + 0.5), mfloor(cy - uy + 0.5))
			dirty = true
		end
		-- re-apply so the module re-anchors from its own settings; without this
		-- the frame sits where it was dropped but snaps back on the next apply
		ApplyOne(self.spec)
		-- A class-gated module ignores that call off-class, so if WE anchored
		-- this frame, we keep it where it was dropped.
		RePlaceIfOurs(self.spec, f)
		RefreshReadout(self)
		Rebuild()
	end)

	o:SetScript('OnMouseUp', function(self, button)
		if button ~= 'RightButton' then return end
		Select(self)
		if ns.ShowConfigPage and self.spec.page then
			pcall(ns.ShowConfigPage, self.spec.page)
		elseif ns.OpenConfig then
			ns.OpenConfig()
		end
	end)

	Paint(o)
	return o
end

-- Re-place every overlay over its element. Called after any move and on a slow
-- ticker, because several elements resize themselves as their contents change.
function Rebuild()
	if not active then return end
	for _, m in ipairs(MOVABLES) do
		local f = IsOn(m) and Frm(m) or nil
		local o = overlays[m.db]
		-- A frame with no anchor point has no position to draw a box around.
		-- Class-gated modules do exactly this: Fiery Brand builds its holder on
		-- every character but only POSITIONS it on a Demon Hunter, so off-class
		-- the frame exists, reports shown, and has no centre. Anchoring an
		-- overlay to it would put the box nowhere.
		if f then EnsurePlaced(m, f) end
		if f and f:IsShown() and f:GetCenter() then
			if not o then o = MakeOverlay(m) overlays[m.db] = o end
			local w, h = f:GetWidth(), f:GetHeight()
			-- a zero-sized element cannot be grabbed, so give it a usable box
			o:SetSize(math.max(w or 0, 24), math.max(h or 0, 24))
			o:ClearAllPoints()
			o:SetPoint('CENTER', f, 'CENTER', 0, 0)
			o:Show()
		elseif o then
			o:Hide()
		end
	end
end

----------------------------------------------------------------------
-- Grid
--
-- 40px squares. The two CENTRE lines are drawn brighter because every position in
-- this addon is stored as an offset from the screen centre - that crossing is
-- literally x=0, y=0, so it is worth being able to see.
----------------------------------------------------------------------
local GRID_STEP = 40
local grid

-- ON by default. `editGrid` is nil until you touch the button, and nil has to
-- mean "on" - a plain truthiness test would start it off and there would be no
-- way to tell "never set" apart from "deliberately turned off".
local function GridShown()
	local db = _G.KiraUIChangesDB
	if not db then return true end
	return db.editGrid ~= false
end

local function LayoutGrid()
	if not grid then
		grid = CreateFrame('Frame', 'KiraUIEditModeGrid', UIParent)
		grid:SetAllPoints(UIParent)
		grid:SetFrameStrata('BACKGROUND')
		grid.lines = {}
		grid:Hide()
	end
	for _, t in ipairs(grid.lines) do t:Hide() end
	local n = 0
	local function Line(vertical, offset, centre)
		n = n + 1
		local t = grid.lines[n]
		if not t then
			t = grid:CreateTexture(nil, 'BACKGROUND')
			grid.lines[n] = t
		end
		t:SetColorTexture(1, 1, 1, centre and 0.65 or 0.50)
		t:ClearAllPoints()
		if vertical then
			t:SetWidth(1)
			t:SetPoint('TOP', grid, 'TOP', offset, 0)
			t:SetPoint('BOTTOM', grid, 'BOTTOM', offset, 0)
		else
			t:SetHeight(1)
			t:SetPoint('LEFT', grid, 'LEFT', 0, offset)
			t:SetPoint('RIGHT', grid, 'RIGHT', 0, offset)
		end
		t:Show()
	end
	-- built outward FROM the centre, so a line always sits exactly on 0 no
	-- matter what the resolution is
	local halfW = mfloor((UIParent:GetWidth() or 1920) / 2)
	local halfH = mfloor((UIParent:GetHeight() or 1080) / 2)
	Line(true, 0, true)
	Line(false, 0, true)
	for x = GRID_STEP, halfW, GRID_STEP do Line(true, x) Line(true, -x) end
	for y = GRID_STEP, halfH, GRID_STEP do Line(false, y) Line(false, -y) end
	return grid
end

local function RefreshGrid()
	local g = LayoutGrid()
	g:SetShown(active and GridShown())
end

----------------------------------------------------------------------
-- Entry flourish
--
-- The logo bursts out of the cursor, arcs across the screen trailing sparks,
-- and slams into the control panel, which flashes on impact. Around 2.5s.
--
-- The arc is a Path animation with a control point pushed PERPENDICULAR to the
-- straight line between the two ends. A plain Translation would slide in a dead
-- straight line, which reads as a sprite being repositioned rather than
-- something thrown; the curve is what sells it. The trail is the same path
-- replayed by six smaller sparks on staggered start delays, so they string out
-- behind the logo instead of all sitting on top of it.
--
-- Purely decorative and entirely optional: every piece is pcall'd or nil-checked
-- and mouse input is off throughout, because it flies across the very elements
-- you are about to start clicking.
----------------------------------------------------------------------
-- 256px, cropped from logo_shield and squared, so it stays sharp when large.
-- The old 64px icon was upscaled 2.5x, which is why it looked rough.
local LOGO  = [[Interface\AddOns\KiraUI-Plugin\Media\logo_edit.tga]]
local SPARK = [[Interface\Cooldown\star4]]

local flight, sparks, impact

local function BuildFlight()
	if flight then return end

	flight = CreateFrame('Frame', nil, UIParent)
	flight:SetFrameStrata('FULLSCREEN_DIALOG')
	flight:SetFrameLevel(600)
	flight:EnableMouse(false)
	flight:SetSize(110, 110)
	flight:Hide()
	local tex = flight:CreateTexture(nil, 'ARTWORK')
	tex:SetAllPoints()
	tex:SetTexture(LOGO)
	flight.tex = tex

	local ag = flight:CreateAnimationGroup()
	flight.ag = ag

	-- 1. burst out of the cursor
	local pop = ag:CreateAnimation('Scale')
	pop:SetScaleFrom(0.05, 0.05) pop:SetScaleTo(1, 1)
	pop:SetDuration(0.3) pop:SetOrder(1) pop:SetSmoothing('OUT')
	local popA = ag:CreateAnimation('Alpha')
	popA:SetFromAlpha(0) popA:SetToAlpha(1)
	popA:SetDuration(0.3) popA:SetOrder(1)
	local popSpin = ag:CreateAnimation('Rotation')
	popSpin:SetDegrees(-180) popSpin:SetDuration(0.3) popSpin:SetOrder(1)
	popSpin:SetOrigin('CENTER', 0, 0) popSpin:SetSmoothing('OUT')

	-- 2. the arc
	local path = ag:CreateAnimation('Path')
	path:SetDuration(1.5) path:SetOrder(2)
	if path.SetCurve then pcall(path.SetCurve, path, 'SMOOTH') end
	flight.path = path
	flight.cp1 = path:CreateControlPoint()
	flight.cp1:SetOrder(1)
	flight.cp2 = path:CreateControlPoint()
	flight.cp2:SetOrder(2)

	local spin = ag:CreateAnimation('Rotation')
	spin:SetDegrees(540) spin:SetDuration(1.5) spin:SetOrder(2)
	spin:SetOrigin('CENTER', 0, 0) spin:SetSmoothing('IN_OUT')
	local shrink = ag:CreateAnimation('Scale')
	shrink:SetScaleFrom(1, 1) shrink:SetScaleTo(0.18, 0.18)
	shrink:SetDuration(1.5) shrink:SetOrder(2) shrink:SetSmoothing('IN')
	local dim = ag:CreateAnimation('Alpha')
	dim:SetFromAlpha(1) dim:SetToAlpha(0)
	dim:SetDuration(1.5) dim:SetOrder(2) dim:SetSmoothing('IN')

	-- BOTH are needed. An animation group RESTORES the frame's base state when it
	-- finishes, so without OnFinished the logo reappeared at full alpha back at
	-- its anchor - the cursor, i.e. sitting on the unlock icon.
	ag:SetScript('OnFinished', function() flight:Hide() end)
	ag:SetScript('OnStop', function() flight:Hide() end)

	-- the trail
	sparks = {}
	for i = 1, 6 do
		local sp = CreateFrame('Frame', nil, UIParent)
		sp:SetFrameStrata('FULLSCREEN_DIALOG')
		sp:SetFrameLevel(590)
		sp:EnableMouse(false)
		sp:SetSize(46 - i * 4, 46 - i * 4)
		sp:Hide()
		local st = sp:CreateTexture(nil, 'ARTWORK')
		st:SetAllPoints()
		st:SetTexture(SPARK)
		st:SetBlendMode('ADD')
		st:SetVertexColor(ORANGE[1], ORANGE[2], ORANGE[3])
		local sag = sp:CreateAnimationGroup()
		local sPath = sag:CreateAnimation('Path')
		sPath:SetDuration(1.5) sPath:SetOrder(1)
		-- each spark leaves a little later, so they string out behind the logo
		sPath:SetStartDelay(0.3 + i * 0.07)
		sp.cp1 = sPath:CreateControlPoint() sp.cp1:SetOrder(1)
		sp.cp2 = sPath:CreateControlPoint() sp.cp2:SetOrder(2)
		local sFade = sag:CreateAnimation('Alpha')
		sFade:SetFromAlpha(0.9) sFade:SetToAlpha(0)
		sFade:SetDuration(1.5) sFade:SetOrder(1)
		sFade:SetStartDelay(0.3 + i * 0.07)
		sag:SetScript('OnFinished', function() sp:Hide() end)
		sag:SetScript('OnStop', function() sp:Hide() end)
		sp.ag = sag
		sparks[i] = sp
	end

	-- 3. the impact flash on the panel
	impact = CreateFrame('Frame', nil, UIParent)
	impact:SetFrameStrata('FULLSCREEN_DIALOG')
	impact:SetFrameLevel(580)
	impact:EnableMouse(false)
	impact:SetSize(260, 120)
	impact:Hide()
	local it = impact:CreateTexture(nil, 'ARTWORK')
	it:SetAllPoints()
	it:SetTexture(SPARK)
	it:SetBlendMode('ADD')
	it:SetVertexColor(ORANGE[1], ORANGE[2], ORANGE[3])
	local iag = impact:CreateAnimationGroup()
	local ig = iag:CreateAnimation('Scale')
	ig:SetScaleFrom(0.3, 0.3) ig:SetScaleTo(1.6, 1.6)
	ig:SetDuration(0.45) ig:SetSmoothing('OUT')
	local ia = iag:CreateAnimation('Alpha')
	ia:SetFromAlpha(0.85) ia:SetToAlpha(0)
	ia:SetDuration(0.45) ia:SetSmoothing('OUT')
	iag:SetScript('OnFinished', function() impact:Hide() end)
	impact.ag = iag
end

local function StopFlight()
	if flight then flight.ag:Stop() flight:Hide() end
	if sparks then for _, sp in ipairs(sparks) do sp.ag:Stop() sp:Hide() end end
	if impact then impact.ag:Stop() impact:Hide() end
end

local function FlyLogo(target)
	if not target then return end
	local ok = pcall(BuildFlight)
	if not ok or not flight then return end
	StopFlight()

	-- START: the mouse. GetCursorPosition is in raw screen pixels, so it has to
	-- be divided by the effective scale to land in UIParent's coordinate space.
	local scale = UIParent:GetEffectiveScale()
	if not scale or scale == 0 then scale = 1 end
	local mx, my = _G.GetCursorPosition()
	mx, my = mx / scale, my / scale

	-- END: the control panel, measured NOW - it is draggable, so it will not be
	-- where it was the last time this ran.
	local px, py = target:GetCenter()
	if not px then return end

	local dx, dy = px - mx, py - my
	-- Bend the path perpendicular to the straight line. The sign flips with the
	-- direction of travel so the arc always bows upward on screen rather than
	-- sometimes ducking below the cursor.
	local len = math.sqrt(dx * dx + dy * dy)
	if len < 1 then len = 1 end
	local bow = math.min(180, len * 0.35)
	local nx, ny = -dy / len, dx / len
	if ny < 0 then nx, ny = -nx, -ny end

	local function Aim(f)
		f:ClearAllPoints()
		f:SetPoint('CENTER', UIParent, 'BOTTOMLEFT', mx, my)
		-- Control point offsets are ABSOLUTE from the START point, not
		-- cumulative from the previous point. Treating them as cumulative made
		-- the last point land at half the distance and off to one side, which
		-- is precisely where the logo was stopping.
		f.cp1:SetOffset(dx * 0.5 + nx * bow, dy * 0.5 + ny * bow)
		f.cp2:SetOffset(dx, dy)
	end

	Aim(flight)
	flight:Show()
	flight.ag:Play()

	for _, sp in ipairs(sparks) do
		Aim(sp)
		sp:Show()
		sp.ag:Play()
	end

	-- the panel flash is timed to land with the logo rather than chained to it,
	-- so a stopped flight cannot leave a flash queued
	_G.C_Timer.After(1.75, function()
		if not active then return end
		impact:ClearAllPoints()
		impact:SetPoint('CENTER', target, 'CENTER', 0, 0)
		impact:Show()
		impact.ag:Play()
	end)
end

----------------------------------------------------------------------
-- Control panel
----------------------------------------------------------------------
local function MakePanel()
	if panel then return panel end
	panel = CreateFrame('Frame', 'KiraUIEditModePanel', UIParent)
	panel:SetSize(252, 108)
	panel:SetPoint('TOP', UIParent, 'TOP', 0, -140)
	panel:SetFrameStrata('FULLSCREEN_DIALOG')
	panel:SetFrameLevel(200)
	panel:SetMovable(true)
	panel:EnableMouse(true)
	panel:RegisterForDrag('LeftButton')
	panel:SetScript('OnDragStart', panel.StartMoving)
	panel:SetScript('OnDragStop', panel.StopMovingOrSizing)

	local bg = panel:CreateTexture(nil, 'BACKGROUND')
	bg:SetAllPoints()
	bg:SetColorTexture(0.055, 0.055, 0.065, 0.96)
	for _, p in ipairs({ { 'TOPLEFT', 'TOPRIGHT', 'h' }, { 'BOTTOMLEFT', 'BOTTOMRIGHT', 'h' },
		{ 'TOPLEFT', 'BOTTOMLEFT', 'v' }, { 'TOPRIGHT', 'BOTTOMRIGHT', 'v' } }) do
		local t = panel:CreateTexture(nil, 'OVERLAY')
		t:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.8)
		t:SetPoint(p[1]) t:SetPoint(p[2])
		if p[3] == 'h' then t:SetHeight(1) else t:SetWidth(1) end
	end

	local title = panel:CreateFontString(nil, 'OVERLAY')
	title:SetFont(ns.GetFont(), 13, 'OUTLINE')
	title:SetPoint('TOP', 0, -8)
	title:SetText('KiraUI Edit Mode')
	title:SetTextColor(ORANGE[1], ORANGE[2], ORANGE[3])

	local hint = panel:CreateFontString(nil, 'OVERLAY')
	hint:SetFont(ns.GetFont(), 10, '')
	hint:SetPoint('TOP', title, 'BOTTOM', 0, -4)
	hint:SetWidth(232)
	hint:SetText('Left-click selects  |  drag or arrow keys to move (Shift = 10px)  |  right-click for settings  |  Esc exits')
	hint:SetTextColor(0.75, 0.75, 0.78)
	panel.hint = hint

	-- ARROW KEYS nudge the selected element by 1px.
	--
	-- The handler lives on the panel because the panel exists exactly as long
	-- as edit mode does. Propagation is left ON for every other key, so typing
	-- in chat, hotkeys and Esc all keep working - only the four arrows are
	-- swallowed, and only while something is selected.
	panel:EnableKeyboard(true)
	panel:SetPropagateKeyboardInput(true)
	panel:SetScript('OnKeyDown', function(self, key)
		local dx, dy = 0, 0
		if key == 'UP' then dy = 1
		elseif key == 'DOWN' then dy = -1
		elseif key == 'LEFT' then dx = -1
		elseif key == 'RIGHT' then dx = 1
		else
			self:SetPropagateKeyboardInput(true)
			return
		end
		if not selected then
			self:SetPropagateKeyboardInput(true)
			return
		end
		self:SetPropagateKeyboardInput(false)
		local step = (_G.IsShiftKeyDown and _G.IsShiftKeyDown()) and 10 or 1
		Nudge(selected, dx * step, dy * step)
	end)

	local function Btn(text, x, w, onClick)
		local b = CreateFrame('Button', nil, panel)
		b:SetSize(w, 22)
		b:SetPoint('BOTTOMLEFT', x, 10)
		local t = b:CreateTexture(nil, 'BACKGROUND')
		t:SetAllPoints()
		t:SetColorTexture(0.115, 0.115, 0.135, 1)
		local fs = b:CreateFontString(nil, 'OVERLAY')
		fs:SetFont(ns.GetFont(), 11, '')
		fs:SetPoint('CENTER')
		fs:SetText(text)
		fs:SetTextColor(0.9, 0.9, 0.9)
		b.fs = fs
		b:SetScript('OnEnter', function() t:SetColorTexture(0.175, 0.175, 0.2, 1) end)
		b:SetScript('OnLeave', function() t:SetColorTexture(0.115, 0.115, 0.135, 1) end)
		b:SetScript('OnClick', onClick)
		return b
	end

	-- Lock leaves the mode the same way Esc and the toolbar icon do, prompt and
	-- all: leaving by different routes must not save different things.
	Btn('Lock', 10, 55, function() Stop(true) end)
	Btn('Reset', 69, 55, function()
		-- back to how things were when edit mode opened, WITHOUT leaving, so a
		-- bad experiment does not cost you the session
		RestoreSnapshot()
		Rebuild()
		-- the readout is the number that will be saved, so it has to follow
		if selected then RefreshReadout(selected) end
	end)
	local gridBtn
	gridBtn = Btn('Grid', 128, 55, function()
		local db = _G.KiraUIChangesDB
		if db then db.editGrid = not GridShown() end
		RefreshGrid()
		gridBtn.fs:SetText(GridShown() and 'Grid on' or 'Grid')
		gridBtn.fs:SetTextColor(GridShown() and ORANGE[1] or 0.9,
			GridShown() and ORANGE[2] or 0.9, GridShown() and ORANGE[3] or 0.9)
	end)
	gridBtn.fs:SetText(GridShown() and 'Grid on' or 'Grid')

	-- toggles rather than opens: the window is often in the way of the very
	-- element you are trying to place
	Btn('Settings', 187, 55, function()
		if ns.ToggleConfig then ns.ToggleConfig()
		elseif ns.OpenConfig then ns.OpenConfig() end
	end)
	return panel
end

----------------------------------------------------------------------
-- Save / discard prompt
--
-- Built rather than using StaticPopup: StaticPopup always docks to the top of
-- the screen, and this question is about elements you have just been dragging
-- around the middle of it. Dead centre is where you are already looking.
----------------------------------------------------------------------
local confirm
local function MakeConfirm()
	if confirm then return confirm end
	confirm = CreateFrame('Frame', 'KiraUIEditModeConfirm', UIParent)
	confirm:SetSize(340, 120)
	confirm:SetPoint('CENTER', UIParent, 'CENTER', 0, 0)
	confirm:SetFrameStrata('FULLSCREEN_DIALOG')
	confirm:SetFrameLevel(400)
	confirm:EnableMouse(true)   -- swallow clicks so you cannot drag underneath it
	confirm:Hide()

	local bg = confirm:CreateTexture(nil, 'BACKGROUND')
	bg:SetAllPoints()
	bg:SetColorTexture(0.055, 0.055, 0.065, 0.98)
	for _, p2 in ipairs({ { 'TOPLEFT', 'TOPRIGHT', 'h' }, { 'BOTTOMLEFT', 'BOTTOMRIGHT', 'h' },
		{ 'TOPLEFT', 'BOTTOMLEFT', 'v' }, { 'TOPRIGHT', 'BOTTOMRIGHT', 'v' } }) do
		local t = confirm:CreateTexture(nil, 'OVERLAY')
		t:SetColorTexture(ORANGE[1], ORANGE[2], ORANGE[3], 0.9)
		t:SetPoint(p2[1]) t:SetPoint(p2[2])
		if p2[3] == 'h' then t:SetHeight(1) else t:SetWidth(1) end
	end

	local msg = confirm:CreateFontString(nil, 'OVERLAY')
	msg:SetFont(ns.GetFont(), 13, '')
	msg:SetPoint('TOP', 0, -22)
	msg:SetWidth(300)
	msg:SetText('Keep the positions you just changed?')
	msg:SetTextColor(1, 1, 1)

	local function Btn(text, x, onClick)
		local b = CreateFrame('Button', nil, confirm)
		b:SetSize(120, 24)
		b:SetPoint('BOTTOMLEFT', x, 16)
		local t = b:CreateTexture(nil, 'BACKGROUND')
		t:SetAllPoints()
		t:SetColorTexture(0.115, 0.115, 0.135, 1)
		local fs = b:CreateFontString(nil, 'OVERLAY')
		fs:SetFont(ns.GetFont(), 12, '')
		fs:SetPoint('CENTER')
		fs:SetText(text)
		fs:SetTextColor(0.9, 0.9, 0.9)
		b:SetScript('OnEnter', function() t:SetColorTexture(0.175, 0.175, 0.2, 1) end)
		b:SetScript('OnLeave', function() t:SetColorTexture(0.115, 0.115, 0.135, 1) end)
		b:SetScript('OnClick', onClick)
		return b
	end
	Btn(_G.SAVE or 'Save', 26, function()
		confirm:Hide()
		ns.EditModeFinish(true)
	end)
	Btn('Discard', 194, function()
		confirm:Hide()
		ns.EditModeFinish(false)
	end)
	return confirm
end

----------------------------------------------------------------------
-- Enter / leave
----------------------------------------------------------------------
local ticker

local function SetPreviews(on)
	for _, m in ipairs(MOVABLES) do
		if m.preview and IsOn(m) then
			local f = ns[m.preview]
			-- forced visible so a display that only appears on a ready check or
			-- in combat can still be placed
			if type(f) == 'function' then pcall(f, on) end
		end
	end
end

local function Start()
	if active then return end
	if _G.InCombatLockdown and _G.InCombatLockdown() then
		if ns.Msg then ns.Msg('Edit mode is not available in combat.') end
		return
	end
	active = true
	TakeSnapshot()
	SetPreviews(true)
	local p = MakePanel()
	p:Show()
	RefreshGrid()
	Rebuild()
	FlyLogo(p)
	-- elements resize as their contents change, so the boxes are re-fitted
	-- rather than measured once
	ticker = _G.C_Timer.NewTicker(0.25, Rebuild)
	if ns.Msg then ns.Msg('Edit mode ON - drag to move, right-click for settings, Esc to exit.') end

	-- Say WHY an enabled display has no box. Several modules are class-gated
	-- and never build their frame off-spec - Fiery Brand only exists on a Demon
	-- Hunter, the WoG bar on a Paladin - so there is genuinely nothing to place
	-- and a silent gap looks like a bug rather than a rule.
	local missing
	for _, m in ipairs(MOVABLES) do
		-- only a frame that does not EXIST is dormant now; one that merely
		-- lacked a position gets placed by EnsurePlaced above
		if IsOn(m) and not Frm(m) then
			missing = (missing and (missing .. ', ') or '') .. m.label
		end
	end
	if missing and ns.Msg then
		ns.Msg('|cff888888not shown - dormant on this character (wrong class/spec): ' .. missing .. '|r')
	end
end

-- keep = true commits, false restores the snapshot
function ns.EditModeFinish(keep)
	if not keep then RestoreSnapshot() end
	if confirm then confirm:Hide() end
	dirty = false
	active = false
	Select(nil)
	if ticker then ticker:Cancel() ticker = nil end
	for _, o in pairs(overlays) do o:Hide() end
	if panel then panel:Hide() end
	if grid then grid:Hide() end
	StopFlight()
	SetPreviews(false)
	for _, m in ipairs(MOVABLES) do ApplyOne(m) end
end

-- ask = false skips the prompt (used by Reset-then-leave paths)
function Stop(ask)
	if not active then return end
	if ask ~= false and dirty then
		MakeConfirm():Show()
		return
	end
	ns.EditModeFinish(true)
end

ns.EditModeActive = function() return active end
ns.EditModeToggle = function()
	if active then Stop(true) else Start() end
end

----------------------------------------------------------------------
-- Esc
--
-- UISpecialFrames is the standard way to make Esc close a frame. The panel is
-- the frame Esc acts on, and its OnHide is what actually ends edit mode - so
-- Esc, the Lock button and the toggle icon all funnel through one path.
----------------------------------------------------------------------
local escHooked
local function HookEsc()
	if escHooked or not panel then return end
	escHooked = true
	_G.UISpecialFrames = _G.UISpecialFrames or {}
	local already = false
	for _, n in ipairs(_G.UISpecialFrames) do
		if n == 'KiraUIEditModePanel' then already = true break end
	end
	if not already then
		_G.UISpecialFrames[#_G.UISpecialFrames + 1] = 'KiraUIEditModePanel'
	end
	panel:SetScript('OnHide', function()
		-- Esc hid the panel directly; finish the mode the same way Lock would
		if active then Stop(true) end
	end)
end

local boot = CreateFrame('Frame')
boot:RegisterEvent('PLAYER_ENTERING_WORLD')
boot:SetScript('OnEvent', function()
	MakePanel()
	panel:Hide()
	HookEsc()
end)

-- Combat: secure frames cannot be moved mid-fight and the boxes would only get
-- in the way, so edit mode closes itself rather than half-working.
local cw = CreateFrame('Frame')
cw:RegisterEvent('PLAYER_REGEN_DISABLED')
cw:SetScript('OnEvent', function()
	if active then ns.EditModeFinish(true) end
end)
