-- KiraUI-Plugin_Inspect.lua
-- Inspect talents: a read-only talent tree drawn beside the inspect sheet.
--
-- DESIGN NOTE - why this does NOT reuse Blizzard's talent window.
-- The obvious approach is to open PlayerSpellsFrame in inspect mode and move it
-- next to the sheet. That fails, for reasons worth recording so nobody tries it
-- again:
--   * PlayerSpellsFrame is area="centerOrLeft" and InspectFrame is area="left",
--     pushable=0, so the panel manager EVICTS the sheet the moment the tree
--     opens.
--   * Escaping the panel manager means clearing the frame's "area" attribute,
--     but SetAttributeNoHandler on a protected frame can be refused silently.
--   * The other bypass, editModeManuallyShown, works - but it drops the frame's
--     panel chrome and leaves it unscaled, because the panel manager was also
--     what sized and skinned it.
-- The reliable route is not to touch that frame at all: read the trait data
-- directly and draw our own tree. That is what this module does.
--
-- DATA SOURCE
-- After NotifyInspect + INSPECT_READY the client exposes the inspected unit's
-- build under the reserved config ID -1:
--     C_Traits.GetConfigInfo(-1).treeIDs[1]      -> the class/spec tree
--     C_Traits.GetTreeNodes(treeID)              -> every node ID
--     C_Traits.GetNodeInfo(-1, nodeID)           -> position, ranks, edges
--     C_Traits.GetEntryInfo(-1, entryID)         -> definition for the choice
--     C_Traits.GetDefinitionInfo(definitionID)   -> spell -> icon
-- Node coordinates come back on a 300-unit grid; nodes with posX past the
-- midpoint belong to the spec tree (drawn right), the rest to the class tree
-- (drawn left). Hero-talent nodes carry a subTreeID and sit in the middle.

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

local CreateFrame = CreateFrame
local ipairs, pairs, type, pcall, select = ipairs, pairs, type, pcall, select
local floor, sqrt, atan2, max, min = math.floor, math.sqrt, math.atan2, math.max, math.min
local issecretvalue = issecretvalue
local wipe = wipe

ns.hasInspect = true

local INSPECT_CONFIG_ID = -1
local GRID_UNIT = 300   -- trait coordinates per half-tile
local SPEC_SPLIT = 12.5 -- normalized X at/after which a node is spec-side
local EXTRA_H = 2       -- fine tuning so the panel lines up with the sheet

local function secret(v) return issecretvalue and issecretvalue(v) end

----------------------------------------------------------------------
-- Settings
----------------------------------------------------------------------
local DEFAULTS = {
	enable = true,
	side = 'RIGHT',
	gap = 2,
	yOffset = -1, -- nudge so the panel's top edge lines up with the sheet's

	nodeSize = 22,     -- pixel size of one talent icon; the panel sizes to fit
	spacing = 10,      -- extra pixels between icons; grid pitch is nodeSize + this
	showInactive = true,
	bgAlpha = 1,
}

local function GetCfg() return ns.ModuleCfg('inspectTalents', DEFAULTS) end
ns.INSGetCfg = GetCfg

----------------------------------------------------------------------
-- Frame
----------------------------------------------------------------------
local panel, nodePool, edgePool
local numNodes, numEdges = 0, 0
local lastUnit
local buildErr

local function ISF() return _G.InspectFrame end

local function EnsurePanel()
	if panel then return panel end
	panel = CreateFrame('Frame', 'KiraUIInspectTalents', _G.UIParent)
	panel:SetFrameStrata('HIGH')
	panel:SetClampedToScreen(true)
	panel:Hide()

	panel.bg = panel:CreateTexture(nil, 'BACKGROUND')
	panel.bg:SetAllPoints()
	panel.bg:SetColorTexture(0.055, 0.055, 0.065, 1)

	-- 1px border, matching the plugin's own config window
	local function edge()
		local t = panel:CreateTexture(nil, 'BORDER')
		t:SetColorTexture(0, 0, 0, 1)
		return t
	end
	panel.eT, panel.eB, panel.eL, panel.eR = edge(), edge(), edge(), edge()
	panel.eT:SetPoint('TOPLEFT', -1, 1) panel.eT:SetPoint('TOPRIGHT', 1, 1) panel.eT:SetHeight(1)
	panel.eB:SetPoint('BOTTOMLEFT', -1, -1) panel.eB:SetPoint('BOTTOMRIGHT', 1, -1) panel.eB:SetHeight(1)
	panel.eL:SetPoint('TOPLEFT', -1, 1) panel.eL:SetPoint('BOTTOMLEFT', -1, -1) panel.eL:SetWidth(1)
	panel.eR:SetPoint('TOPRIGHT', 1, 1) panel.eR:SetPoint('BOTTOMRIGHT', 1, -1) panel.eR:SetWidth(1)

	panel.title = panel:CreateFontString(nil, 'OVERLAY', 'GameFontNormal')
	panel.title:SetPoint('TOP', 0, -8)

	panel.specText = panel:CreateFontString(nil, 'OVERLAY', 'GameFontHighlightSmall')
	panel.specText:SetPoint('TOPLEFT', 10, -10)

	panel.note = panel:CreateFontString(nil, 'OVERLAY', 'GameFontDisableSmall')
	panel.note:SetPoint('CENTER')
	panel.note:Hide()

	-- The tree lives in a child frame so the panel can be pinned to the inspect
	-- sheet's exact top AND bottom edge while the contents are scaled to fit
	-- whatever height that leaves. Scaling the panel itself would move its
	-- edges away from the sheet, which is the opposite of what we want.
	panel.content = CreateFrame('Frame', nil, panel)
	panel.content:SetPoint('TOPLEFT')

	nodePool, edgePool = {}, {}
	return panel
