-- UI/Window.lua
-- The window: toolbar, status/feedback, an evidence panel (per-target creator +
-- every altering addon, broken down by behavior), a live event log, a
-- "what to analyze" filter panel, plus selection / copy / saved-investigation
-- dialogs. Pure-Lua frames, no XML, no libraries.

local ADDON, ns = ...

local UI = {}
ns.UI = UI

local BACKDROP = {
	bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8",
	edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 },
}

local frame, logFrame, evidence, statusText, feedbackText, monitorBtn, filtersPanel
local selectionDialog, detailFrame, detailEdit, detailCopy, savedDialog
local openEvidenceDetail  -- assigned once showText/savedDetailText exist; called by Evidence row clicks
local built = false

local function cfg(k) return ns.db and ns.db[k] end

-- ---------------------------------------------------------------------------
-- Small widget factories
-- ---------------------------------------------------------------------------

local function makeButton(parent, text, w, onClick)
	local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
	b:SetSize(w or 80, 22); b:SetText(text); b:SetScript("OnClick", onClick)
	return b
end

local function makeCheck(parent, label, getfn, setfn)
	local c = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
	c:SetSize(22, 22)
	local fs = c:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
	fs:SetPoint("LEFT", c, "RIGHT", 2, 0); fs:SetText(label)
	c.label = fs
	c:SetScript("OnShow", function(self) self:SetChecked(getfn()) end)
	c:SetScript("OnClick", function(self) setfn(self:GetChecked() and true or false) end)
	c:SetChecked(getfn())
	return c
end

local function makeScrollText(parent, name)
	local sf = CreateFrame("ScrollFrame", name, parent, "UIPanelScrollFrameTemplate")
	local content = CreateFrame("Frame", nil, sf); content:SetSize(10, 10)
	sf:SetScrollChild(content)
	local fs = content:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
	fs:SetPoint("TOPLEFT"); fs:SetJustifyH("LEFT"); fs:SetSpacing(2)
	local obj = { scroll = sf, content = content, fs = fs }
	function obj:SetWidthPx(w) fs:SetWidth(w - 8); content:SetWidth(w) end
	function obj:SetText(t)
		fs:SetText(t or "")
		content:SetHeight((fs:GetStringHeight() or 10) + 12)
	end
	return obj
end

-- ---------------------------------------------------------------------------
-- Log formatting
-- ---------------------------------------------------------------------------

function UI.FormatLine(ev)
	local catCol = ns.COLORS[ev.cat] or "ffffffff"
	local who = ""
	if ev.kind == "override" then
		-- "<method> [replaced] by <addon>" — action before the addon so it reads forward.
		who = " |cffff8800[replaced]|r by |cffff5555" .. ns.QuoteName(ev.overrideBy) .. "|r"
	elseif ev.kind == "animation" and not ev.ext and not ev.culpritTitle then
		who = "" -- inventory line: not attributed to a caller
	elseif ev.kind ~= "diff" then
		if ev.extTitle then who = " by |cffffffff" .. ns.QuoteName(ev.extTitle) .. "|r"
		elseif ev.culpritTitle then who = " by |cff999999" .. ns.QuoteName(ev.culpritTitle) .. "|r"
		else who = " by |cff999999?|r" end
	elseif ev.extTitle then
		who = " by |cffffffff" .. ns.QuoteName(ev.extTitle) .. "|r"
	end
	-- viaDriver = we recovered the registrant; viaSecure-without-viaDriver = secured-env change
	-- whose caller is hidden (enable deep). Mark the latter so it doesn't read as "by Blizzard".
	local via = ev.viaDriver and (" |cff6699ffvia " .. ev.viaDriver .. "|r")
		or (ev.viaSecure and " |cffff8800(secured env — caller hidden, enable Deep + reload)|r")
		or ""
	-- Mechanism markers for the changes the setter hooks can't see. (override's "[replaced] by …"
	-- is built into `who` above so it reads forward; only the unresolved-taint note trails here.)
	local marker = ""
	if ev.kind == "override" then
		marker = ev.resolved and "" or " |cff888888(unresolved taint)|r"
	elseif ev.kind == "bypass" then
		marker = " |cffaa88ffvia " .. (ev.viaField or "saved original") .. "|r"
	end
	local count = (ev.count and ev.count > 1) and (" |cffaaaaaax" .. ev.count .. "|r") or ""
	local args = (ev.args and ev.args ~= "") and ("  |cff808080" .. ev.args .. "|r") or ""
	-- Colored [category] tag labels the palette inline (the only legend); method neutral.
	local tag = ns.Colorize(catCol, "[" .. (ev.cat or "?") .. "]")
	return string.format("|cff707070%s|r |cff33ff99%s|r %s |cffd8d8d8%s|r%s%s%s%s%s",
		ev.clock or "", (ev.target or "?"):gsub("%..*$", ""),
		tag, ev.method or "?", who, via, marker, args, count)
end

-- ---------------------------------------------------------------------------
-- Evidence panel
-- ---------------------------------------------------------------------------

local function creatorString(rec)
	local c = rec.createdBy
	if not c then return "|cff888888unknown|r" end
	if c.type == "blizzard" then return "|cff6699ffBlizzard UI|r |cff555555(" .. (c.folder or "") .. ")|r" end
	if c.type == "guess" then return "|cffddaa00" .. ns.QuoteName(c.title) .. "|r |cff777777(guess)|r" end
	return "|cffffff00" .. ns.QuoteName(c.title or c.folder) .. "|r"
end

