-- Core/Monitor.lua
-- The monitor engine. Installs taint-safe per-instance secure post-hooks on the
-- mutating methods of one or more target frames, attributes each call to an addon
-- (debugstack), feeds the Evidence aggregator (all callers), keeps a live event log,
-- diffs children/regions, and supports timed runs with live feedback.
--
-- hooksecurefunc hooks cannot be removed; installs are made idempotent via a weak
-- registry and gated at call time by M.monitored.

local ADDON, ns = ...

local M = {}
ns.Monitor = M

local hooksecurefunc = hooksecurefunc
local issecurevariable = issecurevariable
local GetTime, select, wipe = GetTime, select, (wipe or table.wipe)

-- method -> category. Presence-checked per object, so Frames / Textures / FontStrings
-- all work from one list.
local METHODS = {
	-- position
	{ "SetPoint", "position" }, { "SetAllPoints", "position" },
	{ "ClearAllPoints", "position" }, { "ClearPoint", "position" },
	-- size
	{ "SetWidth", "size" }, { "SetHeight", "size" }, { "SetSize", "size" }, { "SetScale", "size" },
	-- visibility
	{ "Show", "visibility" }, { "Hide", "visibility" }, { "SetShown", "visibility" },
	-- alpha / transparency
	{ "SetAlpha", "alpha" }, { "SetIgnoreParentAlpha", "alpha" },
	-- strata / level / z-order
	{ "SetFrameStrata", "strata" }, { "SetFrameLevel", "strata" }, { "SetToplevel", "strata" },
	{ "Raise", "strata" }, { "Lower", "strata" }, { "SetFixedFrameStrata", "strata" }, { "SetFixedFrameLevel", "strata" },
	-- parent
	{ "SetParent", "parent" },
	-- movable / sizable
	{ "SetMovable", "movable" }, { "SetResizable", "movable" }, { "StartMoving", "movable" },
	{ "StartSizing", "movable" }, { "StopMovingOrSizing", "movable" }, { "SetUserPlaced", "movable" },
	-- input
	{ "EnableMouse", "input" }, { "EnableMouseWheel", "input" }, { "SetMouseClickEnabled", "input" },
	{ "SetMouseMotionEnabled", "input" }, { "SetPropagateKeyboardInput", "input" }, { "SetHitRectInsets", "input" },
	-- scripts / behaviour
	{ "SetScript", "script" }, { "HookScript", "script" },
	-- texture restyle
	{ "SetTexture", "texture" }, { "SetAtlas", "texture" }, { "SetColorTexture", "texture" },
	{ "SetVertexColor", "texture" }, { "SetTexCoord", "texture" }, { "SetDesaturated", "texture" },
	{ "SetDesaturation", "texture" }, { "SetRotation", "texture" },
	-- text content
	{ "SetText", "text" }, { "SetFormattedText", "text" },
	-- font (face / size / outline / colour / shadow / layout)
	{ "SetFont", "font" }, { "SetFontObject", "font" }, { "SetTextColor", "font" },
	{ "SetShadowColor", "font" }, { "SetShadowOffset", "font" }, { "SetSpacing", "font" },
	{ "SetJustifyH", "font" }, { "SetJustifyV", "font" },
}

-- method name -> category, derived from METHODS (for override / saved-original attribution, which
-- need a category from just the method name).
local methodCat = {}
for i = 1, #METHODS do methodCat[METHODS[i][1]] = METHODS[i][2] end

M.active     = false
M.monitored  = setmetatable({}, { __mode = "k" }) -- frames whose mutations we log
M.targetList = {}                                  -- ordered primary targets (strong refs while monitoring)
M.baselines  = setmetatable({}, { __mode = "k" })  -- frame -> child/region snapshot
-- frame -> { [method] = taintString } : overrides already reported this investigation. Cleared in
-- SetTargets/Clear; an entry is reset when the method returns to secure so a later re-override re-fires.
M.reportedOverride = setmetatable({}, { __mode = "k" })
M.log        = {}
M.seq        = 0
M.stats      = { events = 0, addons = {} }