end

-- A choice node needs TWO tooltips on screen at once (taken + passed on) and
-- GameTooltip can only hold one spell, so we own a second one. Built lazily so
-- players who never turn the module on never pay for the frame.
local tip2
local function GetTip2()
	if not tip2 then
		tip2 = CreateFrame('GameTooltip', 'KiraUIInspectTooltip2', _G.UIParent, 'GameTooltipTemplate')
	end
	return tip2
end

-- Put the talent's icon in front of its name. The tooltip's first line is the
-- spell name, so we rewrite it in place with a texture escape rather than
-- adding a line (which would sit under the name). The 5:59 crop trims the
-- icon's built-in border, matching how the icons are drawn on the nodes.
local function IconifyTitle(tt, spellID)
	if not spellID then return end
	local name = tt:GetName()
	if not name then return end
	local line = _G[name .. 'TextLeft1']
	if not line then return end
	local tex
	if _G.C_Spell and _G.C_Spell.GetSpellTexture then
		local ok, t = pcall(_G.C_Spell.GetSpellTexture, spellID)
		if ok and t and not secret(t) then tex = t end
	end
	if not tex then return end
	-- Same trap as the colour read below: a tooltip line filled from restricted
	-- data can hand back a SECRET string, and `title == ''` raises on one just
	-- as `>` does on a secret number. Check secrecy and type before comparing,
	-- and before handing it to format() - which is not a secret-safe sink.
	local okT, title = pcall(line.GetText, line)
	if not okT or secret(title) or type(title) ~= 'string' or title == '' then return end
	line:SetText(('|T%s:18:18:0:0:64:64:5:59:5:59|t %s'):format(tex, title))
end

-- Grey every line of a tooltip. Used on the half of a choice the player did
-- NOT take: the colour difference reads at a glance, where a trailing "not
-- taken" label alone does not.
local function GreyTooltip(tt)
	local name = tt:GetName()
	if not name then return end
	for i = 1, tt:NumLines() do
		local l = _G[name .. 'TextLeft' .. i]
		if l then l:SetTextColor(i == 1 and 0.72 or 0.55, i == 1 and 0.72 or 0.55, i == 1 and 0.72 or 0.55) end
		local r = _G[name .. 'TextRight' .. i]
		-- IsShown on an engine-filled region can hand back a SECRET boolean,
		-- which cannot be truth-tested. Writing a colour to a hidden fontstring
		-- is harmless, so an unreadable answer just means "colour it anyway".
		if r then
			local okS, shown = pcall(r.IsShown, r)
			if not okS or secret(shown) or shown then
				r:SetTextColor(0.55, 0.55, 0.55)
			end
		end
	end
end

-- Blank out "Requires <weapon/form/...>" lines. Inspecting someone else's build
-- is about what they PICKED, not about whether you could cast it, so the red
-- requirement lines are noise here.
--
-- Matched by colour rather than by text: the red requirement colour is the same
-- in every locale, whereas the wording is not. A pure text match on "Requires"
-- would only work on an English client.
--
-- GetTextColor can return SECRET numbers once the tooltip has been filled from
-- restricted data, and a secret value cannot be compared - `r > 0.9` RAISES
-- rather than returning false. All three channels are therefore checked for
-- secrecy BEFORE any of them is compared. Unreadable colour means the line is
-- left alone: showing a "Requires" line we could not identify is a cosmetic
-- miss, where the comparison was killing the whole hover.
local function StripRequires(tt)
	local name = tt:GetName()
	if not name then return end
	for i = 1, tt:NumLines() do
		local line = _G[name .. 'TextLeft' .. i]
		if line then
			local okS, shown = pcall(line.IsShown, line)
			if okS and not secret(shown) and shown then
				local okC, r, g, b = pcall(line.GetTextColor, line)
				if okC and not secret(r) and not secret(g) and not secret(b)
					and type(r) == 'number' and type(g) == 'number' and type(b) == 'number' then
					-- RED_FONT_COLOR is 1, 0.125, 0.125
					if r > 0.9 and g < 0.3 and b < 0.3 then
						line:SetText(' ')
					end
				end
			end
		end
	end
end

-- Build the "not taken" tooltip. Split out of OnEnter because it has to run
-- twice - once immediately, once on the next frame after the spell description
-- has been fetched (see the call site).
local function PopulateOtherTip(owner, otherID)
	local t2 = GetTip2()
	t2:SetOwner(owner, 'ANCHOR_NONE')
	t2:ClearAllPoints()
	t2:SetPoint('TOPLEFT', _G.GameTooltip, 'BOTTOMLEFT', 0, -4)
	t2:ClearLines()
	local ok = pcall(t2.SetSpellByID, t2, otherID)
	if not ok then t2:Hide() return end
	t2:Show()
	if t2:NumLines() == 0 then t2:Hide() return end
	t2:AddLine('Not taken', 0.5, 0.5, 0.5)
	StripRequires(t2)
	IconifyTitle(t2, otherID)
	t2:Show() -- re-fit after the extra line
	-- greying must come AFTER Show(): the tooltip re-applies its own line
	-- colours as part of laying out
	GreyTooltip(t2)
end