-- Append one "<category>: Addon xN (Method xM, …)" line per touched category to `lines`.
-- Shared by the live Evidence panel (detailed = false) and the Saved browser
-- (detailed = true → also appends "last <args> @<time>"). Returns true if it emitted any
-- line. Works on any record holding `categories` — live record or persisted entry.
local function appendBreakdown(lines, rec, detailed)
	local before = #lines
	local cats = rec.categories
	if not cats then return false end
	for _, cat in ipairs(ns.CATEGORY_ORDER) do
		local byCat = cats[cat]
		if byCat then
			local parts = {}
			for _, entry in pairs(byCat) do
				local methods = ""
				if entry.methods then
					local mlist = {}
					for m, n in pairs(entry.methods) do mlist[#mlist + 1] = { m, n } end
					table.sort(mlist, function(a, b)
						if a[2] ~= b[2] then return a[2] > b[2] end
						return a[1] < b[1]
					end)
					local ms = {}
					for k = 1, #mlist do ms[k] = mlist[k][1] .. " x" .. mlist[k][2] end
					if #ms > 0 then methods = " |cff888888(" .. table.concat(ms, ", ") .. ")|r" end
				end
				local s = ns.QuoteName(entry.title) .. " |cff888888x" .. entry.count .. "|r" .. methods
				if detailed and entry.lastArgs and entry.lastArgs ~= "" then
					s = s .. " |cff555555last " .. entry.lastArgs
						.. (entry.lastTime and (" @" .. entry.lastTime) or "") .. "|r"
				end
				parts[#parts + 1] = s
			end
			if #parts > 0 then
				lines[#lines + 1] = "  " .. ns.Colorize(ns.COLORS[cat] or "ffffffff", cat)
					.. ": " .. table.concat(parts, ", ")
			end
		end
	end
	return #lines > before
end

-- Each tracked frame renders as its own clickable Button row (not one shared FontString),
-- so the WHOLE block — name + creator + per-category breakdown — highlights on hover and
-- opens the full detail on click, mirroring the live log's clickable rows. Pooled by index.
local function evRow(i)
	local r = evidence.rows[i]
	if r then return r end
	r = CreateFrame("Button", nil, evidence.content)
	r:RegisterForClicks("LeftButtonUp")
	local hl = r:CreateTexture(nil, "HIGHLIGHT")
	hl:SetAllPoints(true); hl:SetColorTexture(0.30, 0.55, 0.95, 0.10)
	local fs = r:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
	fs:SetPoint("TOPLEFT", 2, -2); fs:SetJustifyH("LEFT"); fs:SetJustifyV("TOP"); fs:SetSpacing(2)
	fs:SetWidth((evidence.panelW or 272) - 8)
	r.fs = fs
	r:SetScript("OnClick", function(self)
		if self.rec then openEvidenceDetail(self.rec) end
	end)
	evidence.rows[i] = r
	return r
end

function UI.RefreshEvidence()
	if not built then return end
	local list = ns.Monitor.targetList
	evidence.rows = evidence.rows or {}
	for _, r in ipairs(evidence.rows) do r:Hide(); r.rec = nil end
	if #list == 0 then
		evidence:SetText("|cff888888No target. Use Pick (or /twid name <Frame>) to choose a frame, then Monitor.|r")
		return
	end
	evidence:SetText("")  -- the fallback FontString is idle while rows render the blocks
	local y = 0
	for i = 1, #list do
		local f = list[i]
		local rec = ns.Evidence.live[ns.Fingerprint.Key(f)]
		local lines = { "|cff33ff99" .. (ns.ObjectName(f) or "?") .. "|r |cff666666(" .. ns.ObjectType(f) .. ")|r" }
		if rec then
			lines[#lines + 1] = "  created by: " .. creatorString(rec)
			if not appendBreakdown(lines, rec, false) then
				lines[#lines + 1] = "  |cff666666no alterations recorded yet|r"
			end
		else
			lines[#lines + 1] = "  |cff666666(monitoring — interact to gather evidence)|r"
		end
		local r = evRow(i)
		r.rec = rec
		r.fs:SetText(table.concat(lines, "\n"))
		local h = (r.fs:GetStringHeight() or 12) + 6
		r:ClearAllPoints()
		r:SetPoint("TOPLEFT", evidence.content, "TOPLEFT", 0, -y)
		r:SetPoint("RIGHT", evidence.content, "RIGHT", 0, 0)
		r:SetHeight(h)
		if rec then r:Enable() else r:Disable() end  -- only a record-backed block is clickable
		r:Show()
		y = y + h + 6
	end
	evidence.content:SetHeight(y + 4)
end

-- ---------------------------------------------------------------------------
-- Detail / copy dialog
-- ---------------------------------------------------------------------------

local function chainString(chain)
	if not chain or #chain == 0 then return "(no addon frames — Blizzard/anonymous)" end
	local p = {}
	for i = 1, #chain do p[i] = ns.QuoteName(chain[i].title or chain[i].folder) end
	return table.concat(p, "  >  ")
end

local function eventDetailText(ev)
	local t = {}
	t[#t + 1] = string.format("[%s] %s (%s)%s", ev.clock or "", ev.method or "?", ev.cat or "?",
		(ev.count and ev.count > 1) and ("  x" .. ev.count) or "")
	t[#t + 1] = "Target: " .. (ev.target or "?")
	if ev.args and ev.args ~= "" then t[#t + 1] = "Args:   " .. ev.args end
	if ev.extTitle then t[#t + 1] = "Tweaked by: " .. ns.QuoteName(ev.extTitle) .. " (" .. (ev.ext or "?") .. ")"
	elseif ev.culpritTitle then t[#t + 1] = "Caller: " .. ns.QuoteName(ev.culpritTitle) .. " (" .. (ev.culprit or "?") .. ")" end
	-- Explain the changes the per-instance setter hooks can't see.
	if ev.kind == "override" then
		t[#t + 1] = "Overridden by: " .. ns.QuoteName(ev.overrideBy)
			.. (ev.resolved and "" or "  (taint did not resolve to a known addon — shown for reference, not persisted)")
		t[#t + 1] = "Meaning: this addon replaced " .. (ev.method or "the method")
			.. " on the frame and force-controls the property; its own changes call the saved original and bypass our method hook (see any \"bypass\" events below)."
	elseif ev.kind == "bypass" then
		t[#t + 1] = "Bypass: the addon saved the original " .. (ev.method or "method")
			.. " under the frame field '" .. (ev.viaField or "?")
			.. "' and drives the change through it; we hooked that field. Value omitted (irrelevant, and possibly secret)."
	elseif ev.kind == "animation" then
		if ev.method == "groups present" then
			t[#t + 1] = "Animation inventory: owned animation groups on this frame (name[types,playing]). Re-targeted groups owned by other frames cannot be detected."
		else
			t[#t + 1] = "Animation: group method '" .. (ev.method or "?") .. "' fired; animation types: " .. (ev.args or "?")
				.. ". A C-driven autoplay names no addon (no Lua caller on the stack)."
		end
	end
	if ev.viaDriver then
		t[#t + 1] = "Via:    " .. ev.viaDriver
	end
	if ev.viaSecure then
		-- The call stack below is the Blizzard proxy, NOT the originating addon — explain why.
		local note = "Blizzard secured environment — " .. ns.Drivers.PROXY_FOLDER
			.. " (SecureStateDriverManager). The call stack below is the proxy, not the originator."
		if ev.driverStack then
			note = note .. " See \"driver registered here\" for the addon's registration site."
		elseif not ev.viaDriver then
			if ns.Drivers and not ns.Drivers.Installed() then
				note = note .. " The registering addon is hidden — enable deep investigation (/twid deep or the Deep button) and reload to capture it."
			else
				note = note .. " No driver registration was captured (registered before monitoring, or a non-state driver)."
			end
		end
		t[#t + 1] = "Caught via: " .. note
	end
	if ev.chain then t[#t + 1] = "Chain:  " .. chainString(ev.chain) end
	if ev.stack then
		t[#t + 1] = "\n--- call stack" .. (ev.viaSecure and " (caught via Blizzard secured environment)" or "")
			.. " ---\n" .. ev.stack
	end
	if ev.driverStack then t[#t + 1] = "\n--- driver registered here ---\n" .. ev.driverStack end
	return table.concat(t, "\n")
end

-- One dialog frame, two mutually-exclusive views (swapped per call, like
-- ClassUIEnhanced's report/copy panel):
--   • detailEdit — multi-line, scrollable, the readable view. Used for the live log
--     dump and per-event / per-frame detail, where you want to actually read it.
--   • detailCopy — single-line copy box. Used ONLY for the Saved-investigations export,
--     which can be arbitrarily large: a multi-line EditBox lays every line into
--     FontStrings up front and stalls / visibly breaks on a big dump, whereas a
--     single-line box holds the whole string (newlines included) but renders one line,
--     so it stays fast at any size. Copy-only — opens pre-selected for Ctrl-C.
local DETAIL_W, DETAIL_TALL, DETAIL_SHORT = 580, 380, 92

local function buildDetail()
	detailFrame = CreateFrame("Frame", "TweakIdentifierDetail", UIParent, "BackdropTemplate")
	detailFrame:SetSize(DETAIL_W, DETAIL_TALL); detailFrame:SetPoint("CENTER")
	detailFrame:SetFrameStrata("FULLSCREEN_DIALOG")  -- same strata as Saved/Selection; layered by frame level (see raiseDetail)
	detailFrame:SetBackdrop(BACKDROP); detailFrame:SetBackdropColor(0.04, 0.04, 0.06, 0.97)
	detailFrame:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
	detailFrame:SetMovable(true); detailFrame:EnableMouse(true); detailFrame:RegisterForDrag("LeftButton")
	detailFrame:SetScript("OnDragStart", detailFrame.StartMoving)
	detailFrame:SetScript("OnDragStop", detailFrame.StopMovingOrSizing)
	detailFrame:Hide()
	table.insert(UISpecialFrames, "TweakIdentifierDetail")  -- close on Esc
	detailFrame.title = detailFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	detailFrame.title:SetPoint("TOPLEFT", 12, -10)
	CreateFrame("Button", nil, detailFrame, "UIPanelCloseButton"):SetPoint("TOPRIGHT", 2, 2)

	-- Readable view: scrollable multi-line edit box (the whole text is visible).
	local scroll = CreateFrame("ScrollFrame", "TweakIdentifierDetailScroll", detailFrame, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 12, -34); scroll:SetPoint("BOTTOMRIGHT", -32, 12)
	detailFrame.scroll = scroll
	detailEdit = CreateFrame("EditBox", nil, scroll)
	detailEdit:SetMultiLine(true); detailEdit:SetFontObject(ChatFontNormal); detailEdit:SetAutoFocus(false)
	detailEdit:SetWidth(520); detailEdit:SetMaxLetters(0); detailEdit:EnableMouse(true)
	detailEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus(); detailFrame:Hide() end)
	scroll:SetScrollChild(detailEdit)

	-- Copy-only view: single-line box that holds the whole dump without rendering it.
	detailCopy = CreateFrame("EditBox", nil, detailFrame, "BackdropTemplate")
	detailCopy:SetPoint("TOPLEFT", 12, -34); detailCopy:SetPoint("BOTTOMRIGHT", -12, 12)
	detailCopy:SetMultiLine(false); detailCopy:SetAutoFocus(false)
	detailCopy:SetFontObject(ChatFontNormal)
	detailCopy:SetMaxBytes(0); detailCopy:SetMaxLetters(0)
	detailCopy:SetBackdrop(BACKDROP); detailCopy:SetBackdropColor(0.02, 0.02, 0.03, 1)
	detailCopy:SetBackdropBorderColor(0.3, 0.3, 0.35, 1)
	detailCopy:SetTextInsets(6, 6, 4, 4)
	detailCopy:SetScript("OnEscapePressed", function(self) self:ClearFocus(); detailFrame:Hide() end)
	detailCopy:Hide()

	-- Keep the full string selected: a click re-selects it all (the box never needs a
	-- caret), and any cursor move re-highlights, so Ctrl-C always grabs the whole dump.
	local catcher = CreateFrame("Frame", nil, detailCopy); catcher:SetAllPoints(detailCopy)
	catcher:EnableMouse(true)
	local function reselect() detailCopy:SetFocus(); detailCopy:HighlightText() end
	catcher:SetScript("OnMouseDown", reselect)
	detailCopy:SetScript("OnCursorChanged", reselect)
end

-- Lift the detail/copy dialog above whatever launched it. It shares the FULLSCREEN_DIALOG
-- strata with the Saved/Selection browsers, so layering is by frame level. Raise() only lifts
-- us above other *top-level* frames — NOT above a browser's child rows/scroll/scrollbar, whose
-- levels sit a few above their parent, so they bled through. Set our level explicitly above the
-- highest visible browser's level plus a margin that clears its child hierarchy (descendant
-- levels are re-based off ours, so our own children stay on top of theirs).
local function raiseDetail()
	local lvl = 0
	if savedDialog and savedDialog:IsShown() then lvl = math.max(lvl, savedDialog:GetFrameLevel()) end
	if selectionDialog and selectionDialog:IsShown() then lvl = math.max(lvl, selectionDialog:GetFrameLevel()) end
	if lvl > 0 then detailFrame:SetFrameLevel(lvl + 20) end
end

-- Readable, scrollable view — live log dump and per-event / per-frame detail.
local function showText(text)
	if not detailFrame then buildDetail() end
	detailCopy:Hide(); detailFrame.scroll:Show()
	detailFrame:SetSize(DETAIL_W, DETAIL_TALL)
	detailFrame.title:SetText("|cff33ff99TweakIdentifier|r — select all + Ctrl-C to copy")
	detailFrame:Show(); raiseDetail()  -- frame-level lift above the launching Saved/Selection browser
	detailEdit:SetText(text or ""); detailEdit:HighlightText()
	detailEdit:SetCursorPosition(0); detailEdit:SetFocus()
end

-- Single-line copy box — Saved-investigations export only (potentially large, copy-only).
local function showCopy(text)
	if not detailFrame then buildDetail() end
	detailFrame.scroll:Hide()
	detailFrame:SetSize(DETAIL_W, DETAIL_SHORT)
	detailFrame.title:SetText("|cff33ff99TweakIdentifier|r — press Ctrl-C to copy everything")
	detailFrame:Show(); raiseDetail()  -- frame-level lift above the launching Saved/Selection browser
	detailCopy:Show(); detailCopy:SetText(text or "")
	-- Defer focus/select one frame so it lands after Show settles (same as ClassUIEnhanced).
	C_Timer.After(0, function()
		if detailFrame:IsShown() and detailCopy:IsShown() then detailCopy:SetFocus(); detailCopy:HighlightText() end
	end)
end

function UI.ShowDetail(id)
	for i = #ns.Monitor.log, 1, -1 do
		local ev = ns.Monitor.log[i]
		if ev.id == id then showText(eventDetailText(ev)); return end
	end
	ns.Print("Event no longer in buffer.")
end

function UI.CopyAll()
	local log = ns.Monitor.log
	if #log == 0 then ns.Print("Log is empty.") return end
	local out = { "TweakIdentifier log" }
	for i = 1, #log do out[#out + 1] = "\n" .. eventDetailText(log[i]) end
	showText(table.concat(out, "\n"))
end

-- ---------------------------------------------------------------------------
-- Selection dialog ("which frames under the cursor?" — all by default)
-- ---------------------------------------------------------------------------

local function buildSelection()
	selectionDialog = CreateFrame("Frame", "TweakIdentifierSelection", UIParent, "BackdropTemplate")
	selectionDialog:SetSize(380, 420); selectionDialog:SetPoint("CENTER")
	selectionDialog:SetFrameStrata("FULLSCREEN_DIALOG")
	selectionDialog:SetBackdrop(BACKDROP); selectionDialog:SetBackdropColor(0.04, 0.04, 0.06, 0.97)
	selectionDialog:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
	selectionDialog:Hide()
	table.insert(UISpecialFrames, "TweakIdentifierSelection")  -- close on Esc
	local title = selectionDialog:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOPLEFT", 12, -10); title:SetText("Frames under cursor — pick which to identify")
	CreateFrame("Button", nil, selectionDialog, "UIPanelCloseButton"):SetPoint("TOPRIGHT", 2, 2)
	selectionDialog.checks = {}
	local scroll = CreateFrame("ScrollFrame", "TweakIdentifierSelScroll", selectionDialog, "UIPanelScrollFrameTemplate")
	scroll:SetPoint("TOPLEFT", 10, -56); scroll:SetPoint("BOTTOMRIGHT", -30, 40)
	local content = CreateFrame("Frame", nil, scroll); content:SetSize(320, 10)
	scroll:SetScrollChild(content); selectionDialog.content = content

	selectionDialog.allCheck = makeCheck(selectionDialog, "|cffffff00All|r",
		function() return true end, function() end)
	selectionDialog.allCheck:SetPoint("TOPLEFT", 12, -32)
	selectionDialog.allCheck:SetScript("OnClick", function(self)
		local on = self:GetChecked() and true or false
		for _, c in ipairs(selectionDialog.checks) do c:SetChecked(on) end
	end)

	local go = makeButton(selectionDialog, "Monitor Selected", 140, function()
		local list = {}
		for _, c in ipairs(selectionDialog.checks) do
			if c:GetChecked() and c.frameRef then list[#list + 1] = c.frameRef end
		end
		selectionDialog:Hide()
		if #list > 0 then ns.Monitor.SetTargets(list); UI.Show() else ns.Print("Nothing selected.") end
	end)
	go:SetPoint("BOTTOM", 0, 10)
end

function UI.ShowSelection(frames)
	if not selectionDialog then buildSelection() end
	for _, c in ipairs(selectionDialog.checks) do c:Hide() end
	local y = -4
	for i = 1, math.min(#frames, 40) do
		local f = frames[i]
		local c = selectionDialog.checks[i]
		if not c then
			c = CreateFrame("CheckButton", nil, selectionDialog.content, "UICheckButtonTemplate")
			c:SetSize(20, 20)
			c.text = c:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
			c.text:SetPoint("LEFT", c, "RIGHT", 2, 0); c.text:SetJustifyH("LEFT"); c.text:SetWidth(280)
			selectionDialog.checks[i] = c
		end
		local src = ns.SourceOf(f)
		local by = src and (src.title or src.folder) or ns.GuessFromName(ns.ObjectName(f))
		c.frameRef = f
		c.text:SetText(("|cffffffff%s|r |cff666666(%s)|r%s"):format(
			ns.ObjectName(f) or "?", ns.ObjectType(f), by and (" |cffffff00" .. ns.QuoteName(by) .. "|r") or ""))
		c:SetPoint("TOPLEFT", 4, y); c:SetChecked(true); c:Show()
		y = y - 22
	end
	selectionDialog.content:SetHeight(-y + 10)
	selectionDialog.allCheck:SetChecked(true)
	selectionDialog:Show()
end

-- ---------------------------------------------------------------------------
-- Saved investigations browser
-- ---------------------------------------------------------------------------

-- Full plain-text dump of one investigation record, for the copy box (no colour codes
-- — detailEdit is a raw EditBox). Mirrors eventDetailText's style. Works on any record
-- holding `categories` — a persisted entry (Saved browser) or a live record (clicking an
-- Evidence-panel block), since both share the same shape.
local function savedDetailText(it)
	local t = {}
	t[#t + 1] = (it.name or "?") .. " (" .. (it.type or "?") .. ")  — " .. (it.events or 0) .. " events"
	local c = it.createdBy
	if c then
		t[#t + 1] = "Created by: " .. ns.QuoteName(c.title or c.folder)
			.. (c.type and (" [" .. c.type .. "]") or "")
			.. (c.file and ("  " .. c.file .. (c.line and (":" .. c.line) or "")) or "")
	end
	if it.firstSeen or it.lastSeen then
		t[#t + 1] = "Seen: " .. (it.firstSeen or "?") .. " – " .. (it.lastSeen or "?")
	end
	local id = it.identity
	if id then
		t[#t + 1] = ("Identity: ancestor=%s  size=%sx%s  children=%s  regions=%s"):format(
			tostring(id.ancestor or "?"), tostring(id.width or "?"), tostring(id.height or "?"),
			tostring(id.numChildren or "?"), tostring(id.numRegions or "?"))
	end
	t[#t + 1] = "Key: " .. (it.key or "?")
	t[#t + 1] = ""
	local cats = it.categories or {}
	for _, cat in ipairs(ns.CATEGORY_ORDER) do
		local byCat = cats[cat]
		if byCat then
			t[#t + 1] = "[" .. cat .. "]"
			for _, entry in pairs(byCat) do
				local ms = {}
				for m, n in pairs(entry.methods or {}) do ms[#ms + 1] = m .. " x" .. n end
				table.sort(ms)
				t[#t + 1] = "  " .. ns.QuoteName(entry.title) .. "  x" .. (entry.count or 0)
					.. (#ms > 0 and ("  (" .. table.concat(ms, ", ") .. ")") or "")
					.. ((entry.lastArgs and entry.lastArgs ~= "") and ("  last: " .. entry.lastArgs
						.. (entry.lastTime and (" @" .. entry.lastTime) or "")) or "")
				if entry.sampleStack then
					t[#t + 1] = "    --- sample stack ---"
					for line in tostring(entry.sampleStack):gmatch("[^\n]+") do t[#t + 1] = "    " .. line end
				end
			end
		end
	end
	return table.concat(t, "\n")
end

-- Open one record's full detail (breakdown + sample call stacks) in the readable copy box.
-- Forward-declared at the top so the earlier Evidence row OnClick can call it.
openEvidenceDetail = function(rec) showText(savedDetailText(rec)) end

StaticPopupDialogs["TWEAKIDENTIFIER_CLEAR_SAVED"] = {
	text = "TweakIdentifier: forget ALL saved investigations? This cannot be undone.",
	button1 = YES, button2 = NO,
	OnAccept = function()
		ns.Evidence.ClearPersisted()
		if savedDialog and savedDialog:IsShown() then UI.ShowSaved() end
		ns.Print("Cleared all saved investigations.")
	end,
	timeout = 0, whileDead = true, hideOnEscape = true, preferredIndex = 3,
}

local function buildSaved()
	savedDialog = CreateFrame("Frame", "TweakIdentifierSaved", UIParent, "BackdropTemplate")
	savedDialog:SetSize(440, 460); savedDialog:SetPoint("CENTER")
	savedDialog:SetFrameStrata("FULLSCREEN_DIALOG")
	savedDialog:SetBackdrop(BACKDROP); savedDialog:SetBackdropColor(0.04, 0.04, 0.06, 0.97)
	savedDialog:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
	savedDialog:SetMovable(true); savedDialog:EnableMouse(true); savedDialog:RegisterForDrag("LeftButton")
	savedDialog:SetScript("OnDragStart", savedDialog.StartMoving)
	savedDialog:SetScript("OnDragStop", savedDialog.StopMovingOrSizing)
	savedDialog:Hide()
	table.insert(UISpecialFrames, "TweakIdentifierSaved")  -- close on Esc
	local title = savedDialog:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOPLEFT", 12, -10); title:SetText("Saved investigations")
	CreateFrame("Button", nil, savedDialog, "UIPanelCloseButton"):SetPoint("TOPRIGHT", 2, 2)
	savedDialog.text = makeScrollText(savedDialog, "TweakIdentifierSavedScroll")
	savedDialog.text.scroll:SetPoint("TOPLEFT", 12, -34); savedDialog.text.scroll:SetPoint("BOTTOMRIGHT", -30, 40)
	savedDialog.text:SetWidthPx(390)
	savedDialog.panelW = 390   -- fixed content width; savedRow sizes block FontStrings to it
	savedDialog.rows = {}      -- pool of per-investigation clickable Button rows (see savedRow)
	-- Footer hint, rendered below the clickable blocks (not itself a clickable row).
	savedDialog.footer = savedDialog.text.content:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	savedDialog.footer:SetWidth(390 - 8); savedDialog.footer:SetJustifyH("LEFT"); savedDialog.footer:SetSpacing(2)
	savedDialog.footer:SetText("|cff666666Click an entry for full detail + copy. Use Copy all / Clear all below.|r")

	local copyAll = makeButton(savedDialog, "Copy all", 90, function()
		local items = ns.Evidence.ListPersisted()
		if #items == 0 then ns.Print("No saved investigations.") return end
		local out = {}
		for _, it in ipairs(items) do out[#out + 1] = savedDetailText(it) end
		showCopy(table.concat(out, "\n\n========================================\n\n"))
	end)
	copyAll:SetPoint("BOTTOMLEFT", 12, 10)
	local clearAll = makeButton(savedDialog, "Clear all", 90, function()
		StaticPopup_Show("TWEAKIDENTIFIER_CLEAR_SAVED")
	end)
	clearAll:SetPoint("LEFT", copyAll, "RIGHT", 6, 0)
end

-- One pooled, clickable Button row per saved investigation — the WHOLE block is clickable
-- (matching the live Evidence panel). Click → full detail in the readable, scrollable box
-- (showText), same as the Evidence panel. Only the "Copy all" export uses the one-line copy box.
local function savedRow(i)
	local r = savedDialog.rows[i]
	if r then return r end
	r = CreateFrame("Button", nil, savedDialog.text.content)
	r:RegisterForClicks("LeftButtonUp")
	local hl = r:CreateTexture(nil, "HIGHLIGHT")
	hl:SetAllPoints(true); hl:SetColorTexture(0.30, 0.55, 0.95, 0.10)
	local fs = r:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
	fs:SetPoint("TOPLEFT", 2, -2); fs:SetJustifyH("LEFT"); fs:SetJustifyV("TOP"); fs:SetSpacing(2)
	fs:SetWidth((savedDialog.panelW or 390) - 8)
	r.fs = fs
	r:SetScript("OnClick", function(self)
		if self.item then showText(savedDetailText(self.item)) end
	end)
	savedDialog.rows[i] = r
	return r
end

function UI.ShowSaved()
	if not savedDialog then buildSaved() end
	local items = ns.Evidence.ListPersisted()
	savedDialog._items = items
	savedDialog.rows = savedDialog.rows or {}
	for _, r in ipairs(savedDialog.rows) do r:Hide(); r.item = nil end
	if #items == 0 then
		savedDialog.footer:Hide()
		savedDialog.text:SetText("|cff888888No saved investigations yet.|r")
		savedDialog:Show()
		return
	end
	savedDialog.text:SetText("")  -- the fallback FontString is idle while rows render the blocks
	local y = 0
	for i, it in ipairs(items) do
		local lines = { ("|cff33ff99%s|r |cff666666(%s)|r |cff888888— %d events|r"):format(
			it.name or "?", it.type or "?", it.events or 0) }
		local seen = it.firstSeen and (" |cff555555· " .. it.firstSeen
			.. (it.lastSeen and ("–" .. it.lastSeen) or "") .. "|r") or ""
		lines[#lines + 1] = "  created by: " .. creatorString(it) .. seen
		appendBreakdown(lines, it, true)
		local r = savedRow(i)
		r.item = it
		r.fs:SetText(table.concat(lines, "\n"))
		local h = (r.fs:GetStringHeight() or 12) + 6
		r:ClearAllPoints()
		r:SetPoint("TOPLEFT", savedDialog.text.content, "TOPLEFT", 0, -y)
		r:SetPoint("RIGHT", savedDialog.text.content, "RIGHT", 0, 0)
		r:SetHeight(h)
		r:Enable(); r:Show()
		y = y + h + 6
	end
	savedDialog.footer:ClearAllPoints()
	savedDialog.footer:SetPoint("TOPLEFT", savedDialog.text.content, "TOPLEFT", 2, -y - 2)
	savedDialog.footer:Show()
	savedDialog.text.content:SetHeight(y + (savedDialog.footer:GetStringHeight() or 12) + 12)
	savedDialog:Show()
end

-- ---------------------------------------------------------------------------
-- Filters panel ("what to analyze" + options)
-- ---------------------------------------------------------------------------

local function buildFilters()
	filtersPanel = CreateFrame("Frame", nil, frame, "BackdropTemplate")
	filtersPanel:SetPoint("TOPLEFT", frame, "TOPRIGHT", 2, 0); filtersPanel:SetWidth(220)
	filtersPanel:SetBackdrop(BACKDROP); filtersPanel:SetBackdropColor(0.04, 0.04, 0.06, 0.97)
	filtersPanel:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
	filtersPanel:Hide()
	local head = filtersPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	head:SetPoint("TOPLEFT", 10, -8); head:SetText("What to analyze")
	local y = -24
	for _, cat in ipairs(ns.CATEGORY_ORDER) do
		local this = cat
		local c = makeCheck(filtersPanel, ns.Colorize(ns.COLORS[cat] or "ffffffff", cat),
			function() return ns.db.categories[this] end, function(v) ns.db.categories[this] = v end)
		c:SetPoint("TOPLEFT", 8, y); y = y - 21
	end
	local h2 = filtersPanel:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	h2:SetPoint("TOPLEFT", 8, y - 4); h2:SetText("Options"); y = y - 22
	local opts = {
		{ "Only non-Blizzard", "onlyNonBlizzard" },
		{ "Output to chat", "chatOutput" },
		{ "Monitor descendants", "monitorDescendants" },
		{ "Simple view (picker)", "simpleView" },
		{ "Show target outline", "showTargetOutline" },
	}
	for _, o in ipairs(opts) do
		local key = o[2]
		local c = makeCheck(filtersPanel, o[1], function() return ns.db[key] end, function(v) ns.db[key] = v end)
		c:SetPoint("TOPLEFT", 8, y); y = y - 21
	end
	-- Auto-forget saved investigations older than the retention window (epoch-pruned at
	-- login by Evidence.LoadPersisted). Toggle maps the numeric autoClearDays: on = 7, off = 0.
	local autoClear = makeCheck(filtersPanel, "Auto-clear saved >7 days",
		function() return (ns.db.autoClearDays or 0) > 0 end,
		function(v) ns.db.autoClearDays = v and 7 or 0 end)
	autoClear:SetPoint("TOPLEFT", 8, y); y = y - 21
	-- Background must cover all rows; y is the next-row cursor below the last
	-- checkbox, so -y + padding spans from the top to just past the last glyph.
	filtersPanel:SetHeight(-y + 12)
end

-- ---------------------------------------------------------------------------
-- Main window
-- ---------------------------------------------------------------------------

local function saveWindow()
	local point, _, _, x, y = frame:GetPoint()
	local w = ns.db.window
	w.point, w.x, w.y = point, x, y
	w.w = math.floor(frame:GetWidth() + 0.5)
	w.h = math.floor(frame:GetHeight() + 0.5)
end

-- Layout columns. Evidence is a fixed-width left panel; the live log flexes with
-- the window's width. LOG_LEFT clears the evidence ScrollFrame's scrollbar, which
-- UIPanelScrollFrameTemplate floats ~6..22px to the RIGHT of the panel's edge — so
-- a too-small gap let it bleed into the log. The +32 past EV_W keeps them apart.
local PAD       = 10
local TOP_INSET = -72   -- below the toolbar
local BOT_INSET = 12    -- above the footer / size grip
local EV_W      = 288   -- evidence ScrollFrame width
local LOG_LEFT  = PAD + EV_W + 32   -- left edge of the log column (clears EV scrollbar)

local function build()
	if built then return end
	built = true

	local logBar, syncLogBar, applyScale  -- forward decls (Lua 5.1 upvalue capture)

	frame = CreateFrame("Frame", "TweakIdentifierWindow", UIParent, "BackdropTemplate")
	local w = cfg("window") or {}
	frame:SetSize(w.w or 920, w.h or 600)
	frame:SetScale(w.scale or 1)
	frame:SetPoint(w.point or "CENTER", UIParent, w.point or "CENTER", w.x or 0, w.y or 0)
	frame:SetFrameStrata("HIGH"); frame:SetBackdrop(BACKDROP)
	frame:SetBackdropColor(0.05, 0.05, 0.07, 0.94); frame:SetBackdropBorderColor(0.4, 0.4, 0.45, 1)
	frame:SetMovable(true); frame:SetClampedToScreen(true); frame:EnableMouse(true)
	frame:SetResizable(true)
	if frame.SetResizeBounds then frame:SetResizeBounds(720, 460) end
	frame:RegisterForDrag("LeftButton")
	frame:SetScript("OnDragStart", frame.StartMoving)
	frame:SetScript("OnDragStop", function(self) self:StopMovingOrSizing(); saveWindow() end)
	frame:Hide()
	table.insert(UISpecialFrames, "TweakIdentifierWindow")  -- close on Esc

	-- Whole-window zoom: Ctrl + mouse wheel anywhere over the frame (the log handles
	-- it too, see below). SetScale is taint-free on our own frame.
	applyScale = function(delta)
		local s = (ns.db.window.scale or 1) + delta
		if s < 0.6 then s = 0.6 elseif s > 1.8 then s = 1.8 end
		ns.db.window.scale = s
		frame:SetScale(s)
		feedbackText:SetText(("|cffffd100scale %d%%|r"):format(math.floor(s * 100 + 0.5)))
	end
	frame:EnableMouseWheel(true)
	frame:SetScript("OnMouseWheel", function(_, d)
		if IsControlKeyDown() then applyScale(d > 0 and 0.05 or -0.05) end
	end)

	-- Drag-resize grip (bottom-right). Internal anchors flex, so the log grows.
	local grip = CreateFrame("Button", nil, frame)
	grip:SetSize(16, 16); grip:SetPoint("BOTTOMRIGHT", -4, 4)
	grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
	grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
	grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
	grip:SetScript("OnMouseDown", function() frame:StartSizing("BOTTOMRIGHT") end)
	grip:SetScript("OnMouseUp", function() frame:StopMovingOrSizing(); saveWindow() end)

	local header = frame:CreateTexture(nil, "ARTWORK")
	header:SetColorTexture(0.12, 0.12, 0.16, 1)
	header:SetPoint("TOPLEFT", 1, -1); header:SetPoint("TOPRIGHT", -1, -1); header:SetHeight(24)
	local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	title:SetPoint("TOPLEFT", 10, -6); title:SetText("|cff33ff99TweakIdentifier|r")
	CreateFrame("Button", nil, frame, "UIPanelCloseButton"):SetPoint("TOPRIGHT", 2, 2)

	statusText = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	statusText:SetPoint("TOP", 0, -7); statusText:SetText("|cffff5555paused|r")
	feedbackText = frame:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	feedbackText:SetPoint("TOPRIGHT", -28, -7); feedbackText:SetText("")

	-- toolbar (two rows)
	local pick = makeButton(frame, "Pick", 60, function() ns.Picker.Toggle(true) end)
	pick:SetPoint("TOPLEFT", 8, -28)
	-- Identify button: left-click starts/stops hover-identify, right-click flips the default
	-- stack end (top/front vs bottom/back) the picker lands on. Tooltip surfaces both + state.
	local hover = makeButton(frame, "Identify", 70, nil)
	hover:SetPoint("LEFT", pick, "RIGHT", 4, 0)
	hover:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	local function hoverTooltip(self)
		GameTooltip:SetOwner(self, "ANCHOR_TOP")
		GameTooltip:AddLine("Identify (hover)")
		GameTooltip:AddLine("Defaults to the |cffffff00" .. ns.Picker.PickEndText() .. "|r frame under the cursor.")
		GameTooltip:AddLine(" ")
		GameTooltip:AddLine("|cffffff00Left-click|r — start / stop")
		GameTooltip:AddLine("|cffffff00Right-click|r — toggle top / bottom default (shared with Pick)")
		GameTooltip:Show()
	end
	hover:SetScript("OnEnter", hoverTooltip)
	hover:SetScript("OnLeave", function() GameTooltip:Hide() end)
	hover:SetScript("OnClick", function(self, button)
		if button == "RightButton" then ns.Picker.TogglePickEnd() else ns.Picker.Toggle(false) end
		if self:IsMouseOver() then hoverTooltip(self) end  -- refresh state in place
	end)
	monitorBtn = makeButton(frame, "Monitor", 70, function() ns.Monitor.Toggle() end)
	monitorBtn:SetPoint("LEFT", hover, "RIGHT", 4, 0)
	local timed = makeButton(frame, "Timed 30s", 80, function() ns.Monitor.StartTimed(30) end)
	timed:SetPoint("LEFT", monitorBtn, "RIGHT", 4, 0)
	-- Deep button: left-click starts/stops deep, right-click toggles permanent (persist) mode.
	-- A tooltip surfaces both, plus the current state — the slash command's UI counterpart.
	local deep = makeButton(frame, "Deep", 60, nil)
	deep:SetPoint("LEFT", timed, "RIGHT", 4, 0)
	deep:RegisterForClicks("LeftButtonUp", "RightButtonUp")
	local function deepTooltip(self)
		GameTooltip:SetOwner(self, "ANCHOR_TOP")
		GameTooltip:AddLine("Deep investigation")
		GameTooltip:AddLine(ns.Deep.StatusText())
		GameTooltip:AddLine(" ")
		GameTooltip:AddLine("|cffffff00Left-click|r — start / stop (arms on a reload)")
		GameTooltip:AddLine(ns.Deep.IsPersistent()
			and "|cffffff00Right-click|r — make one-session again"
			or  "|cffffff00Right-click|r — keep enabled permanently across reloads")
		GameTooltip:Show()
	end
	deep:SetScript("OnEnter", deepTooltip)
	deep:SetScript("OnLeave", function() GameTooltip:Hide() end)
	deep:SetScript("OnClick", function(self, button)
		if button == "RightButton" then ns.Deep.TogglePersist() else ns.Deep.Toggle() end
		if self:IsMouseOver() then deepTooltip(self) end  -- refresh state in place
	end)
	local clear = makeButton(frame, "Clear", 56, function() ns.Monitor.Clear() end)
	clear:SetPoint("LEFT", deep, "RIGHT", 4, 0)
	local copy = makeButton(frame, "Copy", 56, function() UI.CopyAll() end)
	copy:SetPoint("LEFT", clear, "RIGHT", 4, 0)
	local filt = makeButton(frame, "Filters", 60, function()
		if filtersPanel:IsShown() then filtersPanel:Hide() else filtersPanel:Show() end
	end)
	filt:SetPoint("LEFT", copy, "RIGHT", 4, 0)
	local saved = makeButton(frame, "Saved", 56, function() UI.ShowSaved() end)
	saved:SetPoint("LEFT", filt, "RIGHT", 4, 0)

	-- evidence panel (left)
	local evLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	evLabel:SetPoint("TOPLEFT", PAD, -56); evLabel:SetText("|cffff9900Evidence|r |cff666666(click an entry for the breakdown + call stacks)|r")
	evidence = makeScrollText(frame, "TweakIdentifierEvidenceScroll")
	evidence.scroll:SetPoint("TOPLEFT", PAD, TOP_INSET)
	evidence.scroll:SetPoint("BOTTOMLEFT", PAD, BOT_INSET)
	evidence.scroll:SetWidth(EV_W); evidence:SetWidthPx(EV_W - 16)
	-- Each tracked frame is a clickable Button row (built lazily in evRow); clicking any
	-- block opens its full per-category breakdown + sample call stacks in the copy box —
	-- the aggregated, per-frame counterpart of the live log's per-event detail.
	evidence.panelW = EV_W - 16   -- fixed content width; evRow sizes block FontStrings to it
	evidence.rows = {}            -- pool of per-frame clickable Button rows

	-- live log (right) — flexes with the window width; 30px right margin holds the scrollbar
	local logLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
	logLabel:SetPoint("TOPLEFT", LOG_LEFT, -56); logLabel:SetText("|cffff9900Live log|r |cff666666(click a line for the call stack)|r")
	logFrame = CreateFrame("ScrollingMessageFrame", "TweakIdentifierLog", frame)
	logFrame:SetPoint("TOPLEFT", LOG_LEFT, TOP_INSET); logFrame:SetPoint("BOTTOMRIGHT", -30, BOT_INSET)
	logFrame:SetFontObject(ChatFontNormal); logFrame:SetJustifyH("LEFT")
	logFrame:SetFading(false); logFrame:SetMaxLines(600); logFrame:SetHyperlinksEnabled(true)
	if logFrame.SetInsertMode then logFrame:SetInsertMode("BOTTOM") end

	-- A ScrollingMessageFrame ships with NO scrollbar (wheel-only). Attach a native
	-- UIPanelScrollBar and drive it from the message-offset scroll model: offset 0 =
	-- newest (bottom), GetMaxScrollRange() = oldest (top). Slider value = max - offset,
	-- so the thumb sits at the bottom when the log is at the bottom.
	logBar = CreateFrame("Slider", "TweakIdentifierLogScroll", frame, "UIPanelScrollBarTemplate")
	logBar.scrollStep = 1
	logBar:SetPoint("TOPRIGHT", logFrame, "TOPRIGHT", 20, -16)
	logBar:SetPoint("BOTTOMRIGHT", logFrame, "BOTTOMRIGHT", 20, 26)
	-- Replace the template's default OnValueChanged (it calls GetParent():SetVerticalScroll,
	-- which our Frame parent lacks) BEFORE the initial SetValue, or that call would throw.
	logBar:SetScript("OnValueChanged", function(self, value)
		if self._syncing then return end          -- ignore programmatic syncs (avoid feedback loop)
		local _, maxv = self:GetMinMaxValues()
		logFrame:SetScrollOffset(maxv - value)
	end)
	logBar:SetMinMaxValues(0, 0); logBar:SetValue(0)

	syncLogBar = function()
		local maxOffset = logFrame:GetMaxScrollRange() or 0
		local offset = logFrame:GetScrollOffset() or 0
		logBar._syncing = true
		logBar:SetMinMaxValues(0, maxOffset)
		logBar:SetValue(maxOffset - offset)
		logBar._syncing = false
		if logBar.ScrollUpButton then
			if offset >= maxOffset then logBar.ScrollUpButton:Disable() else logBar.ScrollUpButton:Enable() end
			if offset <= 0 then logBar.ScrollDownButton:Disable() else logBar.ScrollDownButton:Enable() end
		end
	end
	-- Re-sync the thumb to the live scroll state (new messages, wheel, resize). Throttled,
	-- only runs while the window is shown. Field writes on our own frame are taint-safe.
	logFrame._t = 0
	logFrame:SetScript("OnUpdate", function(self, dt)
		self._t = self._t + dt
		if self._t < 0.1 then return end
		self._t = 0
		syncLogBar()
	end)

	logFrame:EnableMouseWheel(true)
	logFrame:SetScript("OnMouseWheel", function(self, d)
		if IsControlKeyDown() then applyScale(d > 0 and 0.05 or -0.05); return end
		if IsShiftKeyDown() then (d > 0 and self.ScrollToTop or self.ScrollToBottom)(self)
		elseif d > 0 then self:ScrollUp(); self:ScrollUp(); self:ScrollUp()
		else self:ScrollDown(); self:ScrollDown(); self:ScrollDown() end
		syncLogBar()
	end)
	logFrame:SetScript("OnHyperlinkClick", function(_, link)
		local id = tonumber(string.match(link, "^tweakid:(%d+)$"))
		if id then UI.ShowDetail(id) end
	end)

	buildFilters()
	logFrame:AddMessage("|cff808080Pick or Identify a frame. Enable Deep to see who CREATED frames.|r")
	syncLogBar()
	UI.RefreshEvidence()
end

-- ---------------------------------------------------------------------------
-- Public surface
-- ---------------------------------------------------------------------------

function UI.Ensure() build() end
function UI.Show() build(); frame:Show(); ns.db.window.shown = true; UI.RefreshEvidence(); UI.SetStatus(ns.Monitor.active) end
function UI.Hide() if frame then frame:Hide(); ns.db.window.shown = false end end
function UI.Toggle() build(); if frame:IsShown() then UI.Hide() else UI.Show() end end

function UI.SetTargets(_list) build(); UI.RefreshEvidence() end

function UI.SetStatus(active)
	build()
	statusText:SetText((active and "|cff66ff66monitoring|r" or "|cffff5555paused|r")
		.. "   " .. ns.Deep.StatusText())
	if monitorBtn then monitorBtn:SetText(active and "Stop" or "Monitor") end
	UI.RefreshEvidence()
end

function UI.SetFeedback(fb)
	if not built or not fb then return end
	local s = ("events |cffffffff%d|r · addons |cffffffff%d|r"):format(fb.events or 0, fb.addons or 0)
	if fb.remaining then s = s .. (" · |cffffd100%ds|r"):format(math.ceil(fb.remaining)) end
	feedbackText:SetText(s)
	UI.RefreshEvidence()
end

function UI.AppendEvent(ev)
	build()
	logFrame:AddMessage("|Htweakid:" .. ev.id .. "|h" .. UI.FormatLine(ev) .. "|h")
end

function UI.RefreshEvent(_ev) end -- ScrollingMessageFrame lines are immutable; counts show in Copy / Evidence

function UI.ClearLog() if logFrame then logFrame:Clear() end; UI.RefreshEvidence() end