local hooked = setmetatable({}, { __mode = "k" })
-- group -> owning frame (weak): maps an animation group back to its monitored target at Play time.
local groupFrame  = setmetatable({}, { __mode = "k" })
local hookedGroups = setmetatable({}, { __mode = "k" }) -- animation groups already hooked (idempotency)
local hookedField = {} -- "tostring(frame)|key" -> true : saved-original / CreateAnimationGroup hooks installed
local recent = {}
local diffTicker, feedbackTicker
M.endTime = nil

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

-- ---------------------------------------------------------------------------
-- Argument summary (secret-safe)
-- ---------------------------------------------------------------------------

local function summarizeArgs(n, ...)
	if n == 0 then return "" end
	local parts, lim = {}, (n < 6 and n or 6)
	for i = 1, lim do parts[i] = ns.SafeArg((select(i, ...))) end
	if n > 6 then parts[#parts + 1] = "…" end
	return table.concat(parts, ", ")
end

-- ---------------------------------------------------------------------------
-- Event recording
-- ---------------------------------------------------------------------------

local function record(ev)
	M.seq = M.seq + 1
	ev.id = M.seq
	local log = M.log
	log[#log + 1] = ev
	local maxLog = cfg("maxLog") or 600
	while #log > maxLog do table.remove(log, 1) end
	if ns.UI and ns.UI.AppendEvent then ns.UI.AppendEvent(ev) end
	if cfg("chatOutput") and ns.UI and ns.UI.FormatLine then ns.Print(ns.UI.FormatLine(ev)) end
end
M.Record = record

local function pruneRecent(t)
	local cutoff = t - 5
	for k, v in pairs(recent) do
		if v.t < cutoff then recent[k] = nil end
	end
end

-- Coalesce a rapid-fire signature into its recently recorded event. Returns true when this call was
-- folded into an existing event (count bumped) and should not be recorded again. Shared by the
-- method-hook, saved-original-bypass, and animation paths so a multi-tick burst is one row xN.
local function coalesceRecent(key, t, throttle)
	local prev = recent[key]
	if prev and prev.ev and (t - prev.t) < throttle then
		prev.t = t
		prev.ev.count = (prev.ev.count or 1) + 1
		if ns.UI and ns.UI.RefreshEvent then ns.UI.RefreshEvent(prev.ev) end
		return true
	end
	return false
end

local function onMutation(self, method, cat, n, ...)
	if not M.active or not M.monitored[self] then return end
	local cats = cfg("categories")
	if cats and cats[cat] == false then return end

	local args = summarizeArgs(n, ...)
	-- tostring(self) is a stable per-instance id, so distinct anonymous frames with the
	-- same name never collide in the dedup table.
	local key  = tostring(self) .. "|" .. method .. "|" .. args
	local t    = GetTime()
	local throttle = cfg("throttle") or 0.25

	-- Coalesce only into a real recorded event; a falsey sentinel must not swallow a
	-- later genuine external mutation that shares the signature.
	if coalesceRecent(key, t, throttle) then return end

	local culprit, culpritTitle, ext, extTitle, chain, stack = ns.GetCaller()

	-- The change came through Blizzard's secured/restricted environment when the immediate
	-- caller (first stack folder) is the SecureStateDriverManager proxy — the live stack then
	-- shows Blizzard, never the originating addon. Flag it regardless of whether we can name
	-- the originator, so the detail view always explains the indirection.
	local viaSecure = (ns.Drivers and ns.Drivers.IsProxyCulprit(culprit)) or false

	-- Recover the addon that registered the driver (captured at registration in Core/Drivers.lua).
	-- Done BEFORE the onlyNonBlizzard gate so a recovered external culprit isn't wrongly suppressed.
	local viaDriver, driverStack
	if not ext and viaSecure then
		local reg = ns.Drivers.Lookup(self)
		if reg then
			ext, extTitle = reg.ext, reg.extTitle
			viaDriver, driverStack = reg.kind, reg.stack
		end
	end

	-- Restricted environment detected, but the real caller is hidden: the change came through
	-- the secure-driver proxy and we recovered no registrant. Hint to enable deep ONLY when the
	-- capture hooks aren't installed this session (deep off) — then enabling deep would catch it.
	-- If the hooks ARE installed and we still got nothing, it's a Blizzard / pre-hook driver that
	-- deep can't recover, so no hint. When onlyNonBlizzard is enabled the event below is dropped,
	-- so this fires before that gate or the user would see nothing at all. Hint once.
	if viaSecure and not ext and not M.deepHintShown and ns.Drivers and not ns.Drivers.Installed() then
		M.deepHintShown = true
		ns.Print("A change is reaching this frame through Blizzard's secured environment "
			.. "(a state/unit/attribute driver) — the real addon is hidden. To identify who "
			.. "registered it, enable deep investigation (|cffffff00/twid deep|r or the "
			.. "|cffffff00Deep|r button) and reload.")
	end

	if cfg("onlyNonBlizzard") and not ext then
		recent[key] = { t = t, ev = false }
		return
	end

	local folder = ext or culprit
	local title  = extTitle or culpritTitle
	ns.Evidence.AddAlter(self, cat, method, args, folder, title, chain, stack)
	M.stats.events = M.stats.events + 1
	if folder then M.stats.addons[folder] = title or folder end

	local ev = {
		kind = "method", time = t, clock = date("%H:%M:%S"),
		target = ns.ObjectName(self) or tostring(self),
		method = method, cat = cat, args = args,
		culprit = culprit, culpritTitle = culpritTitle, ext = ext, extTitle = extTitle,
		chain = chain, stack = stack, count = 1,
		viaSecure = viaSecure, viaDriver = viaDriver, driverStack = driverStack,
	}
	recent[key] = { t = t, ev = ev }
	record(ev)
end
M.OnMutation = onMutation

-- ---------------------------------------------------------------------------
-- Detecting changes the per-instance setter hooks can't see
--
-- A frame's mutator can be HIJACKED: an addon saves the original method
-- (frame._origSetAlpha = frame.SetAlpha), replaces frame.SetAlpha with its own
-- function, and drives changes through the saved copy. Our hooksecurefunc on
-- frame.SetAlpha (installed at pick, after the hijack) then wraps the replacement
-- and never sees the real change (AutoHideUI's "Force Alpha"). All value-free —
-- we read taint metadata + call stacks + object-type strings, never a value
-- (alpha/scale/etc. are frequently SECRET in 12.0).
-- ---------------------------------------------------------------------------

-- An override of `method` was detected on `frame`, written by `taint`. Persist as evidence ONLY when
-- the taint resolves to a known addon (never key SavedVariables by an unvalidated engine string);
-- otherwise log it for reference only.
local function recordOverride(frame, name, cat, taint)
	local res = ns.ResolveTaint(taint)
	if res and res.folder == ADDON then return end -- our own hook (belt-and-suspenders; hooksecurefunc is taint-safe)
	local folder = res and res.folder
	local title  = (res and res.title) or taint
	if res then
		ns.Evidence.AddAlter(frame, cat, name .. " (replaced)", "method overridden", folder, title)
		M.stats.events = M.stats.events + 1
		M.stats.addons[folder] = title
	end
	record({
		kind = "override", time = GetTime(), clock = date("%H:%M:%S"),
		target = ns.ObjectName(frame) or tostring(frame),
		method = name, cat = cat, args = "", count = 1,
		ext = folder, extTitle = res and title or nil,
		overrideBy = title, resolved = res ~= nil,
	})
end

-- The saved original was just called (the real mutation). Attribute via the live stack (the addon's
-- OnUpdate/driver is on it) and record value-free. Coalesces a fade burst into one row xN.
local function recordBypass(self, method, cat, field)
	if not M.active or not M.monitored[self] then return end
	local cats = cfg("categories")
	if cats and cats[cat] == false then return end
	local t = GetTime()
	local key = tostring(self) .. "|bypass|" .. method
	if coalesceRecent(key, t, cfg("throttle") or 0.25) then return end
	local culprit, culpritTitle, ext, extTitle, chain, stack = ns.GetCaller()
	if cfg("onlyNonBlizzard") and not ext then
		recent[key] = { t = t, ev = false }
		return
	end
	local folder = ext or culprit
	local title  = extTitle or culpritTitle
	ns.Evidence.AddAlter(self, cat, method .. " (bypass)", "via " .. field, folder, title, chain, stack)
	M.stats.events = M.stats.events + 1
	if folder then M.stats.addons[folder] = title or folder end
	local ev = {
		kind = "bypass", time = t, clock = date("%H:%M:%S"),
		target = ns.ObjectName(self) or tostring(self),
		method = method, cat = cat, viaField = field, args = "",
		culprit = culprit, culpritTitle = culpritTitle, ext = ext, extTitle = extTitle,
		chain = chain, stack = stack, count = 1,
	}
	recent[key] = { t = t, ev = ev }
	record(ev)
end

-- Find the addon's saved copy of an overridden method (a function-valued OWN key whose name contains
-- the method name) and hook it, so the real calls become attributed bypass events. pairs(frame)
-- surfaces only addon-added fields (widget methods live on the metatable __index).
local function hookSavedOriginal(frame, name)
	local lname = name:lower()
	local cat = methodCat[name] or "alpha"
	for k, v in pairs(frame) do
		if type(k) == "string" and k ~= name and type(v) == "function"
			and not ns.IsSecret(v) and k:lower():find(lname, 1, true) then
			local fkey = tostring(frame) .. "|" .. k
			if not hookedField[fkey] then
				hookedField[fkey] = true
				hooksecurefunc(frame, k, function(self, ...)
					recordBypass(self, name, cat, k)
				end)
			end
		end
	end
end

-- Check the frame's mutator methods for an insecure override (an addon replaced the method on the
-- instance). Runs pre-hook in HookFrame (the only point our own hook hasn't yet masked the taint) and
-- as a ticker re-check (catches an addon that clobbers our hook mid-investigation). Value-free.
local function checkOverrides(frame)
	if not ns.IsWidget(frame) then return end
	local rep = M.reportedOverride[frame]
	for i = 1, #METHODS do
		local name = METHODS[i][1]
		if type(frame[name]) == "function" then
			local ok, secure, taint = pcall(issecurevariable, frame, name)
			if ok and secure == false and taint and taint ~= "" and taint ~= ADDON then
				if not rep then rep = {}; M.reportedOverride[frame] = rep end
				if rep[name] ~= taint then
					rep[name] = taint
					recordOverride(frame, name, METHODS[i][2], taint)
					hookSavedOriginal(frame, name)
				end
			elseif ok and secure ~= false and rep and rep[name] then
				rep[name] = nil -- returned to secure; allow a later re-override to re-fire
			end
		end
	end
end
M.CheckOverrides = checkOverrides

-- ---------------------------------------------------------------------------
-- Animation groups OWNED by the frame (value-free; owner-side only — WoW exposes no reverse
-- "what animates region R" lookup, so a group owned elsewhere and re-targeted here is not caught).
-- ---------------------------------------------------------------------------

-- Unique animation types in a group ("Alpha", "Scale", …) — each animation is its own UIObject type.
local function animTypes(group)
	local ok, list = pcall(function() return { group:GetAnimations() } end)
	if not ok then return "?" end
	local seen, out = {}, {}
	for i = 1, #list do
		local a = list[i]
		if not ns.IsSecret(a) then
			local ty = ns.ObjectType(a)
			if ty and ty ~= "?" and not seen[ty] then seen[ty] = true; out[#out + 1] = ty end
		end
	end
	return #out > 0 and table.concat(out, "+") or "Animation"
end

-- A group control method (Play/Restart/…) fired. Attribute via the live stack (names the addon for a
-- synchronous Lua call; a C-driven autoplay leaves no addon on the stack).
local function recordAnimation(group, method)
	local frame = groupFrame[group]
	if not frame or not (M.active and M.monitored[frame]) then return end
	local cats = cfg("categories")
	if cats and cats["animation"] == false then return end
	local t = GetTime()
	local key = tostring(group) .. "|anim|" .. method
	if coalesceRecent(key, t, cfg("throttle") or 0.25) then return end
	local culprit, culpritTitle, ext, extTitle, chain, stack = ns.GetCaller()
	if cfg("onlyNonBlizzard") and not ext then
		recent[key] = { t = t, ev = false }
		return
	end
	local types = animTypes(group)
	local folder = ext or culprit
	local title  = extTitle or culpritTitle
	ns.Evidence.AddAlter(frame, "animation", method, types, folder, title, chain, stack)
	M.stats.events = M.stats.events + 1
	if folder then M.stats.addons[folder] = title or folder end
	local ev = {
		kind = "animation", time = t, clock = date("%H:%M:%S"),
		target = ns.ObjectName(frame) or tostring(frame),
		method = method, cat = "animation", args = types,
		culprit = culprit, culpritTitle = culpritTitle, ext = ext, extTitle = extTitle,
		chain = chain, stack = stack, count = 1,
	}
	recent[key] = { t = t, ev = ev }
	record(ev)
end

local function hookGroup(group, frame)
	if ns.IsSecret(group) or type(group) ~= "table" or hookedGroups[group] then return end
	hookedGroups[group] = true
	groupFrame[group] = frame
	for _, m in ipairs({ "Play", "Restart", "Finish", "Stop", "Pause" }) do
		if type(group[m]) == "function" then
			hooksecurefunc(group, m, function(self) recordAnimation(self, m) end)
		end
	end
end

-- Enumerate the frame's OWN animation groups, hook their control methods, hook CreateAnimationGroup
-- for groups made later, and emit a one-time inventory of what's present. Skips protected/forbidden
-- frames (protection cascades — never instance-hook them).
local function hookAnimationGroups(frame)
	if type(frame.GetAnimationGroups) ~= "function" then return end
	if type(frame.IsForbidden) == "function" then
		local ok, forbidden = pcall(frame.IsForbidden, frame)
		if ok and forbidden then return end
	end
	if type(frame.IsProtected) == "function" then
		local ok, prot = pcall(frame.IsProtected, frame)
		if ok and prot then return end
	end
	local ok, groups = pcall(function() return { frame:GetAnimationGroups() } end)
	if not ok then return end
	for i = 1, #groups do hookGroup(groups[i], frame) end

	if type(frame.CreateAnimationGroup) == "function" then
		local fkey = tostring(frame) .. "|CreateAnimationGroup"
		if not hookedField[fkey] then
			hookedField[fkey] = true
			-- post-hooks don't receive the return value; re-enumerate to find the new group.
			hooksecurefunc(frame, "CreateAnimationGroup", function(self)
				if not (M.active and M.monitored[self]) then return end
				local ok2, gs = pcall(function() return { self:GetAnimationGroups() } end)
				if ok2 then for j = 1, #gs do hookGroup(gs[j], self) end end
			end)
		end
	end

	-- One-time inventory at pick: characterise the groups present (name / types / playing), value-free.
	if #groups > 0 then
		local parts = {}
		for i = 1, #groups do
			local g = groups[i]
			if not ns.IsSecret(g) then
				local nm = "anon"
				local okN, gn = pcall(g.GetName, g)
				if okN and type(gn) == "string" and gn ~= "" then nm = gn end
				local playing = ""
				local okP, isp = pcall(g.IsPlaying, g)
				if okP and not ns.IsSecret(isp) and isp == true then playing = ",playing" end
				parts[#parts + 1] = nm .. "[" .. animTypes(g) .. playing .. "]"
			end
		end
		if #parts > 0 then
			record({
				kind = "animation", time = GetTime(), clock = date("%H:%M:%S"),
				target = ns.ObjectName(frame) or tostring(frame),
				method = "groups present", cat = "animation",
				args = table.concat(parts, ", "), count = 1,
			})
		end
	end
end

-- ---------------------------------------------------------------------------
-- Hook installation
-- ---------------------------------------------------------------------------

function M.HookFrame(frame)
	if not ns.IsWidget(frame) or hooked[frame] then return end
	hooked[frame] = true
	-- BEFORE installing our own hooks: detect any pre-existing override (after our hooksecurefunc the
	-- key is re-secured and the addon's taint is masked, so this is the only reliable detection point).
	checkOverrides(frame)
	for i = 1, #METHODS do
		local name, cat = METHODS[i][1], METHODS[i][2]
		if type(frame[name]) == "function" then
			hooksecurefunc(frame, name, function(self, ...)
				onMutation(self, name, cat, select("#", ...), ...)
			end)
		end
	end
	hookAnimationGroups(frame)
end

-- ---------------------------------------------------------------------------
-- Child / region diff (catches additions / reparenting / removals; attributes
-- creators of new children via the engine source location)
-- ---------------------------------------------------------------------------

local function snapshot(frame)
	-- GetChildren/GetRegions carry SecretAspect.Hierarchy and may return secret handles
	-- on secured frames; a secret can never be used as a table key, so skip them.
	local set = {}
	if type(frame.GetChildren) == "function" then
		local kids = { frame:GetChildren() }
		for i = 1, #kids do
			local k = kids[i]
			if not ns.IsSecret(k) then set[k] = "child" end
		end
	end
	if type(frame.GetRegions) == "function" then
		local regs = { frame:GetRegions() }
		for i = 1, #regs do
			local r = regs[i]
			if not ns.IsSecret(r) then set[r] = "region" end
		end
	end
	return set
end
M.Snapshot = snapshot

local function diffOne(target)
	local current = snapshot(target)
	local base = M.baselines[target] or {}
	for obj, kind in pairs(current) do
		if not base[obj] then
			local src = ns.SourceOf(obj)
			local by  = src and (src.title or src.folder) or ns.GuessFromName(ns.ObjectName(obj))
			record({
				kind = "diff", time = GetTime(), clock = date("%H:%M:%S"),
				target = ns.ObjectName(target) or tostring(target),
				method = "added " .. kind, cat = "create",
				args = (ns.ObjectName(obj) or tostring(obj)) .. " (" .. ns.ObjectType(obj) .. ")",
				ext = src and src.folder, extTitle = by, count = 1,
			})
			if by then
				ns.Evidence.AddAlter(target, "create", "added " .. kind,
					ns.ObjectName(obj) or "?", src and src.folder, by, nil, nil)
			end
			if kind == "child" and cfg("monitorDescendants") then
				M.monitored[obj] = true
				M.HookFrame(obj)
			end
		end
	end
	for obj, kind in pairs(base) do
		if not current[obj] then
			record({
				kind = "diff", time = GetTime(), clock = date("%H:%M:%S"),
				target = ns.ObjectName(target) or tostring(target),
				method = "removed " .. kind, cat = "create",
				args = (ns.ObjectName(obj) or tostring(obj)) .. " (" .. ns.ObjectType(obj) .. ")", count = 1,
			})
		end
	end
	M.baselines[target] = current
end

local function runDiff()
	if not M.active then return end
	pruneRecent(GetTime())
	for i = 1, #M.targetList do
		local f = M.targetList[i]
		if ns.IsWidget(f) then
			diffOne(f)
			-- Ticker re-check (primaries only — bounded cost): catches an addon that clobbers our
			-- method hook mid-investigation (its insecure write re-taints the key, un-masking it).
			checkOverrides(f)
		end
	end
end

-- ---------------------------------------------------------------------------
-- Timers / feedback
-- ---------------------------------------------------------------------------

local function startTickers()
	if not (C_Timer and C_Timer.NewTicker) then return end
	if not diffTicker then diffTicker = C_Timer.NewTicker(0.5, runDiff) end
	if not feedbackTicker then
		feedbackTicker = C_Timer.NewTicker(0.5, function()
			if M.endTime and GetTime() >= M.endTime then
				M.Stop()
				ns.Print("Timed investigation complete.")
				return
			end
			if ns.UI and ns.UI.SetFeedback then ns.UI.SetFeedback(M.GetFeedback()) end
		end)
	end
end

local function stopTickers()
	if diffTicker then diffTicker:Cancel(); diffTicker = nil end
	if feedbackTicker then feedbackTicker:Cancel(); feedbackTicker = nil end
end

function M.GetFeedback()
	local addonCount = 0
	for _ in pairs(M.stats.addons) do addonCount = addonCount + 1 end
	local remaining
	if M.endTime then remaining = math.max(0, M.endTime - GetTime()) end
	return { events = M.stats.events, addons = addonCount, remaining = remaining, active = M.active }
end

-- ---------------------------------------------------------------------------
-- Public control
-- ---------------------------------------------------------------------------

---Monitor a list of frames (the picker hands the under-cursor selection here).
function M.SetTargets(list)
	wipe(M.monitored)
	wipe(M.targetList)
	M.deepHintShown = nil  -- re-hint once per fresh investigation if a hidden driver shows up
	for i = 1, #list do
		local f = list[i]
		if ns.IsWidget(f) then
			M.targetList[#M.targetList + 1] = f
			M.monitored[f] = true
			-- Fresh investigation: clear reported overrides so the ticker re-check can re-surface them
			-- (done OUTSIDE HookFrame, which early-returns for an already-hooked re-picked frame).
			M.reportedOverride[f] = nil
			M.HookFrame(f)
			M.baselines[f] = snapshot(f)
			ns.Evidence.For(f) -- pre-create the record (captures creator/identity)
			if cfg("monitorDescendants") then
				for obj, kind in pairs(M.baselines[f]) do
					if kind == "child" then M.monitored[obj] = true; M.HookFrame(obj) end
				end
			end
		end
	end
	if ns.UI and ns.UI.SetTargets then ns.UI.SetTargets(M.targetList) end
	M.Start()
end

function M.SetTarget(frame)
	M.SetTargets({ frame })
end

function M.Start()
	if #M.targetList == 0 then
		ns.Print("No target selected. Use |cffffff00/twid pick|r first.")
		return
	end
	M.active = true
	startTickers()
	if ns.UI and ns.UI.SetStatus then ns.UI.SetStatus(true) end
end

function M.StartTimed(seconds)
	M.endTime = GetTime() + (seconds or 30)
	M.Start()
	ns.Print(("Investigating for %ds — watch the log, or |cffffff00/twid stop|r early."):format(seconds or 30))
end

function M.Stop()
	M.active = false
	M.endTime = nil
	stopTickers()
	ns.Evidence.SaveAll()
	if ns.UI and ns.UI.SetStatus then ns.UI.SetStatus(false) end
	if ns.UI and ns.UI.SetFeedback then ns.UI.SetFeedback(M.GetFeedback()) end
end

function M.Toggle()
	if M.active then M.Stop() else M.Start() end
end

function M.Clear()
	wipe(M.log); wipe(recent); wipe(M.reportedOverride)
	M.seq = 0
	M.stats = { events = 0, addons = {} }
	if ns.UI and ns.UI.ClearLog then ns.UI.ClearLog() end
end