-- Node buttons are pooled: inspecting a new target reuses them.
local function AcquireNode(i)
	local n = nodePool[i]
	if n then return n end
	n = CreateFrame('Button', nil, panel.content)
	n.border = n:CreateTexture(nil, 'BACKGROUND')
	n.border:SetAllPoints()
	n.icon = n:CreateTexture(nil, 'ARTWORK')
	n.icon:SetPoint('TOPLEFT', 1, -1)
	n.icon:SetPoint('BOTTOMRIGHT', -1, 1)
	n.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

	-- Second half-icon, used only on an UNTAKEN choice node: with nothing picked
	-- there is no single "the" talent to show, so the square is split down the
	-- middle and each option gets one half. Hidden on every other node.
	n.icon2 = n:CreateTexture(nil, 'ARTWORK')
	n.icon2:Hide()

	-- hairline between the two halves so they read as two icons, not one odd one
	n.split = n:CreateTexture(nil, 'OVERLAY', nil, 5)
	n.split:SetColorTexture(0, 0, 0, 0.9)
	n.split:SetWidth(1)
	n.split:SetPoint('TOP', n, 'TOP', 0, 0)
	n.split:SetPoint('BOTTOM', n, 'BOTTOM', 0, 0)
	n.split:Hide()
	-- Choice nodes (pick one of two) keep the same SQUARE footprint as every
	-- other node - Blizzard's octagon art overhung the grid and the icon
	-- corners poked through the connector lines. They are marked instead by a
	-- downward arrow tucked under the icon's bottom edge, in the row gap, so it
	-- never covers the artwork and never collides with a neighbour.
	--
	-- It sits on a dark rounded backing: a thin gold arrow straight on top of
	-- busy talent art was effectively invisible, which was the first attempt.
	n.choiceBg = n:CreateTexture(nil, 'OVERLAY', nil, 6)
	n.choiceBg:SetColorTexture(0, 0, 0, 0.85)
	n.choiceBg:SetPoint('CENTER', n, 'RIGHT', 0, 0)
	n.choiceBg:Hide()

	n.choice = n:CreateTexture(nil, 'OVERLAY', nil, 7)
	n.choice:SetTexture([[Interface\ChatFrame\ChatFrameExpandArrow]])
	-- art already points right, which is the direction we want on this edge
	n.choice:SetPoint('CENTER', n.choiceBg, 'CENTER', 0, 0)
	n.choice:Hide()
	n.rank = n:CreateFontString(nil, 'OVERLAY', 'GameFontNormalSmall')
	n.rank:SetPoint('BOTTOMRIGHT', 2, -2)
	-- blue reads far better against the gold borders and warm talent art than
	-- the font object's default yellow
	n.rank:SetTextColor(0.35, 0.75, 1)
	n:SetScript('OnEnter', function(self)
		local ch = self.choiceSpells
		-- Which two spells to show. On a TAKEN choice node it is picked + the one
		-- passed over; on an UNTAKEN one neither is picked, so we show both
		-- options in entry order. Plain nodes have no `ch` and show one tooltip.
		local main, otherID = self.spellID, nil
		-- On an UNTAKEN choice node neither half is picked. The first tooltip
		-- therefore has to look exactly as unselected as the second: rendering
		-- it in full colour while only the second was greyed made entry 1 read
		-- as the chosen one, which is the opposite of the truth.
		local untaken = false
		if ch then
			if ch.picked then
				main, otherID = ch.picked, ch.other
			elseif ch.opts then
				untaken = true
				main = ch.opts[1] and ch.opts[1].id or main
				otherID = ch.opts[2] and ch.opts[2].id
			end
		end
		if not main then return end
		_G.GameTooltip:SetOwner(self, 'ANCHOR_RIGHT')
		pcall(_G.GameTooltip.SetSpellByID, _G.GameTooltip, main)
		if self.rankText then
			_G.GameTooltip:AddLine(self.rankText, 1, 0.82, 0)
		end
		StripRequires(_G.GameTooltip)
		IconifyTitle(_G.GameTooltip, main)
		if untaken then _G.GameTooltip:AddLine('Not taken', 0.5, 0.5, 0.5) end
		_G.GameTooltip:Show()
		-- greying must come AFTER Show(): the tooltip re-applies its own line
		-- colours as part of laying out
		if untaken then GreyTooltip(_G.GameTooltip) end

		-- second tooltip = the half of the choice they did NOT take, stacked
		-- under the first so both are readable in one hover
		if otherID and otherID ~= main then
			PopulateOtherTip(self, otherID)
			-- The spell's description is fetched from the server on first use,
			-- so hover #1 renders a header with no body and hover #2 looks fine
			-- because the data has since arrived. Re-populate on the next frame
			-- to pick it up, still within the same hover.
			_G.C_Timer.After(0, function()
				if self:IsMouseOver() and tip2 and tip2:IsShown() then
					PopulateOtherTip(self, otherID)
					-- the first tooltip's body can arrive late too, and a
					-- freshly laid-out line comes back in its own colour
					if untaken then GreyTooltip(_G.GameTooltip) end
				end
			end)
		elseif tip2 then
			tip2:Hide()
		end
	end)
	n:SetScript('OnLeave', function()
		_G.GameTooltip:Hide()
		if tip2 then tip2:Hide() end
	end)
	nodePool[i] = n
	return n
end

local function AcquireEdge(i)
	local e = edgePool[i]
	if e then return e end
	e = panel.content:CreateTexture(nil, 'BORDER')
	e:SetColorTexture(1, 1, 1, 1)
	edgePool[i] = e
	return e
end

local function ReleaseAll()
	for i = 1, numNodes do nodePool[i]:Hide() end
	for i = 1, numEdges do edgePool[i]:Hide() end
	numNodes, numEdges = 0, 0
end

----------------------------------------------------------------------
-- Trait data
----------------------------------------------------------------------
-- Trait coordinates -> half-tile grid, plus which tree the node belongs to.
-- The doubled negation matters: it controls which way .5 values round.
local function NormalizePos(posX, posY)
	local ix = floor(posX / GRID_UNIT + 0.5)
	local iy = -floor(-posY / GRID_UNIT + 0.5)
	ix, iy = ix * 0.5, iy * 0.5
	local isLeft
	if ix >= SPEC_SPLIT then ix = ix - 2 else isLeft = true end
	return ix, iy, isLeft
end

-- Hero-talent nodes must NOT go through NormalizePos. Their raw posX sits near
-- the class/spec boundary, so the SPEC_SPLIT correction above fires for some
-- nodes of a hero tree and not others, tearing the column apart by two
-- half-tiles. Blizzard instead rebases them on their own subtree's centre
-- (TalentFrameUtil.GetNormalizedSubTreeNodePosition), which is what this does:
-- coordinates come out relative to the subtree, so x=0 IS the centre column and
-- single-node rows land centred for free.
local subTreeCache = {}
local function SubTreeInfo(subTreeID)
	if subTreeCache[subTreeID] ~= nil then return subTreeCache[subTreeID] or nil end
	local CT = _G.C_Traits
	local info
	if CT and CT.GetSubTreeInfo then
		local ok, si = pcall(CT.GetSubTreeInfo, INSPECT_CONFIG_ID, subTreeID)
		if ok and type(si) == 'table' then info = si end
	end
	subTreeCache[subTreeID] = info or false
	return info
end

local function NormalizeHeroPos(posX, posY, subTreeID)
	local si = SubTreeInfo(subTreeID)
	local ox = (si and si.posX) or 0
	local oy = (si and si.posY) or 0
	local ix = floor((posX - ox) / GRID_UNIT + 0.5) * 0.5
	local iy = -floor(-(posY - oy) / GRID_UNIT + 0.5) * 0.5
	return ix, iy
end

-- entryID -> icon, spellID
local function EntryVisual(configID, entryID)
	if not entryID or secret(entryID) then return nil, nil end
	local ok, entryInfo = pcall(_G.C_Traits.GetEntryInfo, configID, entryID)
	if not (ok and type(entryInfo) == 'table' and entryInfo.definitionID) then return nil, nil end
	local ok2, def = pcall(_G.C_Traits.GetDefinitionInfo, entryInfo.definitionID)
	if not (ok2 and type(def) == 'table') then return nil, nil end
	local spellID = def.spellID
	if secret(spellID) then return nil, nil end
	local icon = def.overrideIcon
	if (not icon) and spellID and _G.C_Spell and _G.C_Spell.GetSpellTexture then
		local ok3, tex = pcall(_G.C_Spell.GetSpellTexture, spellID)
		if ok3 and not secret(tex) then icon = tex end
	end
	return icon or 134400, spellID
end

-- APEX / capstone nodes.
--
-- Blizzard marks these on the ENTRY, not the node: SpendCapstoneCircle and
-- SpendCapstoneSquare are the two entry types drawn with the big ornate plate
-- at the bottom of a tree. Reading the enum beats guessing "bottom row",
-- which breaks the moment a tree has a short branch.
local CAPSTONE_TYPES = {}
do
	local E = _G.Enum and _G.Enum.TraitNodeEntryType
	CAPSTONE_TYPES[(E and E.SpendCapstoneCircle) or 13] = true
	CAPSTONE_TYPES[(E and E.SpendCapstoneSquare) or 14] = true
end

local function IsApexNode(configID, nodeInfo)
	local function isCapstone(entryID)
		if not entryID or secret(entryID) then return false end
		local ok, info = pcall(_G.C_Traits.GetEntryInfo, configID, entryID)
		if not (ok and type(info) == 'table') then return false end
		local t = info.type
		if secret(t) then return false end
		return CAPSTONE_TYPES[t] == true
	end
	local active = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID
	if isCapstone(active) then return true end
	-- an unspent apex has no active entry, so fall back to its options
	local ids = nodeInfo.entryIDs
	if type(ids) == 'table' then
		for _, e in ipairs(ids) do
			if isCapstone(e) then return true end
		end
	end
	return false
end

local function NodeIcon(configID, nodeInfo)
	local entryID = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID
	-- an unspent node still has entries; show the first so the tree reads as a
	-- tree rather than a field of question marks
	if not entryID then
		entryID = nodeInfo.entryIDs and nodeInfo.entryIDs[1]
	end
	return EntryVisual(configID, entryID)
end

-- For a pick-one-of-two node, resolve BOTH options.
--
-- `opts` holds both halves in entry order and is always populated - that is what
-- lets an UNTAKEN choice node show both tooltips and both icons. The earlier
-- version only kept picked + "first one that isn't picked", so on an untaken
-- node (where nothing is picked) the second entry was silently dropped.
local function ChoiceSpells(configID, nodeInfo)
	local ids = nodeInfo.entryIDs
	if type(ids) ~= 'table' or #ids < 2 then return nil end
	local chosen = nodeInfo.activeEntry and nodeInfo.activeEntry.entryID
	-- a secret entryID would blow up the == below, so treat it as "no pick"
	if secret(chosen) then chosen = nil end

	local opts, picked, other = {}, nil, nil
	for _, entryID in ipairs(ids) do
		local icon, spellID = EntryVisual(configID, entryID)
		if spellID then
			opts[#opts + 1] = { id = spellID, icon = icon }
			if chosen and entryID == chosen then picked = spellID
			elseif not other then other = spellID end
		end
	end
	if #opts == 0 then return nil end

	-- Ask the client to fetch the descriptions NOW, while we are building the
	-- tree. A spell whose data is not cached yet renders a header with no body,
	-- which is why the second tooltip used to look empty until you hovered it a
	-- second time - by then the first hover had triggered the fetch.
	if _G.C_Spell and _G.C_Spell.RequestLoadSpellData then
		for _, o in ipairs(opts) do pcall(_G.C_Spell.RequestLoadSpellData, o.id) end
	end
	return { picked = picked, other = other, opts = opts }
end

-- Collect every drawable node for the inspected build.
local function GatherNodes()
	local CT = _G.C_Traits
	if not (CT and CT.GetConfigInfo and CT.GetTreeNodes and CT.GetNodeInfo) then
		buildErr = 'C_Traits API missing'
		return nil
	end
	local ok, configInfo = pcall(CT.GetConfigInfo, INSPECT_CONFIG_ID)
	if not (ok and type(configInfo) == 'table' and configInfo.treeIDs) then
		buildErr = 'no inspect config (data not ready)'
		return nil
	end
	local treeID = configInfo.treeIDs[1]
	if not treeID then buildErr = 'config has no tree' return nil end

	local ok2, nodeIDs = pcall(CT.GetTreeNodes, treeID)
	if not (ok2 and type(nodeIDs) == 'table') then buildErr = 'GetTreeNodes failed' return nil end

	-- The cache holds isActive, which is a property of the CURRENT inspect
	-- config - it differs per target, so it must not survive into the next one.
	wipe(subTreeCache)

	local all, byID = {}, {}
	local heroActive = {} -- subTreeID -> true, straight from node.subTreeActive
	local heroRanks = {}  -- subTreeID -> ranks spent, for the fallbacks below
	for _, nodeID in ipairs(nodeIDs) do
		local okN, info = pcall(CT.GetNodeInfo, INSPECT_CONFIG_ID, nodeID)
		if okN and type(info) == 'table' and info.isVisible
			and info.type ~= 3 and info.posY and info.posY >= 0 then
			local sub0 = info.subTreeID
			if secret(sub0) then sub0 = nil end
			local ix, iy, isLeft
			if sub0 then
				ix, iy = NormalizeHeroPos(info.posX or 0, info.posY or 0, sub0)
			else
				ix, iy, isLeft = NormalizePos(info.posX or 0, info.posY or 0)
			end
			local icon, spellID = NodeIcon(INSPECT_CONFIG_ID, info)
			local ranks = info.ranksPurchased or 0
			local activeRank = info.activeRank or 0
			local sub = sub0
			-- subTreeActive answers "is this node's hero tree the chosen one?"
			-- per node, with no extra API call - authoritative where our old
			-- points-spent guess was only a heuristic
			local subActive = info.subTreeActive
			if secret(subActive) then subActive = nil end
			-- Enum.TraitNodeType.Selection == 2: a pick-one-of-two node
			local isChoice = (info.type == 2)
			local rec = {
				id = nodeID,
				ix = ix, iy = iy,
				isLeft = isLeft,
				subTree = sub,
				isHero = sub and true or false,
				isChoice = isChoice,
				isApex = IsApexNode(INSPECT_CONFIG_ID, info),
				-- resolved here rather than on hover: GetEntryInfo on the inspect
				-- config is only reliable while the inspect data is live
				choice = isChoice and ChoiceSpells(INSPECT_CONFIG_ID, info) or nil,
				icon = icon, spellID = spellID,
				active = (ranks > 0 or activeRank > 0),
				rank = max(ranks, activeRank),
				maxRank = info.maxRanks or 1,
				edges = info.visibleEdges,
			}
			if sub then
				if subActive then heroActive[sub] = true end
				if rec.active then
					heroRanks[sub] = (heroRanks[sub] or 0) + max(ranks, activeRank)
				end
			end
			all[#all + 1] = rec
			byID[nodeID] = rec
		end
	end
	if #all == 0 then buildErr = 'tree returned no nodes' return nil end

	-- Only ONE hero tree can be active, but the config exposes the nodes of
	-- every hero tree available to the spec - which is why all of them were
	-- being drawn. Three sources, best first:
	--   1. node.subTreeActive, set per node by the client
	--   2. GetSubTreeInfo(...).isActive
	--   3. whichever subtree actually has points spent
	local activeSub
	for sub in pairs(heroActive) do activeSub = sub break end
	if not activeSub then
		local seen = {}
		for _, rec in ipairs(all) do
			if rec.subTree and not seen[rec.subTree] then
				seen[rec.subTree] = true
				local si = SubTreeInfo(rec.subTree)
				if si and si.isActive then activeSub = rec.subTree break end
			end
		end
	end
	if not activeSub then
		local best = 0
		for sub, n in pairs(heroRanks) do
			if n > best then best, activeSub = n, sub end
		end
	end

	local out = {}
	for _, rec in ipairs(all) do
		if (not rec.isHero) or rec.subTree == activeSub then
			out[#out + 1] = rec
		else
			byID[rec.id] = nil -- so edges never point at a dropped hero node
		end
	end

	buildErr = nil
	return out, byID
end

----------------------------------------------------------------------
-- Layout + draw
----------------------------------------------------------------------
local HEADER = 24

local function Draw(nodes, byID)
	local cfg = GetCfg()
	EnsurePanel()
	ReleaseAll()

	-- normalize each tree's grid independently so both columns start at 0
	local L = { minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9 }
	local R = { minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9 }
	local H = { minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9 }
	local function acc(t, n)
		t.minX = min(t.minX, n.ix) t.maxX = max(t.maxX, n.ix)
		t.minY = min(t.minY, n.iy) t.maxY = max(t.maxY, n.iy)
	end
	local anyApex = false
	for _, n in ipairs(nodes) do
		acc(n.isHero and H or (n.isLeft and L or R), n)
		if n.isApex then anyApex = true end
	end

	local sz = cfg.nodeSize or 26
	local pad = 10
	-- Icon size and grid step are separate: sz stays the icon, pitch is how far
	-- apart the slots sit, so spacing spreads the tree without inflating the art.
	local pitch = sz + (cfg.spacing or 0)
	-- lines get thicker with the icons, otherwise a 44px tree is joined by hairlines
	local edgeW = max(2, floor(sz / 10 + 0.5))
	-- Border thickness. The border is a texture filling the whole button, so the
	-- icon has to be inset by this much on every side or it covers the border
	-- completely and the node reads as borderless.
	local borderW = max(1, floor(sz / 12 + 0.5))

	local function span(t) return (t.maxX > -1e8) and (t.maxX - t.minX + 1) or 0 end
	local lSpan, rSpan, hSpan = span(L), span(R), span(H)

	-- A column of N slots is NOT N*pitch wide. Nodes are placed at their TOPLEFT
	-- every `pitch` apart, so the last one contributes only its own width - the
	-- trailing gap does not exist. Sizing by N*pitch padded the right-hand edge
	-- by an extra (spacing + 2) versus the left, which is the uneven margin, and
	-- did the same to both sides of the hero column.
	local iconW = sz - 2
	local function colW(n) return (n > 0) and ((n - 1) * pitch + iconW) or 0 end
	local lW, hW, rW = colW(lSpan), colW(hSpan), colW(rSpan)

	-- one shared value, so class->hero and hero->spec are identical
	local colGap = pad * 2

	local leftX = pad
	local heroX = leftX + lW + colGap
	local rightX = (hW > 0) and (heroX + hW + colGap) or (leftX + lW + colGap)

	-- Hero rows are not all the same width - the entry and capstone rows hold a
	-- single node while the middle rows hold two - so laying every row out from
	-- the column's shared minX left-aligns the odd ones. Shift each row by the
	-- difference between the column centre and its own centre instead. Row spans
	-- are never wider than the column, so this can never push a node outside it.
	local heroShift = {}
	if hSpan > 0 then
		local rowMin, rowMax = {}, {}
		for _, n in ipairs(nodes) do
			if n.isHero then
				local r = n.iy
				if rowMin[r] == nil or n.ix < rowMin[r] then rowMin[r] = n.ix end
				if rowMax[r] == nil or n.ix > rowMax[r] then rowMax[r] = n.ix end
			end
		end
		local colMid = (H.minX + H.maxX) * 0.5
		for r, lo in pairs(rowMin) do
			heroShift[r] = colMid - (lo + rowMax[r]) * 0.5
		end
	end

	-- shared baseline for the two main trees (see the note at placement)
	local lrMinY = min(L.minY, R.minY)
	if lrMinY > 1e8 then lrMinY = 0 end

	local maxRows = 0
	if L.maxY > -1e8 then maxRows = max(maxRows, L.maxY - lrMinY + 1) end
	if R.maxY > -1e8 then maxRows = max(maxRows, R.maxY - lrMinY + 1) end
	if H.maxY > -1e8 then maxRows = max(maxRows, H.maxY - H.minY + 1) end

	-- same correction vertically: the bottom row adds its icon height, not a
	-- whole pitch, so the bottom margin now matches the top one
	local width = rightX + rW + pad
	-- an apex hangs its rank BELOW the icon, so the bottom margin has to grow
	-- or the last row's text would be clipped by the panel edge
	local height = HEADER + pad + colW(maxRows) + pad + EXTRA_H + (anyApex and 9 or 0)
	-- the panel sizes itself around the tree it just laid out
	panel:SetSize(width, height)
	panel.content:SetSize(width, height)

	-- place nodes
	for _, n in ipairs(nodes) do
		numNodes = numNodes + 1
		local b = AcquireNode(numNodes)
		b:SetSize(sz - 2, sz - 2)
		local t = n.isHero and H or (n.isLeft and L or R)
		local baseX = n.isHero and heroX or (n.isLeft and leftX or rightX)
		local col = n.ix - t.minX + (n.isHero and (heroShift[n.iy] or 0) or 0)
		local x = baseX + col * pitch
		-- Class and spec rows share ONE vertical baseline. Measuring each side
		-- from its own minY shears the halves apart whenever their topmost
		-- occupied rows differ, so tiers that should line up don't. The hero
		-- column keeps its own baseline - its coordinates are relative to the
		-- subtree, so it shares no origin with the two main trees.
		local rowBase = n.isHero and H.minY or lrMinY
		local y = -(HEADER + (n.iy - rowBase) * pitch + pad)
		b:ClearAllPoints()
		b:SetPoint('TOPLEFT', panel.content, 'TOPLEFT', x, y)

		b.spellID = n.spellID
		-- named choiceSpells, not choice: b.choice is the arrow TEXTURE on the
		-- button, while n.choice is the {picked, other, opts} spell record
		b.choiceSpells = n.choice

		-- An untaken choice node shows BOTH options, split down the middle:
		-- left half of option one on the left, right half of option two on the
		-- right. Cropping each source icon to its own half (rather than
		-- squashing the whole icon) keeps the art at its natural proportions.
		local opts = n.choice and n.choice.opts
		local splitIcons = (not n.active) and n.isChoice and opts and #opts >= 2
		if splitIcons then
			-- inset by borderW on every side so the coloured border stays visible
			local halfW = ((sz - 2) - borderW * 2) * 0.5
			b.icon:ClearAllPoints()
			b.icon:SetPoint('TOPLEFT', b, 'TOPLEFT', borderW, -borderW)
			b.icon:SetPoint('BOTTOMLEFT', b, 'BOTTOMLEFT', borderW, borderW)
			b.icon:SetWidth(halfW)
			b.icon:SetTexture(opts[1].icon or 134400)
			b.icon:SetTexCoord(0.08, 0.50, 0.08, 0.92)

			b.icon2:ClearAllPoints()
			b.icon2:SetPoint('TOPRIGHT', b, 'TOPRIGHT', -borderW, -borderW)
			b.icon2:SetPoint('BOTTOMRIGHT', b, 'BOTTOMRIGHT', -borderW, borderW)
			b.icon2:SetWidth(halfW)
			b.icon2:SetTexture(opts[2].icon or 134400)
			b.icon2:SetTexCoord(0.50, 0.92, 0.08, 0.92)
			b.icon2:Show()
			b.split:Show()
		else
			b.icon:ClearAllPoints()
			b.icon:SetPoint('TOPLEFT', b, 'TOPLEFT', borderW, -borderW)
			b.icon:SetPoint('BOTTOMRIGHT', b, 'BOTTOMRIGHT', -borderW, borderW)
			b.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
			b.icon:SetTexture(n.icon or 134400)
			b.icon2:Hide()
			b.split:Hide()
		end

		-- choice-node art: Blizzard's octagon + arrow, gold when taken. The
		-- atlas draws its own frame, so our flat border is suppressed for
		-- these rather than doubling up.
		-- choice marker: arrow under the icon's bottom edge. Sized off the icon
		-- but with a floor, so it stays legible at small node sizes.
		if n.isChoice then
			local m = max(9, floor(sz * 0.42 + 0.5))
			b.choiceBg:SetSize(m + 4, m + 1)
			b.choice:SetSize(m, m)
			if n.active then
				b.choice:SetVertexColor(1, 0.82, 0.10, 1)
				b.choiceBg:SetColorTexture(0, 0, 0, 0.85)
			else
				b.choice:SetVertexColor(0.75, 0.75, 0.78, 0.95)
				b.choiceBg:SetColorTexture(0, 0, 0, 0.7)
			end
			b.choiceBg:Show()
			b.choice:Show()
		else
			b.choice:Hide()
			b.choiceBg:Hide()
		end
		local function SetBorder(r, g, bl)
			b.border:SetColorTexture(r, g, bl, 1)
		end

		-- both halves get the same treatment, or a split node would show one
		-- bright half and one dim one
		local function Tint(desat, r, g, bl)
			b.icon:SetDesaturated(desat)
			b.icon:SetVertexColor(r, g, bl)
			if b.icon2:IsShown() then
				b.icon2:SetDesaturated(desat)
				b.icon2:SetVertexColor(r, g, bl)
			end
		end

		-- APEX ONLY: the rank goes UNDER the icon, centred.
		--
		-- Apex art is a deep blue plate and the rank text is blue, so in the
		-- bottom-right corner the number vanishes into the icon. Below it, it
		-- sits on the panel background and reads cleanly. Safe to hang outside
		-- the button because an apex is always the last row of its tree.
		b.rank:ClearAllPoints()
		if n.isApex then
			b.rank:SetPoint('TOP', b, 'BOTTOM', 0, -1)
		else
			b.rank:SetPoint('BOTTOMRIGHT', 2, -2)
		end

		if n.active then
			Tint(false, 1, 1, 1)
			SetBorder(0.95, 0.75, 0.10) -- gold = taken
			b:SetAlpha(1)
			if n.maxRank and n.maxRank > 1 then
				b.rank:SetText(n.rank .. '/' .. n.maxRank)
				b.rankText = 'Rank ' .. n.rank .. ' / ' .. n.maxRank
			else
				b.rank:SetText('')
				b.rankText = nil
			end
		else
			Tint(true, 0.45, 0.45, 0.45)
			SetBorder(0.18, 0.18, 0.20)
			b:SetAlpha(1)
			b.rank:SetText('')
			b.rankText = nil
		end
		-- untaken nodes are hidden outright when the option is off; the layout
		-- still reserved their grid slot, so the tree keeps its shape
		b:SetShown(cfg.showInactive or n.active)
		n.px, n.py = x + (sz - 2) * 0.5, y - (sz - 2) * 0.5
	end

	-- connector lines: a flat texture stretched between node centres and rotated
	-- to match. With spacing the
	-- lines are long enough to matter, so they run at edgeW thick and light
	-- enough to actually read against the panel background.
	for _, n in ipairs(nodes) do
		if n.edges and n.px and (cfg.showInactive or n.active) then
			for _, ev in ipairs(n.edges) do
				local target = ev and ev.targetNode and byID[ev.targetNode]
				if target and target.px and (cfg.showInactive or target.active) then
					local x1, y1, x2, y2 = n.px, n.py, target.px, target.py
					local dx, dy = x2 - x1, y2 - y1
					local d = sqrt(dx * dx + dy * dy)
					if d > 1 then
						numEdges = numEdges + 1
						local e = AcquireEdge(numEdges)
						e:SetSize(d, edgeW)
						e:ClearAllPoints()
						e:SetPoint('CENTER', panel.content, 'TOPLEFT', (x1 + x2) * 0.5, (y1 + y2) * 0.5)
						e:SetRotation(atan2(dy, dx))
						if n.active and target.active then
							e:SetColorTexture(1, 0.82, 0.20, 1)
						else
							e:SetColorTexture(0.46, 0.46, 0.52, 0.9)
						end
						e:Show()
					end
				end
			end
		end
	end

	panel.bg:SetAlpha(cfg.bgAlpha or 0.92)
	return width, height
end

----------------------------------------------------------------------
-- Anchoring + show/hide
----------------------------------------------------------------------
-- Anchor the panel beside the inspect sheet by its TOP corner only. Its height
-- comes from the tree it drew, not from the sheet.
--
-- (An earlier version pinned top AND bottom so the two frames matched exactly,
-- and scaled the tree to fit. Reverted by request: it made the icon-size slider
-- indirect, since the scale-to-fit then decided the real size.)
local function Anchor()
	local cfg = GetCfg()
	local ins = ISF()
	if not (panel and ins) then return end
	panel:ClearAllPoints()
	local gap, y = cfg.gap or 4, cfg.yOffset or 0
	if (cfg.side or 'RIGHT') == 'LEFT' then
		panel:SetPoint('TOPRIGHT', ins, 'TOPLEFT', -gap, y)
	else
		panel:SetPoint('TOPLEFT', ins, 'TOPRIGHT', gap, y)
	end
	panel:SetScale(ins:GetScale() or 1)
end

local function Refresh()
	local cfg = GetCfg()
	if not cfg.enable then if panel then panel:Hide() end return end
	local ins = ISF()
	if not (ins and ins:IsShown()) then if panel then panel:Hide() end return end

	EnsurePanel()
	local nodes, byID = GatherNodes()
	if not nodes then
		-- data has not landed yet; show the frame with a note rather than
		-- flashing an empty box
		panel:SetSize(220, 80)
		panel.note:SetText(buildErr or 'waiting for talent data...')
		panel.note:Show()
		ReleaseAll()
		Anchor()
		panel:Show()
		return
	end
	panel.note:Hide()
	Draw(nodes, byID)

	local unit = ins.unit
	lastUnit = unit
	local name = unit and _G.UnitName(unit) or ''
	local specName
	local specID = unit and _G.GetInspectSpecialization and _G.GetInspectSpecialization(unit)
	if specID and specID ~= 0 and _G.GetSpecializationInfoByID then
		local _, sn = _G.GetSpecializationInfoByID(specID)
		if type(sn) == 'string' then specName = sn end
	end
	panel.title:SetText(name ~= '' and name or 'Talents')
	panel.specText:SetText(specName or '')

	Anchor()
	panel:Show()
end
ns.INSApply = Refresh

----------------------------------------------------------------------
-- Drivers
--
-- Blizzard shows the inspect sheet FROM its own INSPECT_READY handler, so a
-- handler registered before theirs sees the sheet still hidden. We therefore
-- refresh on the sheet's OnShow as well, and again on INSPECT_READY - the
-- trait data for config -1 is populated by then, which is what makes the tree
-- appear with the sheet instead of a second later.
----------------------------------------------------------------------
local function HookInspectFrame()
	local ins = ISF()
	if not ins or ins.__kiraINSHooked then return end
	ins.__kiraINSHooked = true
	ins:HookScript('OnShow', Refresh)
	ins:HookScript('OnHide', function() if panel then panel:Hide() end end)
	-- the sheet is re-anchored by the panel manager after it shows
	ins:HookScript('OnSizeChanged', function()
		if panel and panel:IsShown() then Anchor() end
	end)
end

local evt = CreateFrame('Frame')
evt:RegisterEvent('ADDON_LOADED')
evt:RegisterEvent('INSPECT_READY')
evt:SetScript('OnEvent', function(_, event, arg1)
	if event == 'ADDON_LOADED' then
		if arg1 == 'Blizzard_InspectUI' then HookInspectFrame() end
		return
	end
	-- INSPECT_READY
	local ins = ISF()
	if ins and ins:IsShown() then Refresh() end
end)
HookInspectFrame()

----------------------------------------------------------------------
-- Diagnostic
----------------------------------------------------------------------
_G.SLASH_KIRAINSPECT1 = '/kirainspect'
_G.SlashCmdList.KIRAINSPECT = function()
	local function p(...) print('|cffff7d0aKiraInspect|r', ...) end
	local cfg = GetCfg()
	local ins = ISF()
	p('enabled:', cfg.enable and 'yes' or 'NO', '| side:', cfg.side,
		'| node size:', cfg.nodeSize, '| spacing:', cfg.spacing)
	p('InspectFrame:', ins and (ins:IsShown() and 'shown' or 'loaded, hidden') or 'NOT LOADED',
		'| unit:', ins and tostring(ins.unit) or '-')
	p('panel:', panel and (panel:IsShown() and 'shown' or 'hidden') or 'not built',
		'| nodes drawn:', numNodes, '| edges:', numEdges)
	local CT = _G.C_Traits
	local ok, ci = pcall(CT and CT.GetConfigInfo or function() end, INSPECT_CONFIG_ID)
	p('inspect config(-1):', (ok and type(ci) == 'table') and ('tree ' .. tostring(ci.treeIDs and ci.treeIDs[1])) or 'UNAVAILABLE')
	if buildErr then p('|cffff5555last build error:|r', buildErr) end
	p('last unit drawn:', tostring(lastUnit))
end
