--------------------------------------------------------------------## Author: zecmo ## Version: 2.1 ## SavedVariables: rtOptions, rtSettings 
-- RandomToysConfig.lua -- shipped set catalog, descriptions, groupings,
-- and the stateless set/tag helper functions. Constants and cross-file
-- helpers are published on the shared private table RT.
local RT = {}

local rtDebug = false

-- true : curating shipped defaults. The grid's checkbox toggles LIST
--        membership rather than pool/active state, and filters to
--        whatever set is selected same as normal mode -- select All
--        (its list is forced true for every toy) to browse the full
--        collection and check new toys into a category; select any
--        other set to review/prune just what's already in its list.
-- false: normal use. The grid's checkbox toggles pool/active state,
--        filtered to toys already checked into the selected category
--        -- i.e. the list you've settled on. Unchecking one still
--        removes it from that category (and it disappears from view,
--        since it's no longer in the list).
local rtConfigMode = false

--------------------------------------------------------------------
-- Macro Categories
--   The original shipped sets. Any set can maintain a macro now, so
--   this list's remaining jobs are save-file migration and seeding the
--   defaults for a fresh install. ["Any" the derived grab-bag pseudo-set
--   lives outside this list, like "All" -- see GetAnyToyIds -- since
--   neither ever stores per-toy list/pool data of its own.]
--------------------------------------------------------------------

local CATEGORIES = {"Full", "Fight", "Fragile", "Flair", "Props", "Incap", "All"}

-- Fill these in with whatever blurb you want shown in each set's rail
-- hover tooltip -- every shipped tag (RT_DEFAULT_TAGS below) can have
-- an entry here --
local CATEGORY_DESCRIPTIONS = {
	Full = "完整 - 战斗和骑乘时都能保持变身",
	Fight = "战斗 - 战斗不会打断，但骑乘时不可见",
	Fragile = "易碎 - 骑乘、战斗、受伤……乃至移动都会中断",
	Flair = "装扮 - 穿戴、携带或附着在角色身上的物品",
	Props = "道具 - 携带/放置的物品、召唤物及其他效果",
	Incap = "高风险 - 会使角色失能或失去控制的玩具",
	All = "全部 - 自定义你收藏中的每一个玩具！",
	Any = "混合池 - 自动汇集当前所有启用集合中的玩具",
	Favs = "收藏 - 仅显示已标记收藏的玩具",

	-- 新增内置标签的占位说明 —— 可自由编辑 ——
	Ports = "传送门 - 传送以及各种前往他处的方式",
	Hearth = "炉石 - 将你送回家的玩具",
	Pets = "伙伴 - 战斗宠物和小动物主题玩具",
	Corpse = "亡者 - 缅怀或嘲讽被击杀的玩家",
	Zone = "区域 - 仅与特定区域相关的玩具",
	Lures = "钓鱼 - 鱼饵、浮标和诱饵",
	Eng = "工程学 - 装置、机械和工程玩具",
	Rafts = "涉水 - 木筏及各种渡水方式",
	Friends = "社交 - 对其他玩家使用或与其共享的玩具",
	Fires = "篝火 - 烹饪火堆和温暖的火焰",
	Banner = "旗帜 - 军旗、旗帜和三角旗",
	Summon = "召唤 - 可召唤的 NPC、商人和助手",
	Mail = "邮箱 - 便携邮箱访问",
	Maps = "飞行点 - 更轻松地解锁飞行点",
	Placed = "地面放置 - 需要再次点击才能放到地面的玩具",

	-- 最新一批内置标签的占位说明 —— 可自由编辑 ——
	Hats = "头饰 - 任何戴在头上的物品",
	Fall = "缓降 - 减缓或消除坠落伤害",
	Noisy = "音效 - 播放声音或乐曲的玩具",
	Firework = "烟花 - 烟火表演和火花棒",
	Seat = "座椅 - 椅子、坐垫及可坐之处",
	Dummy = "训练假人 - 沙袋和练习假人",
	Clone = "复制 - 复制你或其他目标的玩具",
	Critter = "小动物 - 召唤或击杀非战斗伙伴",
	Remote = "遥控 - 在一旁遥控载具",
	Mount = "坐骑主题 - 瞄准你的坐骑试试看！",
	Snacks = "餐点 - 各类食物和饮品",
	Brew = "酿造节 - 酒桶、酒杯和酿造节狂欢",
	React = "互动 - 触发预设回应的玩具",
	Rings = "戒指 - 戴在手上的饰品",
}

-- Short tags shown on each toy cell for categories it's already in --
local CATEGORY_ABBR = {
	Full = "Fu",
	Fight = "Fi",
	Fragile = "Fr",
	Flair = "Fl",
	Props = "Pr",
	Incap = "In",
	All = "Al",
}

-- Add an old->new pair here whenever you rename an entry in CATEGORIES,
-- so existing per-toy list/pool data, the categoryEnabled flag, and the
-- category's macro carry over instead of resetting. Safe to leave old
-- entries in permanently -- once migrated there's nothing left under
-- the old name to move. Point every historical name straight at its
-- current final name (not at an intermediate old name) so a save file
-- that skips several versions still lands in the right place.
local CATEGORY_RENAMES = {
	Accessory = "Flair",
	External = "Props",
	Semi = "Fight",
	Clothes = "Flair",
}

--------------------------------------------------------------------
-- Tags [the left rail]
--   The shipped categories double as shipped tags, listed in this
--   order. "All" leads as the browse-everything view. Users add
--   their own custom tags after these (rtSettings.customTags), which
--   reuse the same per-toy {list, pool} data shape as categories --
--   list = "has the tag", pool = active in that tag's rotation.
--------------------------------------------------------------------

-- "No Set" is a pure filter view, not a set: it matches toys belonging
-- to no set at all (shipped or custom), the complement of every other
-- filter. Nothing is ever stored under this name -- mainly a
-- debug/triage view for finding toys that slipped through curation --
local UNTAGGED = "No Set"

-- The shipped tag list, in rail display order. Lives here (not in
-- RandomToysDefaults.lua, which only carries per-toy memberships)
-- so each tag's description in CATEGORY_DESCRIPTIONS above sits next
-- to it. Shipping a new tag: add its name here, give it a blurb above,
-- and paste its toy memberships into the defaults file (the Export
-- Defaults button produces both) --
RT_DEFAULT_TAGS = {
	"Full",
	"Fight",
	"Fragile",
	"Flair",
	"Props",
	"Incap",
	"Ports",
	"Hearth",
	"Pets",
	"Corpse",
	"Zone",
	"Lures",
	"Eng",
	"Rafts",
	"Friends",
	"Fires",
	"Banner",
	"Summon",
	"Mail",
	"Maps",
	"Placed",
	"Hats",
	"Fall",
	"Noisy",
	"Firework",
	"Seat",
	"Dummy",
	"Clone",
	"Critter",
	"Remote",
	"Mount",
	"Snacks",
	"Brew",
	"React",
	"Rings",
}

-- Grouping/order for the shipped sets as they appear in the two popups
-- (the Sets menu's "Default Sets" section, and a rail row's right-click
-- "Inherit" flyout) -- edit this table by hand to reorder groups,
-- reorder tags within a group, rename a header, or move a tag to a
-- different group. This is popup-only: the rail itself still lists
-- shipped sets in RT_DEFAULT_TAGS order, untouched by this table.
-- Every RT_DEFAULT_TAGS entry must appear exactly once somewhere below
-- -- ValidateDefaultSetGroups (right after this table) warns on load if
-- one's missing, duplicated, or misspelled --
local DEFAULT_SET_GROUPS = {
	{header = "角色扮演", tags = {"Full", "Fight", "Flair", "Fragile", "Hats", "Rings"}},
	{header = "杂物", tags = {"Props", "Placed", "Brew", "Snacks", "Seat", "Remote"}},
	{header = "需要目标", tags = {"Friends", "Pets", "Critter", "Mount", "Dummy","Corpse"}},
	{header = "欢庆", tags = {"Firework", "Noisy", "Summon","Clone", "React"}},
	{header = "旅行", tags = {"Hearth", "Ports", "Rafts", "Fall"}},
	{header = "专业", tags = {"Lures", "Eng", "Fires"}},
	{header = "特殊", tags = {"Mail", "Incap", "Zone", "Maps", "Banner"}},	
}

local function ValidateDefaultSetGroups()
	local seen = {}
	for _, group in ipairs(DEFAULT_SET_GROUPS) do
		for _, tag in ipairs(group.tags) do
			if seen[tag] then
				print('|cffff5555Random Toys:|r DEFAULT_SET_GROUPS 中重复列出了 "' .. tag .. '"。')
			end
			seen[tag] = true
		end
	end
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		if not seen[tag] then
			print('|cffff5555Random Toys:|r DEFAULT_SET_GROUPS 中缺少 "' .. tag .. '"，它不会显示在 Sets/Inherit 弹出菜单中。')
		end
		seen[tag] = nil
	end
	for tag in pairs(seen) do
		print('|cffff5555Random Toys:|r DEFAULT_SET_GROUPS 中列出了 "' .. tag .. '"，但它不是内置集合。')
	end
end
ValidateDefaultSetGroups()

-- Any RT_DEFAULT_TAGS entry not covered by DEFAULT_SET_GROUPS. Normally none
-- (ValidateDefaultSetGroups warns at load if the shipped catalog and the
-- curated grouping table drift apart) -- but a set added at runtime via
-- config mode's "+ New Set" always starts here, since it can't know which
-- group it belongs in. Surfaced in the Sets/Inherit popups as its own
-- section, positioned like Custom Sets, instead of being invisible until a
-- dev manually slots it into DEFAULT_SET_GROUPS and ships it --
function RT.GetUngroupedDefaultTags()
	local grouped = {}
	for _, group in ipairs(DEFAULT_SET_GROUPS) do
		for _, tag in ipairs(group.tags) do
			grouped[tag] = true
		end
	end
	local ungrouped = {}
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		if not grouped[tag] then
			table.insert(ungrouped, tag)
		end
	end
	return ungrouped
end

-- The rail's shipped entries: All (empty filter), then the shipped tags
-- from RT_DEFAULT_TAGS, then Untagged (complement filter). Custom tags
-- follow at runtime.
local RAIL_TAGS = {"All", "Any", "Favs"}
for _, tag in ipairs(RT_DEFAULT_TAGS) do
	table.insert(RAIL_TAGS, tag)
end
table.insert(RAIL_TAGS, UNTAGGED)

-- Sets visible in the rail on a fresh install (or the first load after
-- the Sets filter menu shipped). All and No Set stay visible as core
-- navigation; Any starts hidden like every other shipped set. Every
-- other shipped set starts hidden until checked in the Sets menu --
local DEFAULT_VISIBLE_SETS = {"All", "Full", "Fight", "Flair", "Friends", "Fires", "Lures", "Hearth", "Mail", UNTAGGED}

-- Names a custom tag can never take: every shipped category and rail
-- pseudo-entry, plus the historical names in CATEGORY_RENAMES --
-- MigrateCategoryRenames would silently swallow a custom tag named
-- after an old key on next login --
local RESERVED_TAG_NAMES = {}
for _, cat in ipairs(CATEGORIES) do RESERVED_TAG_NAMES[cat:lower()] = true end
for _, tag in ipairs(RAIL_TAGS) do RESERVED_TAG_NAMES[tag:lower()] = true end
for oldName in pairs(CATEGORY_RENAMES) do RESERVED_TAG_NAMES[oldName:lower()] = true end

--------------------------------------------------------------------
-- Macro names cap at 16 characters; tag names run to 24. Every place
-- that names, finds, or deletes a set's macro goes through this clamp
-- so long tag names stay consistent everywhere.
--------------------------------------------------------------------
function RT.MacroNameForSet(cat)
	return strsub(cat, 1, 16)
end

function RT.GetCustomTags()
	return (rtSettings and rtSettings.customTags) or {}
end

-- Rail display order for shipped sets, recency-ordered (see
-- ToggleSetVisibility) rather than RT_DEFAULT_TAGS' fixed catalog
-- order. Falls back to that catalog order before saved variables load
-- (RebuildTagRail runs once at file-load time, before ADDON_LOADED) --
function RT.GetShippedOrder()
	return (rtSettings and rtSettings.shippedOrder) or RT_DEFAULT_TAGS
end

-- Custom sets render in Okabe-Ito "sky blue" (colorblind-safe cyan)
-- everywhere they're listed, distinguishing them from shipped sets.
-- Done as an inline color escape so buttons/menus that swap font
-- objects on hover can't clobber it --
function RT.CustomSetLabel(tag)
	return "|cff56B4E9" .. tag .. "|r"
end

-- All and Any (the two derived pseudo-sets) render in the same gold
-- used throughout the default UI, in the rail only -- NORMAL_FONT_COLOR
-- is that "yellow used everywhere" (button text, titles, etc.) --
function RT.PseudoSetLabel(tag)
	return NORMAL_FONT_COLOR:WrapTextInColorCode(tag)
end

function RT.IsShippedTag(tag)
	for _, t in ipairs(RT_DEFAULT_TAGS) do
		if t == tag then return true end
	end
	return false
end

-- Removes tag from list if present, then appends it -- used to send a
-- just-toggled-on set to the bottom of its list (recency ordering) --
function RT.MoveTagToEnd(list, tag)
	for i, t in ipairs(list) do
		if t == tag then
			table.remove(list, i)
			break
		end
	end
	table.insert(list, tag)
end

-- A set unchecked in the Sets menu is out of use entirely: no rail
-- entry, no macro. Config mode ignores the filter -- defaults curation
-- needs every shipped set reachable --
function RT.IsSetHidden(tag)
	return not rtConfigMode and rtSettings and rtSettings.hiddenSets
		and rtSettings.hiddenSets[tag] or false
end

local function rtPrintError(msg)
	print("|cffff5555Random Toys:|r " .. msg)
end

function RT.CheckTagName(name, excluding)
	name = name and strtrim(name) or ""
	if name == "" then return nil end
	if #name > 24 then
		return nil, "集合名称最多 24 个字符。"
	end
	if RESERVED_TAG_NAMES[name:lower()] then
		return nil, '"' .. name .. '" 是内置名称。'
	end
	local function IsExcluded(other)
		return excluding and other:lower() == excluding:lower()
	end
	for _, t in ipairs(RT.GetCustomTags()) do
		if t:lower() == name:lower() and not IsExcluded(t) then
			return nil, '集合 "' .. t .. '" 已存在。'
		end
	end
	-- Set names run to 24 characters but macro names cap at 16, so two sets
	-- agreeing on their first 16 drive the SAME macro (see MacroNameForSet):
	-- each rewrites the other's body on every roll, and switching one off
	-- deletes the macro out from under the other. Nothing downstream can tell
	-- them apart, so the collision has to be refused here --
	local macroName = RT.MacroNameForSet(name)
	for _, list in ipairs({RT_DEFAULT_TAGS, RAIL_TAGS, RT.GetCustomTags()}) do
		for _, t in ipairs(list) do
			if t:lower() ~= name:lower() and not IsExcluded(t)
					and RT.MacroNameForSet(t):lower() == macroName:lower() then
				return nil, 'First 16 characters clash with "' .. t .. '".'
			end
		end
	end
	return name
end

function RT.ValidateNewTagName(name, excluding)
	local valid, reason = RT.CheckTagName(name, excluding)
	if not valid and reason then rtPrintError(reason) end
	return valid
end

function RT.ToyHasTag(toyID, tag)
	local opt = rtOptions[toyID]
	local entry = opt and opt[tag]
	return entry and entry.list and true or false
end

-- True when a toy belongs to no set at all -- RT_DEFAULT_TAGS covers
-- the shipped sets, so "All" (forced onto every toy) doesn't count --
function RT.ToyIsUntagged(opt)
	if not opt then return true end
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		local e = opt[tag]
		if e and e.list then return false end
	end
	for _, tag in ipairs(RT.GetCustomTags()) do
		local e = opt[tag]
		if e and e.list then return false end
	end
	return true
end

-- "Any" is the modern rebirth of the old grab-bag idea: instead of a
-- hardcoded four categories, it unions whichever sets -- All, shipped,
-- or custom -- currently maintain a macro (categoryEnabled), pulling in
-- a toy the moment any one of its enabled sets has it pooled. Fully
-- derived, like "All", so there's nothing of its own to store or
-- curate --
function RT.IsToyInAny(opt)
	if not opt then return false end
	if rtSettings.categoryEnabled["All"] then
		local e = opt["All"]
		if e and e.list and e.pool then return true end
	end
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		if rtSettings.categoryEnabled[tag] then
			local e = opt[tag]
			if e and e.list and e.pool then return true end
		end
	end
	for _, tag in ipairs(RT.GetCustomTags()) do
		if rtSettings.categoryEnabled[tag] then
			local e = opt[tag]
			if e and e.list and e.pool then return true end
		end
	end
	return false
end

function RT.GetAnyToyIds()
	local ids = {}
	for i = 1, #RT.toys do
		local data = RT.toys[i]
		if data.owned and PlayerHasToy(data.id)
				and RT.IsToyInAny(rtOptions[data.id]) then
			table.insert(ids, data.id)
		end
	end
	return ids
end

-- "Favs" is the same idea as "Any" and "No Set": a pure derived filter with
-- nothing of its own to store. It rides straight on Blizzard's own Toy Box
-- favorite flag (C_ToyBox), so starring/unstarring a toy anywhere -- the
-- right-click menu, the Toy Box itself -- is reflected here immediately --
function RT.ToyIsFavorited(id)
	return C_ToyBox.GetIsFavorite(id) and true or false
end

function RT.GetFavToyIds()
	local ids = {}
	for i = 1, #RT.toys do
		local data = RT.toys[i]
		if data.owned and PlayerHasToy(data.id)
				and RT.ToyIsFavorited(data.id) then
			table.insert(ids, data.id)
		end
	end
	return ids
end

--------------------------------------------------------------------
-- Publish shared constants on RT for the other files to localize.
--------------------------------------------------------------------
RT.debug = rtDebug
RT.configMode = rtConfigMode
RT.CATEGORIES = CATEGORIES
RT.CATEGORY_DESCRIPTIONS = CATEGORY_DESCRIPTIONS
RT.CATEGORY_ABBR = CATEGORY_ABBR
RT.CATEGORY_RENAMES = CATEGORY_RENAMES
RT.UNTAGGED = UNTAGGED
RT.DEFAULT_SET_GROUPS = DEFAULT_SET_GROUPS
RT.RAIL_TAGS = RAIL_TAGS
RT.DEFAULT_VISIBLE_SETS = DEFAULT_VISIBLE_SETS
RT.RESERVED_TAG_NAMES = RESERVED_TAG_NAMES


--------------------------------------------------------------------
-- Shipped defaults: which tags a toy starts with.
--
-- The shipped tag list itself (RT_DEFAULT_TAGS) lives in
-- RandomToys.lua, next to the per-tag descriptions -- this file only
-- carries the per-toy memberships.
--
-- Generated with the "Export Defaults" button (Toys tab, config mode):
-- copy/paste its RT_DEFAULT_TOY_LISTS output over the table below, then
-- /reload to pick it up. Only tags already in RT_DEFAULT_TAGS are
-- exported -- custom sets are excluded and stay local to each account.
-- To promote a custom set to shipped: add its exact name to
-- RT_DEFAULT_TAGS (plus a description) in RandomToys.lua and /reload
-- -- its existing toy memberships carry over automatically, since
-- they're stored by tag name either way -- then re-run Export Defaults
-- and paste the result here.
-- Tags beyond the original six categories are tag-only for now -- no
-- macro or Cosplays row until the Sets phase decouples those.
--
-- RT_DEFAULT_TOY_LISTS format: [toyID] = { TagName = true, ... }
-- Only list membership is captured here. On a fresh install (or when a
-- listed toy is first acquired) a shipped entry seeds BOTH list and pool,
-- so the toy is active in rotation out of the box -- see the seeding branch
-- in SyncNewToysIntoOptions. Toys the player later curates keep whatever
-- pool state they set; this default only affects the first-seen seed.
--------------------------------------------------------------------
if GetLocale() == "zhCN" then
  RandomCosplayLocal = "|cff8080ff[幻化]|r随机变幻";
elseif GetLocale() == "zhTW" then
  RandomCosplayLocal = "|cff8080ff[幻化]|r随机变幻";
else
  RandomCosplayLocal = "RandomCosplay";
end

RT_DEFAULT_TOY_LISTS = {
	[1973] = { Full = true }, -- Orb of Deception
	[13379] = { Flair = true, Noisy = true, React = true }, -- Piccolo of the Flaming Fire
	[17716] = { Eng = true }, -- Snowmaster 9000
	[18660] = { Eng = true }, -- World Enlarger
	[18984] = { Ports = true, Eng = true }, -- Dimensional Ripper - Everlook
	[18986] = { Ports = true, Eng = true }, -- Ultrasafe Transporter: Gadgetzan
	[23767] = { Props = true, Remote = true }, -- Crashin' Thrashin' Robot
	[30542] = { Ports = true, Eng = true }, -- Dimensional Ripper - Area 52
	[30544] = { Ports = true, Eng = true }, -- Ultrasafe Transporter: Toshley's Station
	[30690] = { Props = true, Firework = true }, -- Power Converter
	[32542] = { Props = true }, -- Imp in a Ball
	[32566] = { Props = true }, -- Picnic Basket
	[32782] = { Fight = true }, -- Time-Lost Figurine
	[33219] = { Props = true }, -- Goblin Gumbo Kettle
	[33223] = { Props = true, Seat = true }, -- Fishing Chair
	[33927] = { Props = true, Placed = true, Brew = true }, -- Brewfest Pony Keg
	[34480] = { Props = true }, -- Romantic Picnic Basket
	[34499] = { Props = true }, -- Paper Flying Machine Kit
	[34686] = { Fragile = true, Props = true }, -- Brazier of Dancing Flames
	[35227] = { Props = true }, -- Goblin Weather Machine - Prototype 01-B
	[35275] = { Full = true }, -- Orb of the Sin'dorei
	[36862] = { Props = true }, -- Worn Troll Dice
	[36863] = { Props = true }, -- Decahedral Dwarven Dice
	[37254] = { Full = true, Fight = true, Flair = true }, -- Super Simian Sphere
	[37460] = { Pets = true }, -- Rope Pet Leash
	[37710] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Racer Controller
	[38301] = { Props = true, React = true }, -- D.I.S.C.O.
	[38578] = { Corpse = true }, -- The Flag of Ownership
	[40727] = { Eng = true }, -- Gnomish Gravity Well
	[40768] = { Mail = true }, -- MOLL-E
	[40895] = { Eng = true }, -- Gnomish X-Ray Specs
	[43499] = { Fight = true }, -- Iron Boot Flask
	[43824] = { Zone = true }, -- The Schools of Arcane Magic - Mastery
	[44430] = { Props = true }, -- Titanium Seal of Dalaran
	[44606] = { Props = true }, -- Toy Train Set
	[44719] = { Fight = true }, -- Frenzyheart Brew
	[44820] = { Pets = true }, -- Red Ribbon Pet Leash
	[45011] = { Banner = true }, -- Stormwind Banner
	[45013] = { Props = true, Banner = true }, -- Thunder Bluff Banner
	[45014] = { Banner = true }, -- Orgrimmar Banner
	[45015] = { Props = true, Banner = true }, -- Sen'jin Banner
	[45016] = { Props = true, Banner = true }, -- Undercity Banner
	[45017] = { Props = true, Banner = true }, -- Silvermoon City Banner
	[45018] = { Banner = true }, -- Ironforge Banner
	[45019] = { Banner = true }, -- Gnomeregan Banner
	[45020] = { Banner = true }, -- Exodar Banner
	[45021] = { Banner = true }, -- Darnassus Banner
	[45057] = { Props = true }, -- Wind-Up Train Wrecker
	[45063] = { Props = true }, -- Foam Sword Rack
	[45984] = { Props = true }, -- Unusual Compass
	[46709] = { Props = true, Incap = true, Remote = true }, -- MiniZep Controller
	[46780] = { Props = true, Placed = true, Dummy = true }, -- Ogre Pinata
	[46843] = { Props = true, Banner = true, Placed = true }, -- Argent Crusader's Banner
	[48933] = { Ports = true, Eng = true }, -- Wormhole Generator: Northrend
	[49703] = { Props = true, Firework = true }, -- Perpetual Purple Firework
	[50471] = { Props = true }, -- The Heartbreaker
	[52201] = { Full = true }, -- Muradin's Favor
	[52253] = { Props = true, Summon = true, Noisy = true }, -- Sylvanas' Music Box
	[53057] = { Full = true }, -- Faded Wizard Hat
	[54212] = { Incap = true }, -- Instant Statue Pedestal
	[54343] = { Props = true, Incap = true, Remote = true }, -- Blue Crashin' Thrashin' Racer Controller
	[54437] = { Props = true, Placed = true }, -- Tiny Green Ragdoll
	[54438] = { Props = true }, -- Tiny Blue Ragdoll
	[54452] = { Hearth = true }, -- Ethereal Portal
	[60854] = { Eng = true }, -- Loot-A-Rang
	[63141] = { Props = true }, -- Tol Barad Searchlight
	[63269] = { Props = true }, -- Loaded Gnomish Dice
	[64358] = { Full = true, Props = true, Summon = true, Noisy = true, Clone = true }, -- Highborne Soul Mirror
	[64361] = { Props = true }, -- Druid and Priest Statue Set
	[64373] = { Props = true, Summon = true }, -- Chalice of the Mountain Kings
	[64383] = { Props = true, Noisy = true }, -- Kaldorei Wind Chimes
	[64456] = { Props = true, Summon = true }, -- Arrival of the Naaru
	[64457] = { Ports = true }, -- The Last Relic of Argus
	[64481] = { Fragile = true }, -- Blessing of the Old God
	[64482] = { Props = true }, -- Puzzle Box of Yogg-Saron
	[64488] = { Hearth = true }, -- The Innkeeper's Daughter
	[64646] = { Fight = true }, -- Bones of Transformation
	[64651] = { Fragile = true }, -- Wisp Amulet
	[64881] = { Props = true }, -- Pendant of the Scarab Storm
	[64997] = { Props = true }, -- Tol Barad Searchlight
	[66888] = { Fight = true }, -- Stave of Fur and Claw
	[67097] = { Props = true, Fires = true }, -- Grim Campfire
	[68806] = { Full = true }, -- Kalytha's Haunted Locket
	[69215] = { Props = true, Mount = true }, -- War Party Hitching Post
	[69227] = { Props = true }, -- Fool's Gold
	[69775] = { Flair = true }, -- Vrykul Drinking Horn
	[69776] = { Incap = true }, -- Ancient Amber
	[69777] = { Props = true }, -- Haunted War Drum
	[69895] = { Props = true }, -- Green Balloon
	[69896] = { Props = true }, -- Yellow Balloon
	[70159] = { Props = true }, -- Mylune's Call
	[70161] = { Props = true, Seat = true }, -- Mushroom Chair
	[70722] = { Props = true, Fires = true }, -- Little Wickerman
	[71137] = { Props = true, Summon = true, Critter = true, Brew = true }, -- Brewfest Keg Pony
	[71259] = { Full = true }, -- Leyara's Locket
	[71628] = { Props = true }, -- Sack of Starfish
	[72159] = { Fight = true }, -- Magical Ogre Idol
	[72161] = { Incap = true }, -- Spurious Sarcophagus
	[75042] = { Props = true }, -- Flimsy Yellow Balloon
	[79769] = { Full = true, Flair = true }, -- Demon Hunter's Aspect
	[80822] = { Props = true }, -- The Golden Banana
	[82467] = { Props = true }, -- Ruthers' Harness
	[85500] = { Rafts = true }, -- Anglers Fishing Raft
	[86565] = { Zone = true }, -- Battle Horn
	[86568] = { Full = true }, -- Mr. Smite's Brass Compass
	[86571] = { Flair = true }, -- Kang's Bindstone
	[86573] = { Fragile = true, Incap = true }, -- Shard of Archstone
	[86575] = { Flair = true }, -- Chalice of Secrets
	[86578] = { Zone = true, Snacks = true }, -- Eternal Warrior's Sigil
	[86582] = { Zone = true }, -- Aqua Jewel
	[86583] = { Banner = true }, -- Salyin Battle Banner
	[86586] = { Props = true, Noisy = true, Critter = true }, -- Panflute of Pandaria
	[86588] = { Props = true, Firework = true }, -- Pandaren Firework Launcher
	[86589] = { Full = true, Flair = true, Friends = true }, -- Ai-Li's Skymirror
	[86590] = { Zone = true }, -- Essence of the Breeze
	[86593] = { Full = true, Flair = true }, -- Hozen Beach Ball
	[86594] = { Zone = true }, -- Helpful Wikky's Whistle
	[86596] = { Props = true, Seat = true }, -- Nat's Fishing Chair
	[87214] = { Eng = true }, -- Blingtron 4000
	[87215] = { Ports = true, Eng = true }, -- Wormhole Generator: Pandaria
	[87528] = { Flair = true }, -- Honorary Brewmaster Keg
	[88370] = { Props = true }, -- Puntable Marmot
	[88375] = { Props = true, Placed = true, Dummy = true }, -- Turnip Punching Bag
	[88377] = { Friends = true }, -- Turnip Paint "Gun"
	[88381] = { Props = true }, -- Silversage Incense
	[88385] = { Critter = true }, -- Hozen Idol
	[88387] = { Props = true }, -- Shushen's Spittoon
	[88417] = { Fragile = true, Flair = true }, -- Gokk'lok's Shell
	[88531] = { Props = true, Brew = true }, -- Lao Chin's Last Mug
	[88566] = { Full = true }, -- Krastinov's Bag of Horrors
	[88579] = { Props = true, Placed = true, Brew = true }, -- Jin Warmkeg's Brew
	[88580] = { Friends = true }, -- Ken-Ken's Mask
	[88584] = { Props = true }, -- Totem of Harmony
	[88589] = { Corpse = true }, -- Cremating Torch
	[88801] = { Props = true }, -- Flippable Table
	[88802] = { Props = true, Incap = true, Remote = true }, -- Foxicopter Controller
	[89139] = { Pets = true }, -- Chain Pet Leash
	[89205] = { Props = true }, -- Mini Mana Bomb
	[89222] = { Pets = true }, -- Cloud Ring
	[89614] = { Zone = true, Placed = true, Dummy = true }, -- Anatomical Dummy
	[89869] = { Props = true }, -- Pandaren Scarecrow
	[89999] = { Props = true, Firework = true }, -- Everlasting Alliance Firework
	[90000] = { Props = true, Firework = true }, -- Everlasting Horde Firework
	[90067] = { Friends = true }, -- B. F. F. Necklace
	[90175] = { Corpse = true }, -- Gin-Ji Knife Set
	[90427] = { Flair = true, Props = true, Incap = true, Brew = true }, -- Pandaren Brewpack
	[90883] = { Friends = true }, -- The Pigskin
	[90888] = { Friends = true }, -- Special Edition Foot Ball
	[90899] = { Props = true, Noisy = true, React = true }, -- Darkmoon Whistle
	[91904] = { Props = true }, -- Stackable Stag
	[92738] = { Flair = true, Hats = true }, -- Safari Hat
	[95567] = { Ports = true }, -- Kirin Tor Beacon
	[95568] = { Ports = true }, -- Sunreaver Beacon
	[95589] = { Banner = true }, -- Glorious Standard of the Kirin Tor Offensive
	[95590] = { Props = true }, -- Glorious Standard of the Sunreaver Onslaught
	[97921] = { Props = true }, -- Bom'bay's Color-Seein' Sauce
	[97942] = { Props = true, Noisy = true }, -- Sen'jin Spirit Drum
	[97994] = { Props = true }, -- Darkmoon Seesaw
	[98132] = { Props = true, Incap = true }, -- Shado-Pan Geyser Gun
	[98136] = { Props = true, Incap = true, Remote = true }, -- Gastropod Shell
	[98552] = { Props = true }, -- Xan'tish's Flute
	[101571] = { Flair = true, Hats = true, Noisy = true }, -- Moonfang Shroud
	[102467] = { Zone = true }, -- Censer of Eternal Agony
	[103685] = { Full = true }, -- Celestial Defender's Medallion
	[104262] = { Flair = true }, -- Odd Polished Stone
	[104294] = { Full = true }, -- Rime of the Time-Lost Mariner
	[104302] = { Flair = true }, -- Blackflame Daggers
	[104309] = { Props = true, Fires = true }, -- Eternal Kiln
	[104318] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Flyer Controller
	[104323] = { Friends = true }, -- The Swineskin
	[104324] = { Friends = true }, -- Foot Ball
	[104329] = { Zone = true }, -- Ash-Covered Horn
	[104331] = { Props = true }, -- Warning Sign
	[105898] = { Fight = true }, -- Moonfang's Paw
	[108631] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Roller Controller
	[108632] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Flamer Controller
	[108633] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Cannon Controller
	[108634] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Mortar Controller
	[108635] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Killdozer Controller
	[108735] = { Props = true, Summon = true, Noisy = true }, -- Arena Master's War Horn
	[108739] = { Flair = true }, -- Pretty Draenor Pearl
	[108743] = { Flair = true }, -- Deceptia's Smoldering Boots
	[108745] = { Props = true, Summon = true, Clone = true }, -- Personal Hologram
	[109183] = { Eng = true }, -- World Shrinker
	[109739] = { Props = true }, -- Star Chart
	[110560] = { Ports = true }, -- Garrison Hearthstone
	[111476] = { Flair = true }, -- Stolen Breath
	[111821] = { Eng = true }, -- Blingtron 5000
	[112059] = { Ports = true, Eng = true }, -- Wormhole Centrifuge
	[112324] = { Props = true, Mount = true }, -- Nightmarish Hitching Post
	[113096] = { Fight = true }, -- Bloodmane Charm
	[113375] = { Flair = true }, -- Vindicator's Armor Polish Kit
	[113540] = { Zone = true, Snacks = true }, -- Ba'ruun's Bountiful Bloom
	[113542] = { Flair = true, Fall = true }, -- Whispers of Rai'Vosh
	[113543] = { Zone = true }, -- Spirit of Shinri
	[113570] = { Incap = true }, -- Ancient's Bloom
	[113631] = { Critter = true }, -- Hypnosis Goggles
	[113670] = { Props = true, Noisy = true }, -- Mournful Moan of Murmur
	[114227] = { Props = true }, -- Bubble Wand
	[115468] = { Flair = true }, -- Permanent Frost Essence
	[115501] = { Zone = true }, -- Kowalski's Music Box
	[115503] = { Zone = true }, -- Blazing Diamond Pendant
	[115506] = { Zone = true }, -- Treessassin's Guise
	[116067] = { Fight = true }, -- Ring of Broken Promises
	[116113] = { Zone = true }, -- Breath of Talador
	[116115] = { Full = true, Flair = true }, -- Blazing Wings
	[116120] = { Props = true, Incap = true, Snacks = true }, -- Tasty Talador Lunch
	[116122] = { Props = true }, -- Burning Legion Missive
	[116125] = { Incap = true }, -- Klikixx's Webspinner
	[116139] = { Props = true }, -- Haunting Memento
	[116400] = { Friends = true }, -- Silver-Plated Turkey Shooter
	[116435] = { Props = true, Fires = true }, -- Cozy Bonfire
	[116440] = { Fight = true }, -- Burning Defender's Medallion
	[116456] = { Props = true }, -- Scroll of Storytelling
	[116651] = { Props = true }, -- True Love Prism
	[116689] = { Props = true, Seat = true }, -- Pineapple Lounge Cushion
	[116690] = { Props = true, Seat = true }, -- Safari Lounge Cushion
	[116691] = { Props = true, Seat = true }, -- Zhevra Lounge Cushion
	[116692] = { Props = true, Seat = true }, -- Fuzzy Green Lounge Cushion
	[116757] = { Props = true, Fires = true }, -- Steamworks Sausage Grill
	[116758] = { Flair = true, Banner = true }, -- Brewfest Banner
	[116763] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Shredder Controller
	[116856] = { Flair = true }, -- "Blooming Rose" Contender's Costume
	[116888] = { Flair = true }, -- "Night Demon" Contender's Costume
	[116889] = { Flair = true }, -- "Purple Phantom" Contender's Costume
	[116890] = { Flair = true }, -- "Santo's Sun" Contender's Costume
	[116891] = { Flair = true }, -- "Snowy Owl" Contender's Costume
	[117550] = { Flair = true, Props = true }, -- Angry Beehive
	[117569] = { Flair = true, Props = true }, -- Giant Deathweb Egg
	[117573] = { Props = true, Fires = true }, -- Wayfarer's Bonfire
	[118191] = { Props = true, Summon = true }, -- Archmage Vargoth's Spare Staff
	[118221] = { Critter = true }, -- Petrification Stone
	[118222] = { Props = true }, -- Spirit of Bashiok
	[118244] = { Full = true }, -- Iron Buccaneer's Hat
	[118427] = { Props = true }, -- Autographed Hearthstone Card
	[118716] = { Fragile = true }, -- Goren Garb
	[118935] = { Zone = true, Snacks = true }, -- Ever-Blooming Frond
	[118937] = { Full = true }, -- Gamon's Braid
	[118938] = { Full = true }, -- Manastorm's Duplicator
	[119001] = { Props = true, Placed = true, Brew = true }, -- Mystery Keg
	[119003] = { Props = true }, -- Void Totem
	[119039] = { Props = true }, -- Lilian's Warning Sign
	[119083] = { Props = true }, -- Fruit Basket
	[119092] = { Full = true, Flair = true }, -- Moroes' Famous Polish
	[119093] = { Zone = true }, -- Aviana's Feather
	[119134] = { Flair = true }, -- Sargerei Disguise
	[119144] = { Flair = true }, -- Touch of the Naaru
	[119145] = { Props = true }, -- Firefury Totem
	[119160] = { Props = true, Placed = true, React = true }, -- Tickle Totem
	[119163] = { Corpse = true }, -- Soul Inhaler
	[119178] = { Zone = true }, -- Black Whirlwind
	[119180] = { Props = true }, -- Goren "Log" Roller
	[119182] = { Props = true }, -- Soul Evacuation Crystal
	[119210] = { Props = true }, -- Hearthstone Board
	[119211] = { Incap = true }, -- Golden Hearthstone Card: Lord Jaraxxus
	[119212] = { Props = true }, -- Winning Hand
	[119215] = { Full = true }, -- Robo-Gnomebulator
	[119217] = { Corpse = true }, -- Alliance Flag of Victory
	[119218] = { Corpse = true }, -- Horde Flag of Victory
	[119219] = { Corpse = true }, -- Warlord's Flag of Victory
	[119421] = { Full = true }, -- Sha'tari Defender's Medallion
	[119432] = { Incap = true }, -- Botani Camouflage
	[120276] = { Full = true }, -- Outrider's Bridle Chain
	[122117] = { Fight = true }, -- Cursed Feather of Ikzan
	[122119] = { Props = true, Firework = true }, -- Everlasting Darkmoon Firework
	[122121] = { Incap = true, Remote = true }, -- Darkmoon Gazer
	[122122] = { Props = true, Incap = true, Remote = true }, -- Darkmoon Tonk Controller
	[122123] = { Props = true, React = true }, -- Darkmoon Ring-Flinger
	[122126] = { Props = true }, -- Attraction Sign
	[122129] = { Props = true }, -- Fire-Eater's Vial
	[122283] = { Fight = true }, -- Rukhmar's Sacred Memory
	[122293] = { Flair = true, Summon = true, Hats = true, Noisy = true }, -- Trans-Dimensional Bird Whistle
	[122298] = { Full = true, Flair = true }, -- Bodyguard Miniaturization Device
	[122674] = { Props = true, Incap = true }, -- S.E.L.F.I.E. Camera MkII
	[122681] = { Zone = true }, -- Sternfathom's Pet Journal
	[122700] = { Props = true, Noisy = true }, -- Portable Audiophone
	[123851] = { Props = true }, -- Photo B.O.M.B.
	[126931] = { Props = true, Noisy = true }, -- Seafarer's Slidewhistle
	[127394] = { Fragile = true }, -- Podling Camouflage
	[127652] = { Props = true, Fires = true }, -- Felflame Campfire
	[127655] = { Zone = true }, -- Sassy Imp
	[127659] = { Fight = true }, -- Ghostly Iron Buccaneer's Hat
	[127666] = { Incap = true }, -- Vial of Red Goo
	[127668] = { Full = true }, -- Jewel of Hellfire
	[127669] = { Zone = true }, -- Skull of the Mad Chief
	[127670] = { Flair = true }, -- Accursed Tome of the Sargerei
	[127695] = { Pets = true }, -- Spirit Wand
	[127696] = { Pets = true, Clone = true }, -- Magic Pet Mirror
	[127707] = { Pets = true }, -- Indestructible Bone
	[127709] = { Fragile = true }, -- Throbbing Blood Orb
	[127766] = { Props = true }, -- The Perfect Blossom
	[127859] = { Props = true, Incap = true, React = true }, -- Dazzling Rod
	[127864] = { Props = true }, -- Personal Spotlight
	[128223] = { Zone = true, Snacks = true }, -- Bottomless Stygana Mushroom Brew
	[128310] = { Flair = true, Props = true }, -- Burning Blade
	[128328] = { Props = true, Incap = true, Summon = true, Critter = true }, -- Skoller's Bag of Squirrel Treats
	[128471] = { Flair = true }, -- Frostwolf Grunt's Battlegear
	[128536] = { Props = true, Fires = true }, -- Leylight Brazier
	[128636] = { Props = true, Incap = true }, -- Endothermic Blaster
	[128776] = { Incap = true }, -- Red Wooden Sled
	[128794] = { Props = true }, -- Sack of Spectral Spiders
	[128807] = { Full = true }, -- Coin of Many Faces
	[129055] = { Flair = true }, -- Shoe Shine Kit
	[129057] = { Friends = true }, -- Dalaran Disc
	[129093] = { Flair = true }, -- Ravenbear Disguise
	[129113] = { Full = true }, -- Faintly Glowing Flagon of Mead
	[129149] = { Full = true, Flair = true }, -- Death's Door Charm
	[129165] = { Fragile = true }, -- Barnacle-Encrusted Gem
	[129211] = { Props = true }, -- Steamy Romance Novel Kit
	[129279] = { Props = true }, -- Enchanted Stone Whistle
	[129926] = { Full = true }, -- Mark of the Ashtongue
	[129929] = { Ports = true, Zone = true }, -- Ever-Shifting Mirror
	[129938] = { Full = true }, -- Will of Northrend
	[129952] = { Props = true, Summon = true, Clone = true }, -- Hourglass of Eternity
	[129956] = { Props = true, Seat = true }, -- Leather Love Seat
	[129958] = { Pets = true }, -- Leather Pet Leash
	[129960] = { Pets = true }, -- Leather Pet Bed
	[129961] = { Pets = true }, -- Flaming Hoop
	[129965] = { Zone = true }, -- Grizzlesnout's Fang
	[130147] = { Fragile = true }, -- Thistleleaf Branch
	[130151] = { Props = true, Zone = true, Snacks = true }, -- The "Devilsaur" Lunchbox
	[130158] = { Flair = true }, -- Path of Elothir
	[130169] = { Props = true }, -- Tournament Favor
	[130170] = { Props = true }, -- Tear of the Green Aspect
	[130171] = { Incap = true }, -- Cursed Orb
	[130191] = { Props = true }, -- Trapped Treasure Chest Kit
	[130199] = { Props = true }, -- Legion Pocket Portal
	[130209] = { Props = true }, -- Never Ending Toy Chest
	[130214] = { Props = true, Placed = true }, -- Worn Doll
	[130232] = { Props = true }, -- Moonfeather Statue
	[130251] = { Props = true }, -- JewelCraft
	[130254] = { Props = true }, -- Chatterstone
	[131717] = { Zone = true }, -- Starlight Beacon
	[131724] = { Props = true }, -- Crystalline Eye of Undravius
	[131811] = { Zone = true, Fall = true }, -- Rocfeather Skyhorn Kite
	[131812] = { Flair = true }, -- Darkshard Fragment
	[131814] = { Props = true }, -- Whitewater Carp
	[131900] = { Zone = true }, -- Majestic Elderhorn Hoof
	[131933] = { Props = true, Summon = true, Placed = true, Critter = true }, -- Critter Hand Cannon
	[132518] = { Props = true }, -- Blingtron's Circuit Design Tutorial
	[133511] = { Full = true }, -- Gurboggle's Gleaming Bauble
	[133542] = { Incap = true }, -- Tosselwrench's Mega-Accurate Simulation Viewfinder
	[133997] = { Flair = true, Rings = true }, -- Black Ice
	[133998] = { Props = true }, -- Rainbow Generator
	[134004] = { Flair = true }, -- Noble's Eternal Elementium Signet
	[134007] = { Flair = true, Rings = true }, -- Eternal Black Diamond Ring
	[134019] = { Full = true, Flair = true, Props = true, Summon = true, Hats = true }, -- Don Carlos' Famous Hat
	[134020] = { Flair = true, Hats = true }, -- Chef's Hat
	[134021] = { Props = true }, -- X-52 Rocket Helmet
	[134022] = { Full = true }, -- Burgy Blackheart's Handsome Hat
	[134023] = { Zone = true }, -- Bottled Tornado
	[134024] = { Zone = true }, -- Cursed Swabby Helmet
	[134026] = { Flair = true }, -- Honorable Pennant
	[134031] = { Flair = true }, -- Prestigious Pennant
	[134032] = { Flair = true }, -- Elite Pennant
	[134034] = { Flair = true }, -- Esteemed Pennant
	[134831] = { Flair = true }, -- Doomsayer's Robes
	[136849] = { Ports = true }, -- Nature's Beacon
	[136855] = { Props = true }, -- Hunter's Call
	[136934] = { Props = true }, -- Raging Elemental Stone
	[136935] = { Props = true }, -- Tadpole Cloudseeder
	[136937] = { Props = true, Summon = true, Critter = true }, -- Vol'jin's Serpent Totem
	[137294] = { Flair = true }, -- Dalaran Initiates' Pin
	[138202] = { Flair = true, Mount = true }, -- Sparklepony XL
	[138415] = { Props = true }, -- Slightly-Chewed Insult Book
	[138873] = { Fragile = true, Flair = true }, -- Mystical Frosh Hat
	[138878] = { Full = true, Flair = true }, -- Copy of Daglop's Contract
	[138900] = { Flair = true }, -- Gravil Goldbraid's Famous Sausage Hat
	[139337] = { Full = true, Flair = true, Props = true }, -- Disposable Winter Veil Suits
	[139773] = { Zone = true }, -- Emerald Winds
	[140160] = { Fight = true }, -- Stormforged Vrykul Horn
	[140192] = { Ports = true }, -- Dalaran Hearthstone
	[140231] = { Pets = true, Clone = true }, -- Narcissa's Mirror
	[140309] = { Flair = true, Props = true }, -- Prismatic Bauble
	[140325] = { Fragile = true, Flair = true }, -- Home Made Party Mask
	[140363] = { Props = true }, -- Pocket Fel Spreader
	[140632] = { Props = true }, -- Lava Fountain
	[140780] = { Fragile = true }, -- Fal'dorei Egg
	[140786] = { Flair = true }, -- Ley Spider Eggs
	[141296] = { Props = true }, -- Ancient Mana Basin
	[141297] = { Props = true, Placed = true }, -- Arcano-Shower
	[141298] = { Incap = true }, -- Displacer Meditation Stone
	[141299] = { Props = true }, -- Kaldorei Light Globe
	[141301] = { Props = true }, -- Unstable Powder Box
	[141306] = { Flair = true }, -- Wisp in a Bottle
	[141331] = { Full = true }, -- Vial of Green Goo
	[141605] = { Ports = true }, -- Flight Master's Whistle
	[141649] = { Incap = true }, -- Set of Matches
	[141862] = { Flair = true }, -- Mote of Light
	[141879] = { Incap = true }, -- Berglrgl Perrgl Girggrlf
	[142265] = { Props = true }, -- Big Red Raygun
	[142341] = { Rafts = true }, -- Love Boat
	[142452] = { Fight = true }, -- Lingering Wyrmtongue Essence
	[142494] = { Pets = true }, -- Purple Blossom
	[142495] = { Pets = true }, -- Fake Teeth
	[142496] = { Pets = true }, -- Dirty Spoon
	[142497] = { Pets = true }, -- Tiny Pack
	[142528] = { Lures = true }, -- Crate of Bobbers: Can of Worms
	[142529] = { Lures = true }, -- Crate of Bobbers: Cat Head
	[142530] = { Lures = true }, -- Crate of Bobbers: Tugboat
	[142531] = { Lures = true }, -- Crate of Bobbers: Squeaky Duck
	[142532] = { Lures = true }, -- Crate of Bobbers: Murloc Head
	[142536] = { Props = true }, -- Memory Cube
	[142542] = { Hearth = true }, -- Tome of Town Portal
	[143534] = { Props = true }, -- Wand of Simulated Life
	[143543] = { Incap = true, Noisy = true }, -- Twelve-String Guitar
	[143660] = { Props = true }, -- Mrgrglhjorn
	[143662] = { Lures = true }, -- Crate of Bobbers: Wooden Pepe
	[143727] = { Fragile = true }, -- Champion's Salute
	[143827] = { Fragile = true }, -- Red Dragon Head Costume
	[143828] = { Fragile = true }, -- Red Dragon Body Costume
	[143829] = { Fragile = true }, -- Red Dragon Tail Costume
	[144072] = { Props = true, Summon = true }, -- Adopted Puppy Crate
	[144339] = { Props = true, Placed = true, Dummy = true }, -- Sturdy Love Fool
	[147307] = { Full = true, Lures = true }, -- Crate of Bobbers: Carved Wooden Helm
	[147308] = { Lures = true }, -- Crate of Bobbers: Enchanted Bobber
	[147309] = { Lures = true }, -- Crate of Bobbers: Face of the Forest
	[147310] = { Lures = true }, -- Crate of Bobbers: Floating Totem
	[147311] = { Lures = true }, -- Crate of Bobbers: Replica Gondola
	[147312] = { Lures = true }, -- Crate of Bobbers: Demon Noggin
	[147537] = { Flair = true, Props = true, Summon = true, Hats = true, Noisy = true }, -- A Tiny Set of Warglaives
	[147708] = { Props = true, Incap = true, Remote = true }, -- Legion Invasion Simulator
	[147832] = { Pets = true }, -- Magical Saucer
	[147838] = { Props = true }, -- Akazamzarak's Spare Hat
	[147843] = { Fight = true }, -- Sira's Extra Cloak
	[147867] = { Props = true }, -- Pilfered Sweeper
	[150547] = { Flair = true }, -- Jolly Roger
	[150743] = { Maps = true }, -- Scouting Map: Surviving Kalimdor
	[150744] = { Maps = true }, -- Scouting Map: Walking Kalimdor with the Earthmother
	[150745] = { Maps = true }, -- Scouting Map: The Eastern Kingdoms Campaign
	[150746] = { Maps = true }, -- Scouting Map: Modern Provisioning of the Eastern Kingdoms
	[151016] = { Ports = true }, -- Fractured Necrolyte Skull
	[151184] = { Props = true }, -- Verdant Throwing Sphere
	[151265] = { Fragile = true }, -- Blight Boar Microphone
	[151270] = { Fragile = true }, -- Horse Tail Costume
	[151271] = { Fragile = true }, -- Horse Head Costume
	[151343] = { Props = true }, -- Hearthstation
	[151344] = { Props = true }, -- Hearthstation
	[151348] = { Flair = true, Props = true }, -- Toy Weapon Set
	[151349] = { Flair = true, Props = true }, -- Toy Weapon Set
	[151652] = { Eng = true }, -- Wormhole Generator: Argus
	[152556] = { Props = true }, -- Trawler Totem
	[152574] = { Friends = true }, -- Corbyn's Beacon
	[152982] = { Full = true }, -- Vixx's Chest of Tricks
	[153004] = { Ports = true }, -- Unstable Portal Emitter
	[153039] = { Props = true, Fires = true }, -- Crystalline Campfire
	[153124] = { Props = true, Summon = true, Critter = true }, -- Spire of Spite
	[153126] = { Props = true, Incap = true, Remote = true }, -- Micro-Artillery Controller
	[153179] = { Flair = true }, -- Blue Conservatory Scroll
	[153180] = { Flair = true }, -- Yellow Conservatory Scroll
	[153181] = { Flair = true }, -- Red Conservatory Scroll
	[153182] = { Friends = true }, -- Holy Lightsphere
	[153183] = { Props = true }, -- Barrier Generator
	[153193] = { Zone = true }, -- Baarut the Brisk
	[153253] = { Props = true, Incap = true, Remote = true }, -- S.F.E. Interceptor
	[153293] = { Zone = true }, -- Sightless Eye
	[156649] = { Props = true }, -- Zandalari Effigy Amulet
	[156833] = { Mail = true }, -- Katy's Stampwhistle
	[156871] = { Flair = true }, -- Spitzy
	[158149] = { Flair = true }, -- Overtuned Corgi Goggles
	[159749] = { Flair = true, Props = true }, -- Haw'li's Hot & Spicy Chili
	[159753] = { Props = true, Summon = true, Noisy = true, Critter = true }, -- Desert Flute
	[160509] = { Flair = true, Props = true }, -- Echoes of Rezan
	[160740] = { Props = true }, -- Croak Crock
	[160751] = { Props = true, Summon = true, Placed = true, Noisy = true }, -- Dance of the Dead
	[161342] = { Props = true }, -- Gem of Acquiescence
	[162539] = { Fragile = true, Props = true }, -- Hot Buttered Popcorn
	[162642] = { Flair = true }, -- Toy Armor Set
	[162643] = { Flair = true }, -- Toy Armor Set
	[162973] = { Hearth = true }, -- Greatfather Winter's Hearthstone
	[163045] = { Hearth = true }, -- Headless Horseman's Hearthstone
	[163201] = { Zone = true, Placed = true, Dummy = true }, -- Gnoll Targeting Barrel
	[163210] = { Props = true }, -- Party Totem
	[163211] = { Props = true, Fires = true, Placed = true }, -- Akunda's Firesticks
	[163463] = { Props = true, Noisy = true }, -- Dead Ringer
	[163603] = { Zone = true }, -- Lucille's Handkerchief
	[163697] = { Pets = true }, -- Laser Pointer
	[163704] = { Pets = true }, -- Tiny Mechanical Mouse
	[163705] = { Pets = true }, -- Imaginary Gun
	[163713] = { Flair = true, Props = true, Hats = true }, -- Brazier Cap
	[163735] = { Props = true }, -- Foul Belly
	[163736] = { Full = true, Flair = true }, -- Spectral Visage
	[163738] = { Flair = true, Hats = true }, -- Syndicate Mask
	[163740] = { Corpse = true }, -- Drust Ritual Knife
	[163741] = { Friends = true }, -- Magic Fun Rock
	[163742] = { Flair = true }, -- Heartsbane Grimoire
	[163744] = { Props = true }, -- Coldrage's Cooler
	[163745] = { Props = true, Noisy = true }, -- Witherbark Gong
	[163750] = { Full = true }, -- Kovork Kostume
	[163775] = { Flair = true }, -- Molok Morion
	[163795] = { Fragile = true }, -- Oomgut Ritual Drum
	[163828] = { Props = true, Incap = true, Remote = true }, -- Toy Siege Tower
	[163829] = { Props = true, Incap = true, Remote = true }, -- Toy War Machine
	[163924] = { Flair = true }, -- Whiskerwax Candle
	[164310] = { Flair = true }, -- Glorious Pennant
	[164373] = { Fragile = true }, -- Enchanted Soup Stone
	[164374] = { Fragile = true }, -- Magic Monkey Banana
	[164375] = { Fragile = true }, -- Bad Mojo Banana
	[164983] = { Zone = true }, -- Rhan'ka's Escape Plan
	[165669] = { Hearth = true }, -- Lunar Elder's Hearthstone
	[165670] = { Hearth = true }, -- Peddlefeet's Lovely Hearthstone
	[165671] = { Fragile = true }, -- Blue Dragon Head Costume
	[165672] = { Fragile = true }, -- Blue Dragon Body Costume
	[165673] = { Fragile = true }, -- Blue Dragon Tail Costume
	[165674] = { Fragile = true, Flair = true }, -- Green Dragon Head Costume
	[165675] = { Fragile = true, Flair = true }, -- Green Dragon Body Costume
	[165676] = { Fragile = true, Flair = true }, -- Green Dragon Tail Costume
	[165791] = { Flair = true }, -- Worn Cloak
	[165802] = { Hearth = true }, -- Noble Gardener's Hearthstone
	[166247] = { Props = true, Summon = true }, -- Citizens Brigade Whistle
	[166308] = { Full = true }, -- For da Blood God!
	[166461] = { Rafts = true }, -- Gnarlwood Waveboard
	[166544] = { Fragile = true }, -- Dark Ranger's Spare Cowl
	[166662] = { Pets = true }, -- Cranky Crab
	[166663] = { Flair = true, Props = true }, -- Hand Anchor
	[166678] = { Props = true }, -- Brynja's Beacon
	[166701] = { Corpse = true }, -- Warbeast Kraal Dinner Bell
	[166702] = { Props = true, Placed = true, Noisy = true }, -- Proudmoore Music Box
	[166703] = { Props = true, Placed = true, Snacks = true }, -- Goldtusk Inn Breakfast Buffet
	[166704] = { Props = true }, -- Bowl of Glowing Pufferfish
	[166743] = { Props = true, Incap = true, Remote = true }, -- Blight Bomber
	[166744] = { Props = true, Incap = true, Remote = true }, -- Glaive Tosser
	[166746] = { Hearth = true }, -- Fire Eater's Hearthstone
	[166747] = { Hearth = true }, -- Brewfest Reveler's Hearthstone
	[166779] = { Full = true, Props = true }, -- Transmorpher Beacon
	[166784] = { Corpse = true }, -- Narassin's Soul Gem
	[166785] = { Flair = true }, -- Detoxified Blight Grenade
	[166787] = { Incap = true }, -- Twiddle Twirler: Sentinel's Glaive
	[166788] = { Incap = true }, -- Twiddle Twirler: Shredder Blade
	[166790] = { Fragile = true }, -- Highborne Memento
	[166808] = { Props = true, Placed = true, Snacks = true }, -- Bewitching Tea Set
	[166877] = { Props = true, Firework = true }, -- Azerite Firework Launcher
	[166879] = { Props = true, Banner = true }, -- Rallying War Banner
	[166880] = { Props = true, Noisy = true }, -- Meerah's Jukebox
	[167698] = { Flair = true }, -- Secret Fish Goggles
	[167931] = { Flair = true }, -- Mechagonian Sawblades
	[168012] = { Props = true, Friends = true }, -- Apexis Focusing Shard
	[168014] = { Flair = true, Banner = true }, -- Banner of the Burning Blade
	[168016] = { Props = true }, -- Hyper-Compressed Ocean
	[168123] = { Flair = true, Hats = true }, -- Twitching Eyeball
	[168667] = { Eng = true }, -- Blingtron 7000
	[168807] = { Ports = true, Eng = true }, -- Wormhole Generator: Kul Tiras
	[168808] = { Ports = true, Eng = true }, -- Wormhole Generator: Zandalar
	[168824] = { Props = true, Noisy = true }, -- Ocean Simulator
	[168907] = { Hearth = true }, -- Holographic Digitalization Hearthstone
	[169108] = { Props = true, Banner = true }, -- Rustbolt Banner
	[169275] = { Banner = true }, -- Alliance War Banner
	[169276] = { Banner = true }, -- Horde War Banner
	[169277] = { Banner = true }, -- Horde War Standard
	[169278] = { Banner = true }, -- Alliance War Standard
	[169298] = { Ports = true }, -- Frostwolf Insignia
	[169303] = { Full = true, Flair = true }, -- Hell-Bent Bracers
	[169347] = { Props = true }, -- Judgment of Mechagon
	[169768] = { Fragile = true, Props = true }, -- Heart of a Champion
	[169796] = { Props = true }, -- Azeroth Mini Collection: Mechagon
	[169865] = { Props = true, Placed = true, Brew = true }, -- Brewfest Chowdown Trophy
	[170154] = { Full = true }, -- Book of the Unshackled
	[170187] = { Props = true }, -- Shadescale
	[170196] = { Props = true, Incap = true }, -- Shirakess Warning Sign
	[170197] = { Props = true }, -- Swarthy Warning Sign
	[170198] = { Flair = true }, -- Eternal Palace Dining Set
	[170199] = { Flair = true, Props = true }, -- Zanj'ir Weapon Rack
	[170203] = { Props = true }, -- Flopping Fish
	[170204] = { Flair = true }, -- Symbol of Gral
	[170380] = { Props = true }, -- Jar of Sunwarmed Sand
	[170469] = { Props = true }, -- Memento of the Deeps
	[170476] = { Props = true }, -- Underlight Sealamp
	[172179] = { Hearth = true }, -- Eternal Traveler's Hearthstone
	[172219] = { Mount = true }, -- Wild Holly
	[172222] = { Props = true, Incap = true, Remote = true }, -- Crashin' Thrashin' Juggernaught
	[172223] = { Incap = true, Remote = true }, -- Crashin' Thrashin' Battleship
	[172924] = { Ports = true, Eng = true }, -- Wormhole Generator: Shadowlands
	[173727] = { Zone = true }, -- Nomi's Vintage
	[173984] = { Full = true, Flair = true }, -- Scroll of Aeons
	[174830] = { Flair = true }, -- Shadowy Disguise
	[174871] = { Pets = true }, -- Mayhem Mind Melder
	[174873] = { Fight = true }, -- Trans-mogu-rifier
	[174874] = { Flair = true }, -- Budget K'thir Disguise
	[174920] = { Props = true }, -- Coifcurl's Close Shave Kit
	[174921] = { Friends = true }, -- Void-Touched Skull
	[174924] = { Props = true, Placed = true, Critter = true }, -- Void-Touched Souvenir Totem
	[174926] = { Flair = true, Hats = true }, -- Overly Sensitive Void Spectacles
	[174928] = { Props = true }, -- Rotten Apple
	[174995] = { Pets = true }, -- Void Tendril Pet Leash
	[175063] = { Friends = true }, -- Aqir Egg Cluster
	[177951] = { Flair = true, Props = true }, -- Glimmerflies on Strings
	[178530] = { Friends = true }, -- Wreath-A-Rang
	[179393] = { Full = true, Flair = true }, -- Mirror of Envious Dreams
	[180290] = { Ports = true, Hearth = true }, -- Night Fae Hearthstone
	[180873] = { Flair = true }, -- Smolderheart
	[180947] = { Flair = true }, -- Tithe Collector's Vessel
	[180993] = { Lures = true }, -- Bat Visage Bobber
	[181794] = { Fragile = true, Props = true }, -- Orophea's Lyre
	[181825] = { Zone = true }, -- Phial of Ravenous Slime
	[182694] = { Props = true, Fall = true }, -- Stylish Black Parasol
	[182695] = { Props = true, Fall = true }, -- Weathered Purple Parasol
	[182696] = { Props = true, Fall = true }, -- The Countess's Parasol
	[182729] = { Zone = true, Fall = true }, -- Hearty Dragon Plume
	[182732] = { Full = true, Flair = true }, -- The Necronom-i-nom
	[182773] = { Hearth = true }, -- Necrolord Hearthstone
	[182780] = { Props = true, Fires = true }, -- Muckpool Cookpot
	[182890] = { Fragile = true, Props = true }, -- Rapid Recitation Quill
	[183716] = { Hearth = true }, -- Venthyr Sinstone
	[183847] = { Full = true }, -- Acolyte's Guise
	[183856] = { Fragile = true }, -- Mystical Orb of Meditation
	[183876] = { Props = true }, -- Quill of Correspondence
	[183900] = { Props = true }, -- Sinvyr Tea Set
	[183901] = { Props = true, Summon = true, Placed = true }, -- Bonestorm Top
	[183903] = { Fight = true }, -- Smelly Jelly
	[183986] = { Flair = true, Props = true }, -- Bondable Sinstone
	[183988] = { Flair = true, Props = true }, -- Bondable Val'kyr Diadem
	[183989] = { Incap = true }, -- Dredger Barrow Racer
	[184075] = { Props = true }, -- Stonewrought Sentry
	[184218] = { Props = true }, -- Vulgarity Arbiter
	[184223] = { Full = true }, -- Helm of the Dominated
	[184292] = { Friends = true }, -- Ancient Elethium Coin
	[184312] = { Zone = true }, -- Borr-Geth's Fiery Brimstone
	[184318] = { Props = true }, -- Battlecry of Krexus
	[184353] = { Hearth = true }, -- Kyrian Hearthstone
	[184396] = { Props = true, Critter = true }, -- Malfunctioning Goliath Gauntlet
	[184404] = { Props = true, Fires = true }, -- Ever-Abundant Hearth
	[184410] = { Props = true }, -- Aspirant's Stretcher
	[184413] = { Props = true }, -- Mnemonic Attunement Pane
	[184415] = { Props = true }, -- Soothing Vesper
	[184418] = { Props = true }, -- Acrobatic Steward
	[184449] = { Pets = true }, -- Jiggles's Favorite Toy
	[184476] = { Props = true }, -- Regenerating Slime Vial
	[184487] = { Props = true, Summon = true, Placed = true }, -- Gormling in a Bag
	[184489] = { Incap = true, Noisy = true }, -- Fae Harp
	[184490] = { Flair = true, Props = true, Noisy = true }, -- Fae Pipes
	[184495] = { Props = true, Incap = true }, -- Infested Arachnid Casing
	[184508] = { Pets = true }, -- Mawsworn Pet Leash
	[186501] = { Props = true }, -- Doomwalker Trophy Stand
	[186686] = { Props = true }, -- Pallid Oracle Bones
	[186702] = { Props = true, Noisy = true }, -- Pallid Bone Flute
	[186973] = { Pets = true }, -- Anima-ted Leash
	[186974] = { Props = true }, -- Experimental Anima Cell
	[186985] = { Pets = true }, -- Elusive Pet Treat
	[187051] = { Pets = true }, -- Forgotten Feather
	[187075] = { Props = true, Summon = true }, -- Box of Rattling Chains
	[187113] = { Fragile = true, Incap = true }, -- Personal Ball and Chain
	[187139] = { Fragile = true }, -- Bottled Shade Heart
	[187140] = { Props = true, Clone = true }, -- Ring of Duplicity
	[187154] = { Props = true }, -- Ancient Korthian Runes
	[187155] = { Fragile = true }, -- Guise of the Changeling
	[187159] = { Props = true }, -- Shadow Slicing Shortsword
	[187174] = { Corpse = true }, -- Shaded Judgment Stone
	[187176] = { Props = true }, -- Vesper of Harmony
	[187184] = { Props = true }, -- Vesper of Clarity
	[187185] = { Props = true }, -- Vesper of Faith
	[187339] = { Zone = true }, -- Silver Shardhide Whistle
	[187344] = { Props = true }, -- Offering Kit Maker
	[187416] = { Incap = true }, -- Jailer's Cage
	[187417] = { Incap = true }, -- Adamant Vaults Cell
	[187420] = { Props = true }, -- Maw-Ocular Viewfinder
	[187422] = { Props = true, Incap = true, Remote = true }, -- Rockin' Rollin' Racer Customizer 19.9.3
	[187512] = { Critter = true }, -- Tome of Small Sins
	[187591] = { Fragile = true, Props = true }, -- Nightborne Guard's Vigilance
	[187689] = { Incap = true }, -- Dance Dance Darkmoon
	[187705] = { Props = true }, -- Choofa's Call
	[187793] = { Incap = true }, -- Personal Containment Trap
	[187840] = { Flair = true }, -- Sparkle Wings
	[187860] = { Incap = true }, -- Mortis Mover
	[187869] = { Maps = true }, -- Scouting Map: Into the Shadowlands
	[187875] = { Maps = true }, -- Scouting Map: United Fronts of the Broken Isles
	[187895] = { Maps = true }, -- Scouting Map: The Dangers of Draenor
	[187896] = { Maps = true }, -- Scouting Map: A Stormstout's Guide to Pandaria
	[187897] = { Maps = true }, -- Scouting Map: Cataclysm's Consequences
	[187898] = { Maps = true }, -- Scouting Map: True Cost of the Northrend Campaign
	[187899] = { Maps = true }, -- Scouting Map: The Many Curiosities of Outland
	[187900] = { Maps = true }, -- Scouting Map: The Wonders of Kul Tiras and Zandalar
	[188680] = { Noisy = true }, -- Winter Veil Chorus Book
	[188694] = { Props = true }, -- Spring Florist's Pouch
	[188695] = { Fragile = true }, -- Summer Cranial Skillet
	[188699] = { Fragile = true, Incap = true }, -- Insulated Dancing Insoles
	[188701] = { Flair = true, Props = true }, -- Fire Festival Batons
	[188952] = { Hearth = true }, -- Dominated Hearthstone
	[190177] = { Fragile = true, Incap = true }, -- Sphere of Enlightened Cogitation
	[190196] = { Hearth = true }, -- Enlightened Hearthstone
	[190237] = { Hearth = true }, -- Broker Translocation Matrix
	[190238] = { Props = true }, -- Xy'rath's Booby-Trapped Cache
	[190333] = { Props = true }, -- Jiro Circle of Song
	[190457] = { Fight = true }, -- Protopological Cube
	[190734] = { Props = true }, -- Makaris's Satchel of Mines
	[190754] = { Props = true }, -- Firim's Specimen Container
	[190853] = { Props = true }, -- Bushel of Mysterious Fruit
	[190926] = { Flair = true }, -- Infested Automa Core
	[191891] = { Fragile = true }, -- Professor Chirpsnide's Im-PECK-able Harpy Disguise
	[191925] = { Props = true }, -- Falling Star Flinger
	[191937] = { Flair = true, Props = true }, -- Falling Star Catcher
	[192443] = { Eng = true }, -- Element-Infused Rocket Helmet
	[192495] = { Fragile = true, Props = true }, -- Malfunctioning Stealthman 54
	[193032] = { Props = true }, -- Jeweled Offering
	[193033] = { Incap = true }, -- Convergent Prism
	[193476] = { Props = true }, -- Gnoll Tent
	[193478] = { Props = true, Seat = true }, -- Tuskarr Beanbag
	[193588] = { Hearth = true }, -- Timewalker's Hearthstone
	[194052] = { Corpse = true }, -- Forlorn Funeral Pall
	[194056] = { Fragile = true, Incap = true }, -- Duck-Stuffed Duck Lovie
	[194057] = { Props = true, Seat = true }, -- Cushion of Time Travel
	[194058] = { Props = true, Seat = true }, -- Cold Cushion
	[194059] = { Props = true }, -- Market Tent
	[194060] = { Props = true }, -- Dragonscale Expedition's Expedition Tent
	[194885] = { Mail = true }, -- Ohuna Perch
	[197719] = { Props = true }, -- Artisan's Sign
	[197961] = { Flair = true, Incap = true, Fall = true }, -- Whelps on Strings
	[197986] = { Flair = true }, -- Murglasses
	[198039] = { Props = true, Summon = true, Critter = true, Snacks = true }, -- Rock of Appreciation
	[198090] = { Incap = true }, -- Jar of Excess Slime
	[198156] = { Ports = true, Eng = true }, -- Wyrmhole Generator: Dragon Isles
	[198173] = { Full = true, Props = true }, -- Atomic Recalibrator
	[198206] = { Props = true }, -- Environmental Emulator
	[198227] = { Flair = true }, -- Giggle Goggles
	[198264] = { Props = true }, -- Centralized Precipitation Emitter
	[198402] = { Props = true, Fires = true }, -- Maruuk Cooking Pot
	[198409] = { Incap = true }, -- Personal Shell
	[198428] = { Rafts = true }, -- Tuskarr Dinghy
	[198474] = { Incap = true }, -- Artist's Easel
	[198537] = { Props = true, Pets = true, Summon = true }, -- Taivan's Trumpet
	[198646] = { Props = true }, -- Ornate Dragon Statue
	[198720] = { Props = true, Placed = true, Seat = true }, -- Soft Purple Pillow
	[198721] = { Props = true, Placed = true, Seat = true }, -- Skinny Reliquary Pillow
	[198722] = { Props = true, Placed = true, Seat = true }, -- Small Triangular Pillow
	[198728] = { Props = true, Banner = true }, -- Explorers' League Banner
	[198729] = { Props = true, Banner = true }, -- Reliquary Banner
	[198827] = { Incap = true }, -- Magical Snow Sled
	[198857] = { Props = true, Hats = true }, -- Lucky Duck
	[199337] = { Props = true, Critter = true }, -- Bag of Furious Winds
	[199554] = { Props = true, Seat = true }, -- S.E.A.T.
	[199649] = { Props = true, Placed = true, Snacks = true }, -- Dragon Tea Set
	[199650] = { Props = true, Placed = true, Snacks = true }, -- Whale Bone Tea Set
	[199767] = { Props = true, Banner = true }, -- Red Dragon Banner
	[199768] = { Full = true, Props = true, Banner = true }, -- Black Dragon Banner
	[199769] = { Props = true, Banner = true }, -- Blue Dragon Banner
	[199770] = { Props = true, Banner = true }, -- Bronze Dragon Banner
	[199771] = { Props = true, Banner = true }, -- Green Dragon Banner
	[199830] = { Props = true, Placed = true, Dummy = true }, -- Tuskarr Training Dummy
	[199892] = { Props = true, Fires = true }, -- Tuskarr Traveling Soup Pot
	[199894] = { Props = true }, -- Fisherman's Folly
	[199896] = { Props = true, Placed = true, Dummy = true }, -- Rubbery Fish Head
	[199897] = { Props = true, Seat = true }, -- Blue-Covered Beanbag
	[199899] = { Props = true }, -- Iskaara Tug Sled
	[199900] = { Props = true }, -- Secondhand Survey Tools
	[199902] = { Flair = true }, -- Wayfinder's Compass
	[200116] = { Zone = true }, -- Everlasting Horn of Lavaswimming
	[200148] = { Props = true, Summon = true, Clone = true }, -- A Collection of Me
	[200160] = { Props = true }, -- Notfar's Favorite Food
	[200178] = { Fragile = true }, -- Infected Ichor
	[200198] = { Incap = true }, -- Primalist Prison
	[200249] = { Incap = true }, -- Mage's Chewed Wand
	[200469] = { Corpse = true }, -- Khadgar's Disenchanting Rod
	[200550] = { Props = true, Seat = true }, -- Very Comfortable Pelt
	[200551] = { Props = true, Seat = true }, -- Comfortable Pile of Pelts
	[200597] = { Friends = true }, -- Lover's Bouquet
	[200628] = { Props = true }, -- Somewhat-Stabilized Arcana
	[200630] = { Hearth = true }, -- Ohn'ir Windsage's Hearthstone
	[200631] = { Props = true, Summon = true }, -- Happy Tuskarr Palooza
	[200636] = { Fight = true }, -- Primal Invocation Quintessence
	[200640] = { Props = true }, -- Obsidian Egg Clutch
	[200707] = { Flair = true, Props = true }, -- Armoire of Endless Cloaks
	[200857] = { Fight = true }, -- Talisman of Sargha
	[200869] = { Props = true, Brew = true }, -- Ohn Lite Branded Horn
	[200878] = { Props = true, Incap = true, Remote = true }, -- Wheeled Floaty Boaty Controller
	[200926] = { Incap = true, Noisy = true }, -- Compendium of Love
	[200960] = { Fragile = true }, -- Seed of Renewed Souls
	[200999] = { Props = true }, -- The Super Shellkhan Gang
	[201435] = { Props = true, Incap = true }, -- Shuffling Sands
	[201927] = { Props = true }, -- Gleaming Arcanocrystal
	[201931] = { Props = true }, -- Warcraft Rumble Toy: Maiev
	[201933] = { Props = true, Placed = true, Dummy = true }, -- Black Dragon's Challenge Dummy
	[202019] = { Props = true }, -- Golden Dragon Goblet
	[202020] = { Props = true }, -- Chasing Storm
	[202021] = { Corpse = true }, -- Breaker's Flag of Victory
	[202022] = { Props = true }, -- Yennu's Kite
	[202042] = { Flair = true, Props = true, Hats = true }, -- Aquatic Shades
	[202207] = { Lures = true }, -- Reusable Oversized Bobber
	[202253] = { Fight = true }, -- Primal Stave of Claw and Fur
	[202261] = { Props = true }, -- Warcraft Rumble Toy: Sneed
	[202283] = { Flair = true, Hats = true }, -- Reading Glasses
	[202309] = { Props = true }, -- Defective Doomsday Device
	[202360] = { Lures = true }, -- Dented Can
	[202711] = { Fragile = true, Flair = true }, -- Lost Compass
	[202851] = { Props = true }, -- Warcraft Rumble Toy: Night Elf Huntress
	[202856] = { Props = true }, -- Warcraft Rumble Toy: Stonehoof Tauren
	[202859] = { Props = true }, -- Warcraft Rumble Toy: Undead Ghoul
	[202862] = { Props = true }, -- Warcraft Rumble Toy: Murloc
	[202865] = { Props = true }, -- Warcraft Rumble Toy: Whelp Egg
	[203725] = { Props = true }, -- Display of Strength
	[203734] = { Incap = true }, -- Snow Blanket
	[203757] = { Props = true, Fires = true }, -- Brazier of Madness
	[203852] = { Fragile = true }, -- Spore-Bound Essence
	[204170] = { Banner = true }, -- Clan Banner
	[204220] = { Fragile = true, Props = true }, -- Hraxian's Unbreakable Will
	[204256] = { Props = true }, -- Holoviewer: The Scarlet Queen
	[204257] = { Props = true }, -- Holoviewer: The Lady of Dreams
	[204262] = { Props = true }, -- Holoviewer: The Timeless One
	[204389] = { Props = true }, -- Stone Breaker
	[204405] = { Props = true }, -- Stuffed Bear
	[204675] = { Mount = true }, -- A Drake's Big Basket of Eggs
	[204686] = { Incap = true }, -- Titan Containment Device
	[204687] = { Props = true, Noisy = true }, -- Obsidian Battle Horn
	[204818] = { Friends = true }, -- Mallard Mortar
	[205255] = { Ports = true }, -- Niffen Diggin' Mitts
	[205418] = { Fragile = true, Flair = true }, -- Blazing Shadowflame Cinder
	[205419] = { Props = true, Noisy = true }, -- Dinn's Drum
	[205463] = { Props = true }, -- Molten Lava Ball
	[205688] = { Flair = true, Props = true }, -- Glutinous Glitterscale Glob
	[205796] = { Props = true, Incap = true }, -- Molten Lava Pack
	[205904] = { Fight = true }, -- Vibrant Clacking Claw
	[205908] = { Props = true }, -- Inherited Wisdom of Senegos
	[205963] = { Props = true }, -- Sniffin' Salts
	[206008] = { Props = true, Banner = true }, -- Nightmare Banner
	[206038] = { Props = true }, -- Flamin' Ring of Flashiness
	[206043] = { Props = true, Critter = true }, -- Fyrakk's Frenzy
	[206195] = { Hearth = true }, -- Path of the Naaru
	[206268] = { Props = true }, -- Ethereal Transmogrifier
	[206347] = { Fragile = true }, -- Mannequin Charm
	[206565] = { Fragile = true, Props = true }, -- Plagued Grain
	[206696] = { Flair = true, Hats = true }, -- Tricked-Out Thinking Cap
	[206993] = { Full = true, Flair = true }, -- Investi-gator's Pocketwatch
	[207092] = { Props = true, Placed = true, Firework = true, Snacks = true }, -- Portable Party Platter
	[207730] = { Flair = true }, -- Idol of Ohn'ahra
	[208058] = { Props = true }, -- Minute Glass
	[208092] = { Props = true }, -- Torch of Pyrreth
	[208096] = { Props = true }, -- Familiar Journal
	[208186] = { Props = true }, -- Boffins
	[208415] = { Incap = true }, -- Stasis Sand
	[208421] = { Incap = true }, -- Compendium of the New Moon
	[208433] = { Zone = true }, -- Bronze Racer's Pennant
	[208658] = { Full = true }, -- Mirror of Humility
	[208704] = { Hearth = true }, -- Deepdweller's Earthen Hearthstone
	[208798] = { Zone = true }, -- Recorded Memories of Tyr's Guard
	[208825] = { Props = true }, -- Junior Timekeeper's Racing Belt
	[208883] = { Incap = true }, -- Sandbox Storm Gryphon
	[209035] = { Hearth = true }, -- Hearthstone of the Flame
	[209859] = { Props = true }, -- Festive Trans-Dimensional Bird Whistle
	[209944] = { Props = true }, -- Friendsurge Defenders
	[210411] = { Props = true }, -- Fast Growing Seed
	[210455] = { Hearth = true }, -- Draenic Hologem
	[210467] = { Full = true, Props = true }, -- Magical Murkmorpher
	[210656] = { Flair = true }, -- Winter Veil Socks
	[210725] = { Props = true }, -- Owl Post
	[210864] = { Props = true }, -- Improvised Leafbed
	[210975] = { Props = true }, -- Date Simulation Modulator
	[211864] = { Rafts = true }, -- Exquisite Love Boat
	[211931] = { Props = true }, -- Abyss Caller Horn
	[211946] = { Props = true }, -- Hearthstone Game Table
	[212337] = { Hearth = true }, -- Stone of the Hearth
	[212500] = { Props = true, Fall = true }, -- Delicate Silk Parasol
	[212518] = { Flair = true, Mount = true }, -- Vial of Endless Draconic Scales
	[212523] = { Props = true, Fall = true }, -- Delicate Jade Parasol
	[212524] = { Props = true, Fall = true }, -- Delicate Crimson Parasol
	[212525] = { Props = true, Fall = true }, -- Delicate Ebony Parasol
	[215145] = { Corpse = true }, -- Remembrance Stone
	[215147] = { Critter = true }, -- Beautification Iris
	[216881] = { Fragile = true }, -- Duck Disguiser
	[217723] = { Flair = true }, -- Fury of Xuen
	[217724] = { Flair = true }, -- Kindness of Chi-Ji
	[217725] = { Flair = true }, -- Essence of Yu'lon
	[217726] = { Flair = true }, -- Fortitude of Niuzao
	[218112] = { Props = true, Seat = true }, -- Colorful Beach Chair
	[218308] = { Props = true }, -- Winter Veil Cracker
	[218310] = { Props = true }, -- Box of Puntables
	[219387] = { Props = true, Placed = true, Firework = true, Dummy = true }, -- Barrel of Fireworks
	[219403] = { Props = true, Fires = true }, -- Stonebound Lantern
	[220692] = { Props = true, Incap = true }, -- X-treme Water Blaster Display
	[220777] = { Flair = true, Props = true }, -- Cherry Blossom Trail
	[221962] = { Fragile = true, Incap = true }, -- Defective Escape Pod
	[221964] = { Fragile = true, Props = true }, -- Filmless Camera
	[221966] = { Ports = true, Eng = true }, -- Wormhole Generator: Khaz Algar
	[223146] = { Props = true }, -- Satchel of Stormborn Seeds
	[223312] = { Friends = true }, -- Trusty Hat
	[224192] = { Fragile = true, Incap = true }, -- Practice Ravager
	[224251] = { Props = true, Summon = true, Clone = true }, -- Memory Chord
	[224552] = { Props = true }, -- Cave Spelunker's Torch
	[224554] = { Fragile = true, Flair = true }, -- Silver Linin' Scepter
	[224643] = { Pets = true, Fires = true }, -- Pet-Sized Candle
	[224783] = { Full = true, Flair = true, Props = true }, -- Sovereign's Finery Chest
	[225347] = { Props = true }, -- Web-Vandal's Spinning Wheel
	[225547] = { Flair = true }, -- Toxic Victory
	[225556] = { Props = true, Placed = true, Dummy = true }, -- Ancient Construct
	[225641] = { Fight = true }, -- Illusive Kobyss Lure
	[225659] = { Fragile = true, Incap = true }, -- Arathi Book Collection
	[225910] = { Fight = true }, -- Pileus Delight
	[225969] = { Corpse = true }, -- Forged Flag of Victory
	[226191] = { Pets = true }, -- Web Pet Leash
	[226373] = { Full = true }, -- Everlasting Noggenfogger Elixir
	[228413] = { Flair = true, Props = true }, -- Lampyridae Lure
	[228698] = { Props = true, Incap = true }, -- Candleflexer's Dumbbell
	[228705] = { Fight = true }, -- Arachnoserum
	[228706] = { Incap = true }, -- Rockslidomancer's Stone
	[228707] = { Props = true, Incap = true }, -- Trial of Burning Light
	[228789] = { Flair = true, Rings = true }, -- Coldflame Ring
	[228914] = { Flair = true }, -- Arachnophile Spectacles
	[228940] = { Hearth = true }, -- Notorious Thread's Hearthstone
	[228966] = { Flair = true, Hats = true }, -- Starry-Eyed Goggles
	[229828] = { Props = true }, -- 20th Anniversary Balloon Chest
	[230727] = { Props = true }, -- Explosive Victory
	[230850] = { Ports = true }, -- Delve-O-Bot 7001
	[230924] = { Props = true }, -- Spotlight Materializer 1000
	[231064] = { Zone = true }, -- Throwaway Gangster Disguise
	[233202] = { Fight = true }, -- G.O.L.E.M, Jr.
	[233486] = { Props = true }, -- Hallowfall Supply Cache
	[234473] = { Incap = true, Seat = true }, -- Soweezi's Comfy Lawn Chair
	[234950] = { Full = true, Props = true }, -- Atomic Regoblinator
	[234951] = { Flair = true, Props = true, Placed = true, Snacks = true }, -- Uncracked Cold Ones
	[235015] = { Props = true }, -- Awakened Supply Crate
	[235016] = { Hearth = true }, -- Redeployment Module
	[235017] = { Fight = true }, -- Glittering Vault Shard
	[235041] = { Flair = true, Rings = true }, -- Cyrce's Circlet
	[235050] = { Props = true, Incap = true }, -- Desk-in-a-Box
	[235220] = { Fragile = true, Flair = true, Hats = true, Firework = true }, -- Fireworks Hat
	[235669] = { Props = true, Banner = true }, -- Steamwheedle Cartel Banner
	[235670] = { Props = true, Banner = true }, -- Bilgewater Cartel Banner
	[235671] = { Props = true, Banner = true }, -- Blackwater Cartel Banner
	[235672] = { Props = true, Banner = true }, -- Venture Co. Banner
	[235799] = { Friends = true }, -- Throwin' Sawblade
	[235801] = { Rafts = true }, -- Personal Fishing Barge
	[235807] = { Props = true }, -- Storefront-in-a-Box
	[236687] = { Hearth = true }, -- Explosive Hearthstone
	[236749] = { Flair = true, Props = true }, -- Take-Home Torq
	[236751] = { Flair = true, Props = true }, -- Take-Home Flarendo
	[236769] = { Props = true }, -- Gallagio Pipeline Rerouter
	[237345] = { Lures = true }, -- Limited Edition Rocket Bobber
	[237346] = { Lures = true }, -- Artisan Beverage Goblet Bobber
	[237347] = { Lures = true }, -- Organically-Sourced Wellington Bobber
	[237382] = { Flair = true, Props = true }, -- Undermine Supply Crate
	[238850] = { Props = true }, -- Arathi Entertainer's Flame
	[238852] = { Props = true, Banner = true }, -- Flame's Radiance Banner
	[239007] = { Props = true, Banner = true }, -- Dastardly Banner
	[239018] = { Props = true }, -- Winner's Podium
	[239693] = { Mail = true }, -- Radiant Lynx Whistle
	[242323] = { Flair = true }, -- Chowdar's Favorite Ribbon
	[242520] = { Props = true }, -- Festival Hot Air Balloon
	[243056] = { Ports = true }, -- Delver's Mana-Bound Ethergate
	[243146] = { Fragile = true, Flair = true }, -- Ren'dorei Struggle
	[243304] = { Incap = true }, -- Jubilant Snowman Costume
	[244470] = { Fragile = true, Flair = true }, -- Etheric Victory
	[244792] = { Full = true, Props = true }, -- Etheric Brannmorpher
	[244888] = { Props = true }, -- Echo of Xal'atath, Blade of the Black Empire
	[245567] = { Fragile = true }, -- K'aresh Memory Crystal
	[245580] = { Props = true, Incap = true }, -- Rolling Snowball
	[245631] = { Full = true }, -- Royal Visage
	[245942] = { Props = true }, -- Sea-Blessed Shrine
	[245946] = { Props = true }, -- Brewer's Balloon
	[245970] = { Hearth = true }, -- P.O.S.T. Master's Express Hearthstone
	[246227] = { Props = true }, -- Lightning-Blessed Spire
	[246565] = { Hearth = true }, -- Cosmic Hearthstone
	[246903] = { Full = true, Flair = true }, -- Guise of the Phase Diver
	[246905] = { Flair = true }, -- Overtuned K'areshi Goggles
	[246907] = { Flair = true, Props = true }, -- Broker Supply Crate
	[246908] = { Flair = true, Props = true }, -- K'areshi Supply Crate
	[249468] = { Incap = true }, -- Twilight's Blade Top Secret Strategy Training Guide
	[249713] = { Fight = true }, -- Cartel Transmorpher
	[250319] = { Flair = true }, -- Researcher's Shadowgraft
	[250722] = { Props = true }, -- Ethereal Stall
	[250974] = { Fragile = true, Flair = true }, -- Akil'zon's Updraft
	[251491] = { Pets = true }, -- Magical Pet Clicker
	[251633] = { Props = true }, -- Bursting Bounty Bundle
	[251903] = { Fragile = true, Flair = true, Props = true }, -- Potatoad Egg
	[252265] = { Fragile = true }, -- Hexed Potatoad Mucus
	[253629] = { Ports = true }, -- Personal Key to the Arcantina
	[256552] = { Fragile = true, Incap = true }, -- Verdant Rutaani Seed
	[256881] = { Fragile = true }, -- Steward's Bauble
	[256893] = { Fragile = true }, -- Wretched Dredger's Brand
	[257736] = { Hearth = true }, -- Lightcalled Hearthstone
	[258963] = { Zone = true, Fall = true }, -- Shroom Jumper's Parachute
	[259084] = { Props = true }, -- Gift of the Cycle
	[259240] = { Flair = true, Props = true }, -- Sin'dorei Wine
	[259899] = { Fragile = true, Incap = true }, -- Ashen Horn of the Fallen Keeper
	[260170] = { Props = true }, -- Oathstone Fragment
	[262431] = { Props = true, Zone = true }, -- Bouncy Mushroom
	[263198] = { Props = true }, -- Valdekar's Special
	[263244] = { Props = true }, -- Enigmatic Fountain
	[263871] = { Pets = true }, -- Holy Pet Leash
	[263933] = { Hearth = true }, -- Preyseeker's Hearthstone
	[264367] = { Hearth = true }, -- Mycomancer's Hearthspore
	[264413] = { Fragile = true, Flair = true }, -- Dominating Victory
	[264414] = { Zone = true }, -- Midnight Delver's Flare Gun
	[264517] = { Corpse = true }, -- Galactic Flag of Victory
	[264666] = { Corpse = true }, -- Rod of Exanguishation
	[264672] = { Corpse = true }, -- Cosmic Ritual Stone
	[264695] = { Mail = true }, -- Interdimensional Parcel Signal
	[264805] = { Flair = true }, -- Brann-O-Vision 3000
	[265100] = { Hearth = true }, -- Corewarden's Hearthstone
	[267139] = { Incap = true }, -- Hungry Black Hole
	[267323] = { Incap = true }, -- Troll Scroll of Rainbow Roll
	[267456] = { Flair = true, Props = true }, -- Lil' Scoots' Pillow
	[268717] = { Fight = true }, -- Pango Plating
	[272287] = { Props = true, Incap = true }, -- Nap Mat
}

-- RandomToysToys.lua -- scans the player's toy collection into rtToys /
-- rtToysByID, keeps them synced with saved per-toy set data, and drives
-- the Toy Box cross-links. Collection state is published on RT.

local rtConfigMode = RT.configMode
local CATEGORIES = RT.CATEGORIES

--------------------------------------------------------------------
-- Toy List [built from the player's collection, not hardcoded]
--   rtToys    : sorted array of {id, name, icon, spellId, owned}
--   rtToysByID: id -> same table, for quick lookup
--------------------------------------------------------------------

local rtToys, rtToysByID = {}, {}
local rtToysBySpell = {}
-- Every toy in the game (owned + unowned) with just an ownership flag, kept
-- regardless of the Uncollected display toggle so the header progress bar
-- can show an accurate collected/total for any set. Built alongside rtToys
-- in BuildToyList --
local rtAllToys = {}

-- GetNumToys/GetToyFromIndex read through the account's saved Collection
-- Journal filters (collected-only, source, expansion, search text) -- not
-- a true unfiltered list. We force every filter open, read the list, then
-- put the player's real filter settings back the way we found them.
local rtToyFilterString = ""
hooksecurefunc(C_ToyBox, "SetFilterString", function(s) rtToyFilterString = s or "" end)

-- The real Toy Box UI (Blizzard_Collections) only seeds its own sane
-- filter defaults (Collected shown, everything else on) the first time
-- its actual frame is shown -- which may never happen at all for a player
-- who only ever uses this addon's own panel. If we scan before that's
-- happened, every filter reads as an honest "false" (not nil), and a pure
-- save/restore just perpetuates that same all-hidden state forever, since
-- nothing else in a normal session would ever flip it to something sane.
-- So: establish sane defaults ourselves exactly once per login/reload,
-- then trust and preserve whatever's there (ours or the player's own
-- later edits via the real Journal) on every call after that.
local rtFiltersBaselined = false

-- Every SetFilter/ForceToyRefilter call below fires TOYS_UPDATED. Our own
-- event handler reacts to TOYS_UPDATED by rescanning -- which refilters --
-- which fires TOYS_UPDATED again, looping forever and churning garbage every
-- frame (the "memory keeps climbing" symptom). We arm this short window at the
-- start of every scan and ignore the TOYS_UPDATED echoes that land inside it.
-- NEW_TOY_ADDED is never an echo (refiltering doesn't fire it), so it's always
-- honored regardless of this window. Published on RT because the TOYS_UPDATED
-- handler that consults it lives in the UI file.
RT.suppressRescanUntil = 0

function RT.BuildToyList()
	wipe(rtToys)
	wipe(rtToysByID)
	wipe(rtToysBySpell)
	wipe(rtAllToys)

	-- Arm the echo-suppression window before we touch any filters (see
	-- rtSuppressRescanUntil). Covers the whole scan plus the TOYS_UPDATED
	-- events it queues, which arrive over the next frame or two.
	RT.suppressRescanUntil = GetTime() + 0.5

	local tx = C_ToyBox
	local savedFilterString = rtToyFilterString
	local savedCollected, savedUncollected = tx.GetCollectedShown(), tx.GetUncollectedShown()
	local savedSources, savedExpansions = {}, {}
	for i = 1, C_PetJournal.GetNumPetSources() do
		savedSources[i] = tx.IsSourceTypeFilterChecked(i)
	end
	for i = 1, GetNumExpansions() do
		savedExpansions[i] = tx.IsExpansionTypeFilterChecked(i)
	end

	-- A snapshot in which nothing could ever display isn't a player
	-- choice -- it's the unseeded/reset state (see rtFiltersBaselined)
	-- showing up again. Loading screens can hand us exactly that even
	-- after we've baselined once: a TOYS_UPDATED mid-port schedules a
	-- rescan while the toy box's session filter state still reads
	-- all-false, and restoring that writes the zeroed state back for
	-- real (the "every Toy Box filter unchecked after teleporting" bug).
	-- So a degenerate snapshot is never restored -- it falls through to
	-- the sane-defaults branch below. Costs one edge case: a player who
	-- deliberately unchecks EVERY source (or expansion) box will find
	-- them reset to all-on, which beats silently zeroing filters.
	local anySource, anyExpansion = false, false
	for i = 1, #savedSources do
		if savedSources[i] then anySource = true break end
	end
	for i = 1, #savedExpansions do
		if savedExpansions[i] then anyExpansion = true break end
	end
	local snapshotSane = (savedCollected or savedUncollected) and anySource and anyExpansion

	tx.SetFilterString("")
	tx.SetCollectedShown(true)
	-- Enumerate the full universe (owned + unowned) every scan so rtAllToys
	-- is complete for the set counts; rtToys is still filtered to the
	-- player's Uncollected preference by the owned/showAllToys gate below --
	tx.SetUncollectedShown(true)
	tx.SetAllSourceTypeFilters(true)
	tx.SetAllExpansionTypeFilters(true)
	tx.ForceToyRefilter()

	for i = 1, tx.GetNumFilteredToys() do
		local id = tx.GetToyFromIndex(i)
		if id and id > 0 then
			local owned = PlayerHasToy(id)
			rtAllToys[#rtAllToys + 1] = {id = id, owned = owned}
			if owned or rtSettings.showAllToys then
				local _, name, icon = tx.GetToyInfo(id)
				if name then
					local _, spellId = C_Item.GetItemSpell(id)
					local data = {id = id, name = name, icon = icon, spellId = spellId, owned = owned,
						favorited = owned and tx.GetIsFavorite(id) or false}
					table.insert(rtToys, data)
					rtToysByID[id] = data
					if spellId then rtToysBySpell[spellId] = data end
				end
			end
		end
	end

	tx.SetFilterString(savedFilterString)
	if rtFiltersBaselined and snapshotSane then
		-- Already established a real baseline this session and the
		-- snapshot is a state a player could actually be using --
		-- preserve exactly what we captured.
		tx.SetCollectedShown(savedCollected)
		tx.SetUncollectedShown(savedUncollected)
		for i = 1, C_PetJournal.GetNumPetSources() do
			tx.SetSourceTypeFilter(i, savedSources[i])
		end
		for i = 1, GetNumExpansions() do
			tx.SetExpansionTypeFilter(i, savedExpansions[i])
		end
	else
		-- Either the first scan this session (Blizzard's own defaults may
		-- never have been seeded -- see rtFiltersBaselined) or we caught
		-- the filters in a degenerate reset state mid-loading-screen. Set
		-- sane defaults instead of preserving a state that was never
		-- actually initialized by anything.
		tx.SetCollectedShown(true)
		tx.SetUncollectedShown(true)
		tx.SetAllSourceTypeFilters(true)
		tx.SetAllExpansionTypeFilters(true)
		rtFiltersBaselined = true
	end
	tx.ForceToyRefilter()

	RT.SortToyList()
end

-- Owned toys lead, uncollected trail, alphabetical within each group.
-- With Favorites first on, the player's Toy Box favorites (always
-- owned) rise to the very top -- still alphabetical among themselves.
-- Split out of BuildToyList so the checkbox can re-sort without a full
-- collection rescan --
function RT.SortToyList()
	local favoritesFirst = rtSettings and rtSettings.favoritesFirst
	table.sort(rtToys, function(a, b)
		if favoritesFirst and a.favorited ~= b.favorited then return a.favorited end
		if a.owned ~= b.owned then return a.owned end
		return a.name < b.name
	end)
end

function RT.SyncNewToysIntoOptions()
	for i = 1, #rtToys do
		local id = rtToys[i].id
		local opt = rtOptions[id]
		if type(opt) == "boolean" then
			-- migrate from the oldest single-pool format
			local converted = {}
			for _, cat in ipairs(CATEGORIES) do converted[cat] = {list = opt, pool = opt} end
			opt = converted
			rtOptions[id] = opt
		elseif type(opt) ~= "table" then
			opt = {}
			rtOptions[id] = opt
		end
		-- "Any" is fully derived (see GetAnyToyIds) -- it never stores
		-- per-toy data of its own, so wipe any stale entry an old save
		-- (from when it briefly had its own storage) might still carry --
		opt["Any"] = nil
		for _, cat in ipairs(CATEGORIES) do
			local entry = opt[cat]
			if type(entry) == "boolean" then
				-- migrate from the single-flag-per-category format
				opt[cat] = {list = entry, pool = entry}
			elseif type(entry) ~= "table" then
				-- First time we've seen this toy in this category (fresh
				-- install, or a toy just acquired). Seed both flags from the
				-- shipped default: list = true makes it a candidate, and
				-- pool = true activates it straight into rotation, so an
				-- enabled category works out of the box instead of showing
				-- an empty pool until the user hand-toggles every toy. Toys
				-- not shipped in this category seed to false/false as before.
				local shipped = RT_DEFAULT_TOY_LISTS[id]
				local seedList = shipped and shipped[cat] or false
				opt[cat] = {list = seedList, pool = seedList}
			else
				if entry.list == nil then entry.list = false end
				if entry.pool == nil then entry.pool = false end
			end

			-- "All" means literally every toy -- it ignores curated/shipped
			-- list membership entirely and is always considered "in the
			-- list", so nothing needs to be individually checked into it --
			if cat == "All" then
				opt[cat].list = true
			end
		end

		-- Shipped tag membership is curated via the defaults file and
		-- locked in the UI, so it's authoritative on every load -- NOT
		-- just seeded at first sight like it used to be. Without this, a
		-- defaults update never reaches an existing save: entries seeded
		-- under an older defaults file kept their stale list flags
		-- forever (the "shipped six show nothing on machines with old
		-- saved variables" bug). Pool stays the player's own: preserved
		-- wherever the toy keeps its tag, seeded active when a defaults
		-- update newly grants one. Config mode is exempt from the
		-- reconcile -- that's where the defaults themselves get curated,
		-- and forcing them back on every load would wipe un-exported
		-- work between Export Defaults runs.
		local shipped = RT_DEFAULT_TOY_LISTS[id]
		for _, tag in ipairs(RT_DEFAULT_TAGS) do
			local shouldHave = shipped and shipped[tag] and true or false
			local entry = opt[tag]
			if not entry then
				-- First sight of this toy/tag pair (tags beyond the six
				-- macro categories stay sparse -- no entry until one is
				-- warranted) --
				if shouldHave then
					opt[tag] = {list = true, pool = true}
				end
			elseif not rtConfigMode and (entry.list and true or false) ~= shouldHave then
				entry.list = shouldHave
				if shouldHave then
					entry.pool = true
				end
			end
		end
	end
end

--------------------------------------------------------------------
-- C_ToyBox's filter functions (SetFilterString, SetAllSourceTypeFilters,
-- ForceToyRefilter, etc.) are backed by Blizzard_Collections, which is
-- load-on-demand -- on a fresh install the player hasn't necessarily
-- opened the Collections Journal yet this session, so it may not be
-- loaded when BuildToyList first runs, leaving the toy list empty.
--------------------------------------------------------------------
function RT.EnsureCollectionsLoaded()
	if not C_AddOns.IsAddOnLoaded("Blizzard_Collections") then
		C_AddOns.LoadAddOn("Blizzard_Collections")
	end
end

--------------------------------------------------------------------
-- Opens the Collections Journal on the Toy tab and searches it down to
-- a single toy, so middle-clicking a cell lands you right on that toy
-- ready to use. The filter is applied a frame later (C_Timer.After(0)):
-- opening the Toy Box runs its own OnShow, which seeds default filters --
-- deferring lets ours win instead of being overwritten. Assigns the
-- local forward-declared up top so the toy cells' OnClick binds it.
--------------------------------------------------------------------
function RT.OpenToyInToyBox(toyID)
	RT.EnsureCollectionsLoaded()
	local info = rtToysByID[toyID]
	local toyName = info and info.name
	if not toyName then
		local _, name = C_ToyBox.GetToyInfo(toyID)
		toyName = name
	end
	if not (CollectionsJournal and CollectionsJournal:IsShown()) then
		ToggleCollectionsJournal(3) -- tab order: Mounts, Pets, Toys, Heirlooms
	else
		CollectionsJournal_SetTab(CollectionsJournal, 3)
	end
	if toyName and toyName ~= "" then
		C_Timer.After(0, function()
			-- Drive the Toy Box's own search box so the text is visible and
			-- its clear "X" button appears (SetText fires OnTextChanged, which
			-- applies the filter). Also hit the filter API directly as a
			-- belt-and-suspenders in case the search box moves/renames --
			local searchBox = ToyBox and ToyBox.searchBox
			if searchBox then
				searchBox:SetText(toyName)
			end
			C_ToyBox.SetFilterString(toyName)
			C_ToyBox.ForceToyRefilter()
		end)
	end
end

--------------------------------------------------------------------
-- Coalesces a burst of collection-change events into a single rescan a
-- short debounce later, instead of rescanning once per event. BuildToyList
-- arms rtSuppressRescanUntil, so the TOYS_UPDATED echoes its own refiltering
-- produces are ignored (see the note there) rather than looping back in.
--------------------------------------------------------------------
local rtRescanScheduled = false
function RT.RequestRescan()
	if rtRescanScheduled then return end
	rtRescanScheduled = true
	C_Timer.After(0.2, function()
		rtRescanScheduled = false
		if not rtOptions then return end
		RT.BuildToyList()
		RT.SyncNewToysIntoOptions()
		RT.RebuildToyRows()
	end)
end

RT.toys = rtToys
RT.toysByID = rtToysByID
RT.toysBySpell = rtToysBySpell
RT.allToys = rtAllToys


-- RandomToysMacros.lua -- the per-set toy rotation: builds each set's
-- usable-toy pool, rolls a random non-repeating pick, and keeps that
-- set's macro pointed at it. Also owns the spellcast/zone event handling.
local rtToys = RT.toys
local rtToysByID = RT.toysByID

-- Per-set rotation state, keyed by set name and populated lazily. Any
-- set (shipped or custom) can drive a macro. rtCatCurrentToy is published
-- on RT so the UI's rail rows can mirror the current pick for middle-click.
local rtCatList, rtCatSrc, rtCatPrevToy, rtCatCurrentToy = {}, {}, {}, {}
RT.catCurrentToy = rtCatCurrentToy

--------------------------------------------------------------------
-- Removes a category's macro entirely once its "Maintain macro"
-- toggle is switched off, rather than leaving it orphaned/stale.
--------------------------------------------------------------------
function RT.DeleteCategoryMacro(cat)
	if InCombatLockdown() then return end
	local macroIndex = GetMacroIndexByName(RT.MacroNameForSet(cat))
	if macroIndex > 0 then
		DeleteMacro(macroIndex)
	end
end

--------------------------------------------------------------------
-- Assigned methods to the UI Panel's Confirm/Okay
--------------------------------------------------------------------
function RT.RCOptionsOkay()
	for cat, enabled in pairs(rtSettings.categoryEnabled) do
		if enabled then
			RT.RefreshCategoryPool(cat)
			RT.SelectRandomToysToy(cat)
		end
	end
end

-- rtCatList/rtCatSrc/rtCatPrevToy/rtCatCurrentToy (this module's rotation
-- state) are declared up near the rail buttons now -- see the comment
-- there -- since a rail row's own secure click needs them too.

-- Wipes everything a set's macro left behind at runtime -- called (by
-- global name) when a custom tag is renamed or deleted, so a stale
-- rtCatCurrentToy entry can't resurrect the old name's macro from a
-- spellcast event --
function RT.ReleaseSetRotationState(cat)
	rtCatList[cat] = nil
	rtCatSrc[cat] = nil
	rtCatPrevToy[cat] = nil
	rtCatCurrentToy[cat] = nil
end

local function Trace(fmt, ...)
	if not RT.debug then return end
	print(("|cff58C6FART|r [%.2f] " .. fmt):format(GetTime() % 1000, ...))
end

--------------------------------------------------------------------
local REROLL_FAIL_SETTLE = 2 -- seconds a failed cast settles before rolling
local WATCHDOG_DELAY = 0.6   -- no client response by now means silently blocked
local PRESS_MEMORY = 4       -- seconds a press stays known, for echo-matching

local rtCatPress = {}

local function NotePress(cat)
	local existing = rtCatPress[cat]
	local token = GetTime()
	-- The macro's second /click lands in the same frame as the first, so
	-- it shares GetTime() -- one press, not two --
	if existing and existing.token == token then return existing end
	local press = {token = token, answered = false, rolled = false}
	rtCatPress[cat] = press
	C_Timer.After(PRESS_MEMORY, function()
		if rtCatPress[cat] == press then rtCatPress[cat] = nil end
	end)
	Trace("[%s] press noted", cat)
	return press
end

-- The client answered this press (cast started, succeeded, or failed), so
-- the watchdog has nothing to catch --
local function NoteAnswered(cat)
	local press = rtCatPress[cat]
	if press then press.answered = true end
end

-- True while a roll is queued for this set but hasn't landed yet --
local rtCatRollInFlight = {}

-- Rolls once for the press in flight. Rolls nothing (returning false) when
-- the request is an echo of a press that already rolled, or when a roll is
-- already on its way for this set.
local function RollForPress(cat, delay, why)
	local press = rtCatPress[cat]
	if press then
		if press.rolled then
			Trace("[%s] %s -- dropped, already rolled for this press", cat, why or "?")
			return false
		end
		press.rolled = true
	end
	-- Spamming a macro whose toy the client keeps turning down is one press
	-- per repeat, and each would otherwise queue its own roll on the failure
	-- path's ~2s delay -- so a two-second burst of key-repeat lands a dozen
	-- rolls in a row a moment later, churning the macro and burning the pool
	-- down several passes deep. A roll already on its way covers every press
	-- that arrives before it lands --
	if rtCatRollInFlight[cat] then
		Trace("[%s] %s -- dropped, a roll is already on its way", cat, why or "?")
		return false
	end
	Trace("[%s] rolling: %s (in %.1fs)", cat, why or "?", delay or 0)
	local function fire()
		rtCatRollInFlight[cat] = nil
		-- No combat check here: SelectRandomToysToy parks itself past a
		-- lockdown now, so a roll landing mid-fight is caught up when combat
		-- ends rather than dropped on the floor as it used to be --
		RT.SelectRandomToysToy(cat)
	end
	if delay and delay > 0 then
		rtCatRollInFlight[cat] = true
		C_Timer.After(delay, fire)
	else
		fire()
	end
	return true
end

--------------------------------------------------------------------
-- The invisible secure button a set's macro clicks, created the first
-- time that set needs one. The frame name goes into the macro body as
-- "/click <name>", where spaces act as argument separators -- so names
-- are stripped to alphanumerics, with a numeric suffix if two sets
-- collide there ("My Tag" vs "MyTag").
--------------------------------------------------------------------
local RT_COMBAT_PRE = [[ return nil, "rt" ]]
local RT_COMBAT_POST = [[
	if button ~= "LeftButton" then return end
	if self:GetAttribute("rtCombatOff") then return end
	local combat = self:GetAttribute("state-rtcombat")
	if combat ~= 1 and combat ~= "1" then return end
	local order = self:GetAttribute("rtOrder")
	if not order or order == "" then return end
	local count = select("#", strsplit(",", order))
	if count < 1 then return end
	local pos = (tonumber(self:GetAttribute("rtPos")) or 0) + 1
	if pos > count then pos = 1 end
	self:SetAttribute("rtPos", pos)
	local id = tonumber((select(pos, strsplit(",", order))))
	if id then self:SetAttribute("toy", id) end
]]

local function InstallCombatRotation(btn)
	if not (SecureHandlerWrapScript and RegisterStateDriver) then return end
	-- Tells the snippet whether we're in a lockdown; the restricted
	-- environment can't ask directly --
	RegisterStateDriver(btn, "rtcombat", "[combat] 1; 0")
	SecureHandlerWrapScript(btn, "OnClick", btn, RT_COMBAT_PRE, RT_COMBAT_POST)
end

local rtCatButtons = {}
local function EnsureCatButton(cat)
	local btn = rtCatButtons[cat]
	if btn then return btn end
	local base = "rtButton" .. cat:gsub("%W", "")
	local name = base
	local n = 2
	while _G[name] do
		name = base .. n
		n = n + 1
	end
	btn = CreateFrame("Button", name, nil, "SecureActionButtonTemplate,SecureHandlerBaseTemplate")
	btn:RegisterForClicks("LeftButtonDown", "LeftButtonUp")
	btn:SetAttribute("type", "toy")
	InstallCombatRotation(btn)
	btn:HookScript("PostClick", function()
		local press = NotePress(cat)
		C_Timer.After(WATCHDOG_DELAY, function()
			if rtCatPress[cat] ~= press or press.answered then return end
			-- The client said nothing at all, which means it turned the use
			-- down without a word -- count that against the toy --
			local cur = rtCatCurrentToy[cat]
			if cur and RT.NoteToyFailure(cur.id) then
				Trace("[%s] %s has failed enough times -- deprioritising it", cat, cur.name)
			end
			RollForPress(cat, 0, "silent-block watchdog")
		end)
	end)
	rtCatButtons[cat] = btn
	return btn
end

--------------------------------------------------------------------
local rtCatCombatDeferred = {}

local function DeferPastCombat(cat, why)
	rtCatCombatDeferred[cat] = true
	Trace("[%s] %s deferred -- in combat", cat, why)
end

-- Called from the UI's PLAYER_REGEN_ENABLED handler (by name) --
function RT.ReplayDeferredRotation()
	local pending = rtCatCombatDeferred
	rtCatCombatDeferred = {}
	for cat in pairs(pending) do
		Trace("[%s] combat over -- catching the rotation up", cat)
		RT.RefreshCategoryPool(cat)
		RT.SelectRandomToysToy(cat)
	end
	RT.EnsureRotationsReady()
end

-- An enabled set with no current toy has never had its rotation built this
-- session, which also means EnsureCatButton hasn't run and the secure button
-- its macro clicks doesn't exist yet -- pressing it does nothing at all.
-- Entering the world already in combat does exactly that: the handler below
-- bails on the lockdown, and PLAYER_ENTERING_WORLD doesn't come back around
-- until the next loading screen. Cheap to check, so check it whenever combat
-- drops rather than leaving the macro dead for the session.
function RT.EnsureRotationsReady()
	if InCombatLockdown() or not (rtSettings and rtSettings.categoryEnabled) then return end
	for cat, enabled in pairs(rtSettings.categoryEnabled) do
		-- Whatever the snippet advanced to during the fight is the truth
		-- now; take it before deciding anything else --
		if enabled then RT.AdoptCombatRotation(cat) end
		if enabled and not rtCatCurrentToy[cat] then
			Trace("[%s] no rotation yet this session -- building it now", cat)
			RT.RefreshCategoryPool(cat)
			RT.SelectRandomToysToy(cat)
		end
	end
end

--------------------------------------------------------------------
-- Generate the list of valid, enabled toys for one category
--------------------------------------------------------------------
function RT.RefreshCategoryPool(cat)
	if InCombatLockdown() then return DeferPastCombat(cat, "pool refresh") end
	local list
	if cat == "Any" then
		-- Already applies the same PlayerHasToy/IsToyUsable filtering --
		list = RT.GetAnyToyIds()
	elseif cat == "Favs" then
		-- Same deal -- already applies PlayerHasToy/IsToyUsable filtering --
		list = RT.GetFavToyIds()
	else
		list = {}
		for i = 1, #rtToys do
			local data = rtToys[i]
			local opt = rtOptions[data.id]
			local entry = opt and opt[cat]
			if entry and entry.list and entry.pool and PlayerHasToy(data.id) then
				table.insert(list, data.id)
			end
		end
	end
	rtCatList[cat] = list

	if #rtCatList[cat] == 0 then
		rtCatSrc[cat] = "\n/cry"
	else
		local btnName = EnsureCatButton(cat):GetName()
		rtCatSrc[cat] = "\n/click " .. btnName .. " 1\n/click " .. btnName .. " LeftButton 1"
	end
end

--------------------------------------------------------------------
local rtPoolResyncPending = {}

local function ResyncOneSetPool(cat)
	-- Only a set maintaining a macro has a live rotation to correct --
	if not (rtSettings and rtSettings.categoryEnabled and rtSettings.categoryEnabled[cat]) then return end
	if InCombatLockdown() then
		-- Re-rolling rebinds a secure attribute and rewrites the macro,
		-- neither allowed mid-combat -- replay once it ends, the same
		-- pattern the UI's protected refreshes use --
		rtPoolResyncPending[cat] = true
		return
	end
	rtPoolResyncPending[cat] = nil

	local cur = rtCatCurrentToy[cat]
	RT.RefreshCategoryPool(cat)

	-- Is the toy the macro is holding still allowed? Walk the freshly
	-- built pool rather than re-reading the flags, so this agrees with
	-- RefreshCategoryPool on every count (owned, usable, active) --
	local list = rtCatList[cat] or {}
	local curStillActive = false
	if cur then
		for i = 1, #list do
			if list[i] == cur.id then
				-- Still active, so the macro keeps it -- but it counts as
				-- already drawn, so take it back out of the rebuilt pool
				-- and preserve the no-repeats-until-exhausted rotation --
				table.remove(list, i)
				curStillActive = true
				break
			end
		end
	end
	if not curStillActive then
		RT.SelectRandomToysToy(cat)
	end
end

-- Public entry point: call after any edit that changes which toys `cat`
-- may draw --
function RT.ResyncPoolsForSetEdit(cat)
	if not cat then return end
	ResyncOneSetPool(cat)
	-- "Any" is the union of every enabled set's active toys, so an edit to
	-- one of them changes what Any may draw too. "Favs" rides on Blizzard's
	-- own favorite flag and is untouched by set membership --
	if cat ~= "Any" and cat ~= "Favs" then
		ResyncOneSetPool("Any")
	end
end

-- Flips in-combat rotation on/off ("/toys combat"). The snippet stays
-- wrapped either way -- it just reads the flag and stands down -- so this is
-- safe to call at any time, though the attribute write itself waits for the
-- lockdown to end like every other one --
function RT.SetCombatRotationEnabled(enabled)
	rtSettings.combatRotation = enabled and true or false
	if not InCombatLockdown() then
		for cat in pairs(rtCatButtons) do PushCombatOrder(cat) end
	end
	return rtSettings.combatRotation
end

-- Called from the UI's PLAYER_REGEN_ENABLED handler (by name) to replay
-- resyncs that landed mid-combat --
function RT.ReplayDeferredPoolResync()
	local pending = rtPoolResyncPending
	rtPoolResyncPending = {}
	for cat in pairs(pending) do
		ResyncOneSetPool(cat)
	end
end

--------------------------------------------------------------------
-- "/toys why [set]" -- prints, per toy, every test RefreshCategoryPool
-- applies and which one rejected it. A set whose macro rolls /cry has an
-- empty pool, and the four reasons a toy can be missing (not in the set's
-- list, not activated, not owned, not usable right now) are invisible from
-- the grid: a cell can look checked and active and still never be drawn,
-- because owned-and-activated isn't the same as usable.
--------------------------------------------------------------------
function RT.ExplainCategoryPool(cat)
	local inSet, inPool = 0, 0
	print('|cff58C6FARandom Toys|r: pool report for "' .. cat .. '"')
	for i = 1, #rtToys do
		local data = rtToys[i]
		local entry = rtOptions[data.id] and rtOptions[data.id][cat]
		if entry and entry.list then
			inSet = inSet + 1
			local owned = PlayerHasToy(data.id) and true or false
			local rejected
			if not entry.pool then rejected = "not activated"
			elseif not owned then rejected = "not owned"
			end
			if rejected then
				print(("   |cffff5555x|r %s (%d) -- %s"):format(data.name, data.id, rejected))
			else
				inPool = inPool + 1
				-- Not a rejection any more, just a note: the client claiming a
				-- toy is unusable only pushes it to the back of the draw --
				local note = C_ToyBox.IsToyUsable(data.id) == false
					and " (client flags it unusable -- drawn last, not excluded)" or ""
				print(("   |cff40ff40+|r %s (%d) -- in the pool%s"):format(data.name, data.id, note))
			end
		end
	end
	print(("   %d in the set, |cff58C6FA%d in the pool|r%s"):format(inSet, inPool,
		inPool == 0 and " -- this is why the macro rolls /cry" or ""))
end

--------------------------------------------------------------------
-- Gets a random index without allowing the same toy twice in a row,
-- preferring toys that are actually ready to fire.
--
-- The pass itself is unchanged -- every toy in the pool is still drawn
-- exactly once before any repeats (see the table.remove in
-- SelectRandomToysToy). Cooldowns only reorder the pass: a toy that's
-- ticking is passed over for now and comes up later in the same pass,
-- rather than being loaded into the macro where it does nothing when
-- pressed. When every remaining toy is cooling down there's nothing to
-- prefer, so the draw falls back to the whole pool and the rotation
-- keeps moving instead of stalling.
--------------------------------------------------------------------
local GetToyCooldown = (C_Item and C_Item.GetItemCooldown) or GetItemCooldown

local GCD_GRACE = 2

local function ToyIsReady(id)
	local start, duration, enable = GetToyCooldown(id)
	if enable == 0 then return true end
	return not (start and start > 0 and duration and duration > GCD_GRACE)
end

--------------------------------------------------------------------
-- Learning which toys don't actually work.
--
-- There is no dependable way to ask the client whether a toy is usable:
-- IsToyUsable is undocumented and wrong for real toys (see
-- RefreshCategoryPool), and nothing exposes a toy's faction at all --
-- ToyBoxEnhanced ships a hand-maintained faction table rather than derive
-- one. So instead of asking, watch: a toy that fails when it should have
-- worked gets remembered and stops being preferred.
--
-- Two things keep the signal honest, since a failed use usually means
-- "not now" rather than "not ever":
--   * a failure while the toy was on cooldown is explained, so it's not
--     counted -- and cooldown rejections DO come through as cast failures
--     (traced in-game), so this matters
--   * a failure in combat is explained too, since plenty of toys simply
--     can't be used in a fight; only failures out of combat, where the toy
--     should have worked, count against it
-- It also takes several strikes, so one interrupted cast or bad moment
-- doesn't condemn a toy, and the tally is cleared on a zone change --
-- "you can't use that here" is the most common honest failure there is.
--------------------------------------------------------------------
local TOY_FAILURE_LIMIT = 3

local rtToyFailures = {}

-- Called when a use of toyID didn't take. Returns true if this pushed the
-- toy over the limit, so the caller can trace it --
function RT.NoteToyFailure(toyID)
	if not toyID then return false end
	if InCombatLockdown() then return false end   -- combat explains it
	if not ToyIsReady(toyID) then return false end -- cooldown explains it
	local n = (rtToyFailures[toyID] or 0) + 1
	rtToyFailures[toyID] = n
	return n == TOY_FAILURE_LIMIT
end

-- A toy that has failed out of combat, off cooldown, repeatedly --
function RT.ToyLooksUnusable(toyID)
	return (rtToyFailures[toyID] or 0) >= TOY_FAILURE_LIMIT
end

-- A toy that has actually fired at some point. Proof outranks every guess
-- here: it clears the failures we'd counted, and it permanently overrides
-- the client's flag, which we already know it gets wrong for toys that work
-- (the reported Crate of Bobbers case). Kept in saved variables, so a toy
-- proves itself once and stays proven -- re-earning it every login would be
-- the same annoyance in slower motion. Returns true when something actually
-- changed, so the caller can refresh the grid's markers --
local function ToyIsProven(toyID)
	return rtSettings and rtSettings.provenUsableToys
		and rtSettings.provenUsableToys[toyID] or false
end

function RT.NoteToySuccess(toyID)
	if not toyID then return false end
	local changed = rtToyFailures[toyID] ~= nil
	rtToyFailures[toyID] = nil
	if rtSettings then
		rtSettings.provenUsableToys = rtSettings.provenUsableToys or {}
		if not rtSettings.provenUsableToys[toyID] then
			rtSettings.provenUsableToys[toyID] = true
			changed = true
		end
	end
	return changed
end

-- The grid's view of a toy: is it doubtful, and is that doubt our own
-- observation or merely the client's flag? Kept here so the marker and the
-- draw can never disagree about what counts as doubtful --
function RT.ToyDoubt(toyID)
	if RT.ToyLooksUnusable(toyID) then return true, true end
	if ToyIsProven(toyID) then return false, false end
	if C_ToyBox.IsToyUsable(toyID) == false then return true, false end
	return false, false
end

-- Wipes the tally. Zone changes invalidate the most common honest failure
-- ("you can't use that here"), so everything gets a clean slate --
function RT.ForgetToyFailures()
	wipe(rtToyFailures)
end

-- Two different questions, and keeping them apart matters.
--
-- "Looks usable" is about the toy itself: does the client call it unusable,
-- have we watched it fail? A no here is a lasting property.
--
-- "Preferred" adds cooldown, which is temporary -- the toy is fine, just not
-- this second.
--
-- Both are hints, never vetoes: they order the pool, they never empty it --
local function ToyLooksUsable(id)
	return not (RT.ToyDoubt(id))
end

local function ToyIsPreferred(id)
	return ToyLooksUsable(id) and ToyIsReady(id)
end

local function PositionsWhere(list, predicate)
	local hits = {}
	for i = 1, #list do
		if predicate(list[i]) then hits[#hits + 1] = i end
	end
	return hits
end

local function PreferredPositions(list) return PositionsWhere(list, ToyIsPreferred) end
local function UsablePositions(list) return PositionsWhere(list, ToyLooksUsable) end

local function GetRandomIndexForCategory(cat, size)
	local list = rtCatList[cat]
	if size < 1 then return 0 end

	local candidates = PreferredPositions(list)
	if #candidates == 0 then
		-- Nothing preferable left -- draw from the full pool rather than
		-- stall, so an activated toy is always reachable --
		for i = 1, size do candidates[i] = i end
	end

	if #candidates > 1 then
		local slot = math.random(1, #candidates)
		if list[candidates[slot]] == rtCatPrevToy[cat] then
			-- Step to the neighbouring CANDIDATE, so dodging a repeat can't
			-- land back on a toy the cooldown filter just ruled out --
			slot = (slot == 1) and #candidates or (slot - 1)
		end
		rtCatPrevToy[cat] = list[candidates[slot]]
		return candidates[slot]
	end

	rtCatPrevToy[cat] = list[candidates[1]]
	return candidates[1]
end

-- Hands the snippet the order to walk while combat is on: what's left of
-- this pass, best first, so a fight starts on the toys Lua would have
-- picked anyway. Cooldowns go stale the moment the fight starts and the
-- sandbox can't re-read them, which is the price of rotating at all in
-- there. Out of combat only -- SetAttribute is exactly what a lockdown
-- forbids, and it's also when there's nothing to push --
local function PushCombatOrder(cat)
	local btn = rtCatButtons[cat]
	if not btn or InCombatLockdown() then return end
	-- The snippet reads this rather than being unwrapped, so "/toys combat"
	-- can switch in-combat rotation off without touching a secure handler --
	btn:SetAttribute("rtCombatOff", rtSettings and rtSettings.combatRotation == false or nil)
	local list = rtCatList[cat]
	if not list or #list == 0 then
		btn:SetAttribute("rtOrder", "")
		btn:SetAttribute("rtPos", 0)
		return
	end
	-- Preferred first, then the rest, each group keeping the pass's own
	-- shuffled order -- so the sandbox never has to make a judgement call --
	local best, rest = {}, {}
	for i = 1, #list do
		local id = list[i]
		local bucket = ToyIsPreferred(id) and best or rest
		bucket[#bucket + 1] = id
	end
	for i = 1, #rest do best[#best + 1] = rest[i] end
	btn:SetAttribute("rtOrder", table.concat(best, ","))
	btn:SetAttribute("rtPos", 0)
end

-- Combat is over: the snippet has been advancing the button on its own, so
-- Lua's idea of the current toy is stale. Adopt whatever it landed on and
-- rewrite the macro to match -- that's the moment the icon stops lying --
function RT.AdoptCombatRotation(cat)
	local btn = rtCatButtons[cat]
	if not btn or InCombatLockdown() then return end
	local landed = tonumber(btn:GetAttribute("toy"))
	local cur = rtCatCurrentToy[cat]
	if not landed or (cur and cur.id == landed) then return end
	local data = rtToysByID[landed]
	if not data then return end
	rtCatCurrentToy[cat] = data
	RT.UpdateCategoryMacro(cat, data.name, data.icon)
	RT.SyncRailMacroButton(cat)
	-- It's been used now, so take it out of the remaining pass rather than
	-- letting it come round again immediately --
	local list = rtCatList[cat]
	if list then
		for i = 1, #list do
			if list[i] == landed then table.remove(list, i) break end
		end
	end
	rtCatPrevToy[cat] = landed
	Trace("[%s] combat ended on %s -- macro caught up", cat, data.name)
end

--------------------------------------------------------------------
-- Set random toy for a category without repeating from a diminishing pool
--------------------------------------------------------------------
function RT.SelectRandomToysToy(cat)
	if InCombatLockdown() then return DeferPastCombat(cat, "roll") end
	if not rtCatList[cat] or #rtCatList[cat] == 0 then
		RT.RefreshCategoryPool(cat)
	end

	local remaining = rtCatList[cat]
	if remaining and #remaining > 0 and #UsablePositions(remaining) == 0 then
		RT.RefreshCategoryPool(cat)
		if #UsablePositions(rtCatList[cat] or {}) == 0 then
			rtCatList[cat] = remaining
		else
			Trace("[%s] rest of the pass was unusable -- started a fresh one", cat)
		end
	end

	-- This used to discard picks the client called unusable, which threw the
	-- toy out of the pass entirely -- the same bad signal as the pool gate
	-- (see RefreshCategoryPool), just applied one draw later, and enough on
	-- its own to make a toy never come up. Ordering handles it now: an
	-- unusable-looking toy sinks to the back via ToyIsPreferred and is still
	-- drawn if it's all that's left. A pick with no toy data is the only
	-- thing worth dropping, since there'd be nothing to put in the macro --
	local rnd, toyID
	while #rtCatList[cat] > 0 and not toyID do
		rnd = GetRandomIndexForCategory(cat, #rtCatList[cat])
		local candidate = rtCatList[cat][rnd]
		if not rtToysByID[candidate] then
			table.remove(rtCatList[cat], rnd)
		else
			toyID = candidate
		end
	end

	if not toyID then
		rtCatCurrentToy[cat] = nil
		RT.UpdateCategoryMacro(cat, "Hearthstone", 134414)
		RT.SyncRailMacroButton(cat)
		return
	end

	local data = rtToysByID[toyID]

	rtCatCurrentToy[cat] = data
	EnsureCatButton(cat):SetAttribute("toy", toyID)
	RT.UpdateCategoryMacro(cat, data.name, data.icon)
	RT.SyncRailMacroButton(cat)

	table.remove(rtCatList[cat], rnd)
	PushCombatOrder(cat)

	Trace("[%s] macro now on %s (%d left in this pass)", cat, data.name, #rtCatList[cat])
end

--------------------------------------------------------------------
-- Update/Create a category's macro [macro name == category name]
--------------------------------------------------------------------
local rtCatMacroFailWarned = {}

local function WarnMacroNotCreated(cat, reason)
	if rtCatMacroFailWarned[cat] then return end
	rtCatMacroFailWarned[cat] = true
	print('|cffff5555Random Toys:|r couldn\'t create the macro for "' .. cat .. '" -- '
		.. reason .. '. The set still rotates; free up a macro slot and toggle it off and on.')
end

function RT.UpdateCategoryMacro(cat, name, icon)
	if InCombatLockdown() then return end
	local body = "#showtooltip " .. name .. rtCatSrc[cat]
	local macroName = RT.MacroNameForSet(cat)
	local macroIndex = GetMacroIndexByName(macroName)
	if macroIndex > 0 then
		EditMacro(macroIndex, macroName, icon, body)
		rtCatMacroFailWarned[cat] = nil
		return
	end
	-- Creating past the account cap doesn't fail quietly and return nothing
	-- -- it raises -- so check first and still guard the call. Without this
	-- the set looked enabled (box ticked, rotation running) with no macro
	-- anywhere and nothing said why --
	local numAccountMacros = GetNumMacros()
	if numAccountMacros and numAccountMacros >= (MAX_ACCOUNT_MACROS or 120) then
		WarnMacroNotCreated(cat, "your account macros are full")
		return
	end
	local ok = pcall(CreateMacro, macroName, icon, body, nil)
	if not ok then
		WarnMacroNotCreated(cat, "the client refused it")
	else
		rtCatMacroFailWarned[cat] = nil
	end
end

--------------------------------------------------------------------
-- A little wait avoids clobbering the macro mid-cast on failure.
--------------------------------------------------------------------
function RT.WaitThenSetRandomToys(cat)
	RollForPress(cat, REROLL_FAIL_SETTLE, "cast failed")
end

--------------------------------------------------------------------
-- WoW client events we want to know about, once per category
--------------------------------------------------------------------
local rtActionListener = CreateFrame("Frame")
rtActionListener:RegisterEvent("PLAYER_ENTERING_WORLD")
rtActionListener:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED")
rtActionListener:RegisterEvent("UNIT_SPELLCAST_FAILED")
rtActionListener:RegisterEvent("UNIT_SPELLCAST_START")
rtActionListener:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START")
rtActionListener:RegisterEvent("ZONE_CHANGED_NEW_AREA")
rtActionListener:SetScript("OnEvent", function(self, event, arg1, arg2, arg3)
	if InCombatLockdown() then return end

	if event == "PLAYER_ENTERING_WORLD" then
		for cat, enabled in pairs(rtSettings.categoryEnabled) do
			if enabled then
				RT.RefreshCategoryPool(cat)
				RT.SelectRandomToysToy(cat)
			end
		end
		self:UnregisterEvent("PLAYER_ENTERING_WORLD")
	elseif event == "UNIT_SPELLCAST_SUCCEEDED" and arg1 == "player" then
		local used = RT.toysBySpell and RT.toysBySpell[arg3]
		if used and RT.NoteToySuccess(used.id) then
			Trace("%s worked -- clearing any doubt about it", used.name)
			if RT.RefreshToyDoubtMarkers then RT.RefreshToyDoubtMarkers() end
		end
		for cat, cur in pairs(rtCatCurrentToy) do
			if cur.spellId == arg3 then
				NoteAnswered(cat)
				RollForPress(cat, 0, "cast succeeded")
			end
		end
	elseif event == "UNIT_SPELLCAST_FAILED" and arg1 == "player" then
		for cat, cur in pairs(rtCatCurrentToy) do
			if cur.spellId == arg3 then
				NoteAnswered(cat)
				if RT.NoteToyFailure(cur.id) then
					Trace("[%s] %s has failed enough times -- deprioritising it", cat, cur.name)
				end
				RT.WaitThenSetRandomToys(cat)
			end
		end
	elseif (event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_CHANNEL_START") and arg1 == "player" then
		-- A cast beginning means the client accepted the use -- the press
		-- wasn't silently blocked, so the PostClick watchdog stands down;
		-- SUCCEEDED/FAILED above handles the outcome as always. Without
		-- this, a toy with a cast time longer than the watchdog window
		-- would get re-rolled mid-cast.
		for cat, cur in pairs(rtCatCurrentToy) do
			if cur.spellId == arg3 then
				NoteAnswered(cat)
			end
		end
	elseif event == "ZONE_CHANGED_NEW_AREA" and rtSettings then
		RT.ForgetToyFailures()
		-- Usability is zone-dependent: after a zone change, re-roll any
		-- enabled set whose current pick the client now reports
		-- unusable -- or that had gone empty and fell back to the
		-- Hearthstone placeholder macro --
		for cat, enabled in pairs(rtSettings.categoryEnabled) do
			if enabled then
				local cur = rtCatCurrentToy[cat]
				if not cur or C_ToyBox.IsToyUsable(cur.id) == false then
					RT.RefreshCategoryPool(cat)
					RT.SelectRandomToysToy(cat)
				end
			end
		end
	end
end)


-- RandomToysUI.lua -- all the on-screen UI: the options/floating panel,
-- the set rail, the toy grid and its cells, the Sets/tag menus and popups,
-- the defaults exporter, and the addon load / rescan wiring.

local rtConfigMode = RT.configMode
local rtToys = RT.toys
local rtToysByID = RT.toysByID
local rtAllToys = RT.allToys
local rtCatCurrentToy = RT.catCurrentToy
local UNTAGGED = RT.UNTAGGED
local CATEGORIES = RT.CATEGORIES
local CATEGORY_DESCRIPTIONS = RT.CATEGORY_DESCRIPTIONS
local CATEGORY_ABBR = RT.CATEGORY_ABBR
local CATEGORY_RENAMES = RT.CATEGORY_RENAMES
local DEFAULT_SET_GROUPS = RT.DEFAULT_SET_GROUPS
local RAIL_TAGS = RT.RAIL_TAGS
local DEFAULT_VISIBLE_SETS = RT.DEFAULT_VISIBLE_SETS
local RESERVED_TAG_NAMES = RT.RESERVED_TAG_NAMES

-- Points a secure button's "use this toy" action -- middle-click (type3)
-- and Ctrl+Left (ctrl-type1), sharing the one "toy" value -- at toyID,
-- or clears all three when toyID is nil. Shared by the rail rows and the
-- grid cells so both bind the action identically.
local function SetToyClickAction(frame, toyID)
	frame:SetAttribute("type3", toyID and "toy" or nil)
	frame:SetAttribute("ctrl-type1", toyID and "toy" or nil)
	frame:SetAttribute("toy", toyID)
end


--------------------------------------------------------------------
-- rtContent holds all the actual UI. It has two live homes it moves
-- between: the standalone floating window (opened via /toys or the
-- Toy Box header toggle turned off) and the embedded Toy Box takeover.
-- rtOptionsPanel below is no longer a registered Settings canvas -- it
-- stays only as an off-screen parking frame that holds rtContent while
-- neither live home has it (Random Toys is intentionally not listed in
-- Options > AddOns anymore; use /toys or the in-journal toggle).
--------------------------------------------------------------------

local COMMON_WIDTH = SettingsPanel.Container:GetWidth()

-- Off-screen parking home for rtContent (never shown; not a Settings
-- category) --
local rtOptionsPanel = CreateFrame("Frame")

-- Standalone window, opened via /toys --
local rtFloatingWindow = CreateFrame("Frame", "RandomToysFloatingWindow", UIParent, "BasicFrameTemplateWithInset")
rtFloatingWindow:SetSize(COMMON_WIDTH + 30, 560)
rtFloatingWindow:SetPoint("CENTER")
rtFloatingWindow:SetFrameStrata("HIGH")
rtFloatingWindow:SetClampedToScreen(true)
rtFloatingWindow:Hide()
rtFloatingWindow.TitleText:SetText(RandomCosplayLocal)  --"Random Toys"
rtFloatingWindow:SetMovable(true)
rtFloatingWindow:EnableMouse(true)
rtFloatingWindow:RegisterForDrag("LeftButton")
rtFloatingWindow:SetScript("OnDragStart", rtFloatingWindow.StartMoving)
rtFloatingWindow:SetScript("OnDragStop", rtFloatingWindow.StopMovingOrSizing)
tinsert(UISpecialFrames, "RandomToysFloatingWindow")

-- The actual UI. Lives in rtOptionsPanel by default; moves into
-- rtFloatingWindow while that's open, and back when it closes.
-- [OnShow/OnHide scripts for rtFloatingWindow are wired up further down,
-- once BuildToyList/RebuildToyRows/etc. exist]
local rtContent = CreateFrame("Frame", nil, rtOptionsPanel)
rtContent:SetPoint("TOPLEFT")
rtContent:SetPoint("BOTTOMRIGHT")

-- Title --
local rtTitle = CreateFrame("Frame", nil, rtContent)
rtTitle:SetPoint("TOPLEFT", 10, -10)
rtTitle:SetWidth(COMMON_WIDTH-35)
rtTitle:SetHeight(1)
rtTitle.text = rtTitle:CreateFontString(nil, "OVERLAY", "GameFontNormal")
rtTitle.text:SetPoint("TOPLEFT", rtTitle, 0, 0)
rtTitle.text:SetText(RandomCosplayLocal)  --"Random Toys"
rtTitle.text:SetFont(STANDARD_TEXT_FONT, 18)

-- Thanks --
rtContent.Thanks = rtContent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
rtContent.Thanks:SetPoint("BOTTOMRIGHT", -81, 11)
rtContent.Thanks:SetText("献给所有热爱收集的玩家 <3 \n zecmo - Runetotem")
rtContent.Thanks:SetFont(STANDARD_TEXT_FONT, 9)
rtContent.Thanks:SetJustifyH("CENTER")

-- Jumps straight to the Blizzard macro panel, for editing the macros
-- this addon maintains --
local rtMacrosButton = CreateFrame("Button", nil, rtContent, "UIPanelButtonTemplate")
rtMacrosButton:SetSize(50, 30)
rtMacrosButton:SetPoint("TOPRIGHT", -10, -10)
rtMacrosButton:SetText("宏")
rtMacrosButton:SetScript("OnClick", function()
	if MacroFrame and MacroFrame:IsShown() then
		HideUIPanel(MacroFrame)
	else
		SlashCmdList["MACRO"]("")
	end
end)

-- Jumps straight to the Blizzard Toy Box, for cross-checking toys --
local rtToyBoxButton = CreateFrame("Button", nil, rtContent, "UIPanelButtonTemplate")
rtToyBoxButton:SetSize(90, 22)
rtToyBoxButton:SetPoint("TOPRIGHT", rtMacrosButton, "TOPLEFT", -8, 0)
rtToyBoxButton:SetText("玩具")
rtToyBoxButton:SetScript("OnClick", function()
	-- This button is for viewing Blizzard's actual toy box. With the embed
	-- takeover on, opening the journal would just re-embed Random Toys and
	-- you'd never see the stock grid -- so turn the takeover off first (same
	-- as unchecking the "Random Toys" header toggle), then open the journal --
	RT.SetToyBoxEmbedEnabled(false)
	ToggleCollectionsJournal(3) -- Collections Journal tab order: Mounts, Pets, Toys, Heirlooms
end)

--------------------------------------------------------------------
-- Main content [one screen now: the toy grid with the set rail down
-- the left. The old Cosplays tab's per-set info lives on in the rail:
-- descriptions and counts in the hover tooltips, the macro toggle on
-- each row. The Sets button (created in the rail section below) opens
-- the set visibility menu.]
--------------------------------------------------------------------

local rtTabToys = CreateFrame("Frame", nil, rtContent)
rtTabToys:SetPoint("TOPLEFT", 5, -70)
rtTabToys:SetPoint("BOTTOMRIGHT", -5, 5)


--------------------------------------------------------------------
-- The set rail + toy grid [styled after OPie's "Add a new slice"
-- picker: the Sets button caps the rail (its menu leads with "New
-- Set"), custom then shipped sets fill the rest of the rail; the
-- search box sits over the toy grid filling the rest]
--------------------------------------------------------------------

local RAIL_WIDTH = 118
local RAIL_BUTTON_HEIGHT = 20

-- Config mode starts on Full (All is hidden there -- nothing to curate);
-- normal mode starts on the browse-everything view --
local rtCurrentCategory = rtConfigMode and "Full" or "All"


-- Left rail --
local rtTagRail = CreateFrame("Frame", nil, rtTabToys)
rtTagRail:SetPoint("TOPLEFT", 5, -2)
rtTagRail:SetPoint("BOTTOMLEFT", 5, 5)
rtTagRail:SetWidth(RAIL_WIDTH)

-- Sets button caps the rail; opens the set menu [New Set entry, custom
-- sets, then the visibility checkboxes -- OnClick wired further down,
-- once the rail helpers exist] --
local rtSetsButton = CreateFrame("Button", nil, rtTagRail, "UIPanelButtonTemplate")
rtSetsButton:SetPoint("TOPLEFT", 0, 0)
rtSetsButton:SetSize(RAIL_WIDTH, 22)
rtSetsButton:SetText("设置")

-- Search box [over the toy grid; filters the grid by toy name within
-- whatever set is selected]
local rtSearchBox = CreateFrame("EditBox", nil, rtTabToys, "SearchBoxTemplate")
rtSearchBox:SetPoint("TOPLEFT", RAIL_WIDTH + 33, -2)
rtSearchBox:SetSize(175, 20)

-- Thin divider between the set rail and the toy grid --
local rtRailDivider = rtTabToys:CreateTexture(nil, "ARTWORK")
rtRailDivider:SetColorTexture(1, 1, 1, 0.15)
rtRailDivider:SetWidth(1)
rtRailDivider:SetPoint("TOPLEFT", rtTagRail, "TOPRIGHT", 10, 0)
rtRailDivider:SetPoint("BOTTOMLEFT", rtTagRail, "BOTTOMRIGHT", 10, 0)

-- The tag list scrolls (mousewheel only -- a scrollbar would steal
-- width from an already-tight rail) in case custom tags outgrow the
-- panel height. Runs the full height of the rail below the Sets button
-- now that "New Set" lives in that button's menu --
local rtRailScroll = CreateFrame("ScrollFrame", nil, rtTagRail)
rtRailScroll:SetPoint("TOPLEFT", 0, -26)
rtRailScroll:SetPoint("BOTTOMRIGHT", 0, 0)
local rtRailChild = CreateFrame("Frame", nil, rtRailScroll)
rtRailScroll:SetScrollChild(rtRailChild)
rtRailChild:SetSize(RAIL_WIDTH, 1)
rtRailScroll:EnableMouseWheel(true)
rtRailScroll:SetScript("OnMouseWheel", function(self, delta)
	local maxScroll = math.max(0, rtRailChild:GetHeight() - self:GetHeight())
	self:SetVerticalScroll(math.min(maxScroll, math.max(0, self:GetVerticalScroll() - delta * RAIL_BUTTON_HEIGHT * 1.5)))
end)

-- Rail buttons [flat category-list style, same textures OPie's action
-- picker uses; reused across rebuilds like the toy cells are]
local rtRailButtons = {}
local rtRailButtonByTag = {}


-- Every rail entry gets a macro-enable toggle on its row. Any set,
-- shipped or custom, can maintain a macro; only the No Set filter view
-- is excluded, having no pool behind it --

-- Turns a rail row's macro on, exactly like checking its macroCheck box --
-- shared by the checkbox's own OnClick and by middle-clicking an inactive
-- row (see the MiddleButton branch in GetOrCreateRailButton's OnClick
-- below), so both paths land on the same enabled state --
local function EnableRailMacro(b)
	local cat = b.tagName
	rtSettings.categoryEnabled[cat] = true
	b.macroCheck:SetChecked(true)
	RT.RefreshCategoryPool(cat)
	RT.SelectRandomToysToy(cat)
end

local function GetOrCreateRailButton(i)
	local b = rtRailButtons[i]
	if not b then
		-- SecureActionButtonTemplate so a middle-click can fire the selected
		-- set's macro toy directly (using a toy is a protected action, same
		-- reason the toy grid cells use it -- see CreateToyButtonShell). The
		-- type3/toy attributes are kept in sync with whichever set currently
		-- occupies this row by SyncRailMacroButton, called from
		-- RT.RebuildTagRail(row reassigned) and RT.SelectRandomToysToy(new
		-- toy drawn) -- our own clicks ride a HookScript below so the
		-- template's secure OnClick isn't clobbered. Same combat cost as the
		-- toy cells: this row can't be moved/shown/hidden in combat, so
		-- RebuildTagRail bails out and replays once combat ends --
		b = CreateFrame("Button", nil, rtRailChild, "SecureActionButtonTemplate")
		b:SetSize(RAIL_WIDTH, RAIL_BUTTON_HEIGHT)
		b:SetPoint("TOPLEFT", 0, -(i-1) * RAIL_BUTTON_HEIGHT)
		b:SetNormalTexture("Interface/AchievementFrame/UI-Achievement-Category-Background")
		b:SetHighlightTexture("Interface/AchievementFrame/UI-Achievement-Category-Highlight")
		b:GetNormalTexture():SetTexCoord(7/256, 162/256, 5/32, 24/32)
		b:GetHighlightTexture():SetTexCoord(7/256, 163/256, 5/32, 24/32)
		b:GetNormalTexture():SetVertexColor(0.6, 0.6, 0.6)
		b:SetNormalFontObject(GameFontHighlight)
		b:SetHighlightFontObject(GameFontHighlight)
		b:SetPushedTextOffset(0, 0)
		b:SetText(" ")
		local fs = b:GetFontString()
		fs:SetPoint("LEFT", 6, 0)
		fs:SetPoint("RIGHT", -6, 0)
		fs:SetJustifyH("CENTER")
		fs:SetMaxLines(1)
		-- Middle and (Ctrl+)Left both go on the DOWN phase, like the toy
		-- cells -- protected toy-firing attribute clicks only fire on down,
		-- and single-phase per button avoids double-firing plain left-click
		-- too. Right stays on Up, unchanged --
		b:RegisterForClicks("LeftButtonDown", "RightButtonUp", "MiddleButtonDown")
		-- HookScript, not SetScript: the secure template installs its own
		-- OnClick to fire the row's set macro toy on MiddleButton and
		-- Ctrl+Left (via the type3/ctrl-type1/toy attributes -- see
		-- SyncRailMacroButton), and this insecure hook runs after it for
		-- select/menu --
		b:HookScript("OnClick", function(self, mouseButton)
			if mouseButton == "RightButton" then
				-- Rename/Delete only exist for user-created tags; shipped
				-- sets get a trimmed-down menu with just Hide (see
				-- OpenDefaultSetRailMenu) -- All/Any/No Set get neither --
				if self.isCustom then
					RT.OpenRailTagMenu(self)
				elseif not rtConfigMode and RT.IsShippedTag(self.tagName) then
					RT.OpenDefaultSetRailMenu(self)
				end
				return
			end
			if mouseButton == "MiddleButton" or (mouseButton == "LeftButton" and IsControlKeyDown()) then
				-- If this row already had a macro toy, the secure OnClick
				-- fired it above and there's nothing left to do. If it
				-- didn't (no macro enabled yet), that click was silent --
				-- turn the macro on now so this row starts working, same
				-- as checking its macroCheck box. It stays on afterward;
				-- the next middle-click/Ctrl+Left (and every one after)
				-- fires it --
				if not rtConfigMode and self.tagName ~= UNTAGGED
						and not (rtSettings.categoryEnabled and rtSettings.categoryEnabled[self.tagName]) then
					EnableRailMacro(self)
				end
				return
			end
			PlaySound(SOUNDKIT.U_CHAT_SCROLL_BUTTON)
			RT.SelectToyCategory(self.tagName)
		end)
		b:SetScript("OnEnter", function(self)
			RT.ShowRailTagTooltip(self)
		end)
		b:SetScript("OnLeave", function() GameTooltip:Hide() end)

		-- Macro toggle [shown for every entry but No Set -- see
		-- ConfigureRailMacroCheck]. Enabling brings the macro up
		-- immediately; disabling deletes it --
		b.macroCheck = CreateFrame("CheckButton", nil, b, "UICheckButtonTemplate")
		b.macroCheck:SetPoint("RIGHT", -1, 0)
		b.macroCheck:SetSize(18, 18)
		b.macroCheck.Text:Hide()
		b.macroCheck:SetScript("OnClick", function(self)
			local cat = b.tagName
			if self:GetChecked() then
				EnableRailMacro(b)
			else
				rtSettings.categoryEnabled[cat] = false
				RT.DeleteCategoryMacro(cat)
				-- SelectRandomToysToy syncs the row's middle-click
				-- attributes itself when enabling; disabling skips that
				-- call, so clear them here instead --
				RT.SyncRailMacroButton(cat)
			end
			-- The tooltip is still up (the cursor's on this button), so
			-- re-fire it to flip its Create/Remove title to the new state --
			if GameTooltip:GetOwner() == self then
				self:GetScript("OnEnter")(self)
			end
		end)
		-- Tooltip title says what a click will do from the current state --
		b.macroCheck:SetScript("OnEnter", function(self)
			GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
			GameTooltip:SetText(self:GetChecked() and "删除宏" or "创建宏")
			GameTooltip:AddLine('名为 "' .. b.tagName .. '" 的宏会从你的选择中随机使用一个玩具。', 1, 1, 1, true)
			GameTooltip:Show()
		end)
		b.macroCheck:SetScript("OnLeave", function() GameTooltip:Hide() end)

		rtRailButtons[i] = b
	end
	return b
end

-- Shows/hides one rail row's macro toggle and gives the label room for
-- it. Config mode hides all toggles -- it's for curating lists, not for
-- driving macros --
local function ConfigureRailMacroCheck(b)
	local hasMacro = (not rtConfigMode and b.tagName ~= UNTAGGED) or false
	b.macroCheck:SetShown(hasMacro)
	if hasMacro then
		b.macroCheck:SetChecked(rtSettings and rtSettings.categoryEnabled
			and rtSettings.categoryEnabled[b.tagName] or false)
	end
	b:GetFontString():SetPoint("RIGHT", hasMacro and -18 or -6, 0)
end


-- Set true when a rail refresh is requested during combat, since the rail
-- buttons are protected (secure template, for middle-click use) and can't
-- be moved/shown/hidden then. PLAYER_REGEN_ENABLED replays it once combat
-- ends -- same pattern as rtRefreshDeferred for the toy grid --
local rtRailRefreshDeferred = false

function RT.RebuildTagRail()
	if InCombatLockdown() then rtRailRefreshDeferred = true return end
	wipe(rtRailButtonByTag)
	local shown = 0
	local function AddEntry(tag, isCustom)
		shown = shown + 1
		local b = GetOrCreateRailButton(shown)
		b.tagName, b.isCustom = tag, isCustom
		local label = tag
		if isCustom then
			label = RT.CustomSetLabel(tag)
		elseif tag == "All" or tag == "Any" or tag == "Favs" then
			label = RT.PseudoSetLabel(tag)
		end
		b:SetText(label)
		ConfigureRailMacroCheck(b)
		b:Show()
		rtRailButtonByTag[tag] = b
		-- This row's frame object may have belonged to a different set
		-- before this rebuild (rail buttons are reused by position) --
		-- resync its middle-click attributes to whichever set it holds now --
		RT.SyncRailMacroButton(tag)
	end
	-- Display order: All and Any lead, pinned. All stays available in
	-- config mode too -- its list is forced true by SyncNewToysIntoOptions,
	-- so it's the "browse everything, check new toys into a category"
	-- escape hatch while curating (see ReflowToyGrid). Any stays hidden
	-- in config mode -- it's fully derived, nothing of its own to curate.
	-- Then the user's custom sets, then the shipped sets, with the No Set
	-- filter view closing the list. Custom and shipped sets are ordered
	-- by rtSettings.customTags/shippedOrder rather than a fixed catalog
	-- order -- toggling one on (via the Sets menu) sends it to the
	-- bottom of its list, so recently re-enabled sets don't reshuffle
	-- ones you already rely on. Config mode ignores the recency order and
	-- shows every shipped set in RT_DEFAULT_TAGS' fixed order -- curating
	-- defaults needs every one of them reachable regardless of recency --
	if not RT.IsSetHidden("All") then
		AddEntry("All", false)
	end
	if not rtConfigMode and not RT.IsSetHidden("Any") then
		AddEntry("Any", false)
	end
	if not rtConfigMode and not RT.IsSetHidden("Favs") then
		AddEntry("Favs", false)
	end
	-- Custom sets are hidden entirely in config mode -- that's for
	-- curating shipped defaults, and custom sets never ship (they're
	-- excluded from the export too), so showing them here only invites
	-- mixing the two up --
	if not rtConfigMode then
		for _, tag in ipairs(RT.GetCustomTags()) do
			if not RT.IsSetHidden(tag) then
				AddEntry(tag, true)
			end
		end
	end
	if rtConfigMode then
		for _, tag in ipairs(RT_DEFAULT_TAGS) do
			AddEntry(tag, false)
		end
	else
		for _, tag in ipairs(RT.GetShippedOrder()) do
			if not RT.IsSetHidden(tag) then
				AddEntry(tag, false)
			end
		end
	end
	if not RT.IsSetHidden(UNTAGGED) then
		AddEntry(UNTAGGED, false)
	end
	for i = shown + 1, #rtRailButtons do
		rtRailButtons[i]:Hide()
	end
	rtRailChild:SetHeight(math.max(1, shown * RAIL_BUTTON_HEIGHT))
	for tag, b in pairs(rtRailButtonByTag) do
		if tag == rtCurrentCategory then b:LockHighlight() else b:UnlockHighlight() end
	end
end

-- Keeps a rail row's middle-click/Ctrl+Left-click attributes lined up
-- with its set's current macro toy -- called whenever that could have
-- changed: the row was just (re)assigned to this set (RebuildTagRail), a
-- new toy was just drawn for it (SelectRandomToysToy), or its macro was
-- just switched off (the macroCheck handler above). No-ops quietly if the
-- set has no rail row right now (hidden, or config mode hiding custom
-- sets) -- global, not local, so it's callable from all three regardless
-- of definition order in this file (see the comment where rtCatCurrentToy
-- is declared, up by the rail buttons) --
function RT.SyncRailMacroButton(cat)
	local railBtn = rtRailButtonByTag[cat]
	if not railBtn then return end
	local enabled = rtSettings and rtSettings.categoryEnabled and rtSettings.categoryEnabled[cat]
	local cur = enabled and rtCatCurrentToy[cat]
	SetToyClickAction(railBtn, cur and cur.id or nil)
end

--------------------------------------------------------------------
-- Shared hover tooltip for a menu row that names a set: the set name
-- plus its CATEGORY_DESCRIPTIONS blurb (No Set gets a fixed line;
-- custom sets have no blurb, so they show just the name). Used by both
-- the Sets menu's checkboxes and the Inherit flyout's entries.
--
-- The menu system anchors the tooltip itself, only AFTER the SetTooltip
-- callback returns and again when it Show()s, so it lands bottom-left
-- by default. We want it right of the row: the callback names the
-- intended owner in rtMenuTooltipOwner, and the one-time GameTooltip
-- OnShow hook re-anchors the instant it shows -- same frame as the
-- framework's placement, so there's no flash at the old spot. A
-- next-frame C_Timer backstop (in the callback) covers the rare case
-- where OnShow doesn't fire, e.g. the tooltip staying up while moving
-- directly between two rows. Guarded on owner identity throughout, so
-- neither ever touches an unrelated tooltip.
--------------------------------------------------------------------
local rtMenuTooltipOwner
GameTooltip:HookScript("OnShow", function(self)
	if rtMenuTooltipOwner and self:GetOwner() == rtMenuTooltipOwner then
		self:ClearAllPoints()
		self:SetPoint("LEFT", rtMenuTooltipOwner, "RIGHT", 0, 0)
	end
end)

local function AttachSetInfoTooltip(elementDescription, tag)
	elementDescription:SetTooltip(function(tooltip)
		GameTooltip_SetTitle(tooltip, tag)
		local desc = CATEGORY_DESCRIPTIONS[tag]
		if tag == UNTAGGED then
			desc = "不属于以上分类"
		end
		if desc and desc ~= "" then
			GameTooltip_AddNormalLine(tooltip, desc)
		end
		local owner = tooltip:GetOwner()
		if owner then
			rtMenuTooltipOwner = owner
			C_Timer.After(0, function()
				if tooltip:GetOwner() == owner and tooltip:IsShown() then
					tooltip:ClearAllPoints()
					tooltip:SetPoint("LEFT", owner, "RIGHT", 0, 0)
				end
			end)
		end
	end)
end

--------------------------------------------------------------------
-- The Sets menu [the header's Sets button]: "New Set" leads, then the
-- custom sets (if any), then a checkbox per set -- shipped then custom
-- -- controlling whether that set is in use. Checked shows it in the
-- rail; unchecking hides it AND retires its macro. Same menu style as
-- the toy right-click menu.
--------------------------------------------------------------------

local function ToggleSetVisibility(tag)
	if rtSettings.hiddenSets[tag] then
		-- Coming back into use: recency sends it to the bottom of its
		-- list (shipped or custom). All/Any/Favs/No Set are pinned --
		-- neither list owns them, so nothing to reorder --
		rtSettings.hiddenSets[tag] = nil
		if RT.IsShippedTag(tag) then
			RT.MoveTagToEnd(rtSettings.shippedOrder, tag)
		elseif tag ~= "All" and tag ~= "Any" and tag ~= "Favs" and tag ~= UNTAGGED then
			RT.MoveTagToEnd(rtSettings.customTags, tag)
		end
	else
		rtSettings.hiddenSets[tag] = true
		if RT.IsShippedTag(tag) then
			-- Drop from the order too, so a later re-show is judged
			-- fresh (bottom again), not wherever it used to sit --
			for i, t in ipairs(rtSettings.shippedOrder) do
				if t == tag then
					table.remove(rtSettings.shippedOrder, i)
					break
				end
			end
		end
		-- Hidden means fully off: an out-of-use set keeps no macro --
		if rtSettings.categoryEnabled[tag] then
			rtSettings.categoryEnabled[tag] = false
		end
		RT.DeleteCategoryMacro(tag)
	end
	RT.RebuildTagRail()
	-- Falls back to a sane view if the set being viewed was just hidden --
	RT.SelectToyCategory(rtCurrentCategory)
end

rtSetsButton:SetScript("OnClick", function(self)
	local menu = MenuUtil.CreateContextMenu(self, function(owner, rootDescription)
		local function AddEntry(parent, tag, label)
			local checkbox = parent:CreateCheckbox(label or tag,
				function() return not rtSettings.hiddenSets[tag] end,
				function() ToggleSetVisibility(tag) end)
			AttachSetInfoTooltip(checkbox, tag)
		end
		-- "New Set" leads the list. In config mode this drafts a new SHIPPED
		-- set (see AddDefaultTag) instead of a custom one -- custom sets are
		-- hidden from config mode entirely, so creating one here would just
		-- vanish from view --
		rootDescription:CreateButton(rtConfigMode and "+ 新建默认集合" or "+ 新建集合", function()
			StaticPopup_Show("RANDOMTOYS_NEW_TAG", nil, nil, rtConfigMode and {isDefaultTag = true} or nil)
		end)
		rootDescription:CreateDivider()
		-- The four pseudo/filter views, ungrouped -- All, Any, and Favs
		-- are all fully derived, No Set is the complement filter --
		AddEntry(rootDescription, "All")
		AddEntry(rootDescription, "Any")
		AddEntry(rootDescription, "Favs")
		AddEntry(rootDescription, UNTAGGED)
		rootDescription:CreateDivider()
		-- Custom sets next, mirroring the rail's display order --
		local customTags = RT.GetCustomTags()
		if #customTags > 0 then
			rootDescription:CreateTitle("自定义集合")
			for _, tag in ipairs(customTags) do
				AddEntry(rootDescription, tag, RT.CustomSetLabel(tag))
			end
			rootDescription:CreateDivider()
		end
		rootDescription:CreateTitle("默认集合")
		-- Checkbox rows have no real indent of their own, so each group
		-- is a genuine submenu instead of a flat title + rows -- its
		-- members fly out properly nested under the group name --
		for _, group in ipairs(DEFAULT_SET_GROUPS) do
			local groupMenu = rootDescription:CreateButton(group.header)
			for _, tag in ipairs(group.tags) do
				AddEntry(groupMenu, tag)
			end
		end
		-- Shipped sets not yet slotted into a DEFAULT_SET_GROUPS group --
		-- in practice, only a set just drafted via "+ New Default Set" this
		-- session (see AddDefaultTag). Flat, positioned like Custom Sets,
		-- until a dev files it into a real group and ships it --
		local ungroupedDefaults = RT.GetUngroupedDefaultTags()
		if #ungroupedDefaults > 0 then
			rootDescription:CreateDivider()
			rootDescription:CreateTitle("新集合")
			for _, tag in ipairs(ungroupedDefaults) do
				AddEntry(rootDescription, tag)
			end
		end
	end)
	-- Anchor the popup's top-left to the button's top-right, rather than
	-- wherever the Menu system's default context-menu placement lands --
	if menu then
		menu:ClearAllPoints()
		menu:SetPoint("TOPLEFT", self, "TOPRIGHT", 2, 0)
	end
end)

--------------------------------------------------------------------
-- Custom tags [user-created labels living in rtSettings.customTags;
-- each reuses the per-toy {list, pool} shape under its own name, so
-- the grid, search, and pool toggles all work unchanged. Shipped tag
-- assignments stay locked outside config mode -- these are the user's
-- own to create, rename, delete, and paint onto toys via right-click]
--------------------------------------------------------------------


-- Hover tooltip for a rail entry: set name, its CATEGORY_DESCRIPTIONS
-- blurb (shipped sets only -- custom sets have no description), and an
-- "active/total toys active" count (e.g. "12/18") -- total in green for
-- a shipped set with nothing left uncollected. "No Set" and "Any" have
-- no list of their own, so they keep a plain single-number count --
function RT.ShowRailTagTooltip(railButton)
	local tag = railButton.tagName
	GameTooltip:SetOwner(railButton, "ANCHOR_NONE")
	GameTooltip:SetPoint("LEFT", railButton, "RIGHT")
	GameTooltip:SetText(tag)
	local desc = CATEGORY_DESCRIPTIONS[tag]
	if tag == UNTAGGED then
		desc = "不在以上分类中"
	end
	if desc and desc ~= "" then
		GameTooltip:AddLine(desc, 1, 1, 1, true)
	end
	local countText
	if tag == UNTAGGED then
		local count = 0
		for i = 1, #rtToys do
			local data = rtToys[i]
			if data.owned and RT.ToyIsUntagged(rtOptions[data.id]) then count = count + 1 end
		end
		countText = count == 1 and "1 玩具" or (count .. " 玩具")
	elseif tag == "Any" then
		local count = 0
		for i = 1, #rtToys do
			local data = rtToys[i]
			if data.owned and RT.IsToyInAny(rtOptions[data.id]) then count = count + 1 end
		end
		countText = count == 1 and "1 toy selected" or (count .. " toys selected")
	elseif tag == "Favs" then
		local count = 0
		for i = 1, #rtToys do
			local data = rtToys[i]
			if data.owned and RT.ToyIsFavorited(data.id) then count = count + 1 end
		end
		countText = count == 1 and "1 toy" or (count .. " toys")
	else
		-- "Active" is owned toys pooled into rotation -- always scoped to
		-- rtToys/rtOptions, the live per-toy state --
		local active = 0
		for i = 1, #rtToys do
			local data = rtToys[i]
			if data.owned then
				local opt = rtOptions[data.id]
				local entry = opt and opt[tag]
				if entry and entry.list and entry.pool then active = active + 1 end
			end
		end
		-- "Total" is every toy in the set's list, owned or not. Shipped
		-- sets read their total straight from the static defaults catalog
		-- (RT_DEFAULT_TOY_LISTS/PlayerHasToy) so it's accurate even with
		-- "Show All Toys" off, which leaves uncollected toys out of
		-- rtToys/rtOptions entirely. Custom sets have no such catalog, so
		-- they fall back to whatever toys are currently scanned in --
		-- best effort, and the reason custom sets never get the green
		-- "fully collected" treatment below --
		local total, uncollected = 0, false
		if RT.IsShippedTag(tag) then
			for id, list in pairs(RT_DEFAULT_TOY_LISTS) do
				if list[tag] then
					total = total + 1
					if not PlayerHasToy(id) then uncollected = true end
				end
			end
		else
			for i = 1, #rtToys do
				local data = rtToys[i]
				local opt = rtOptions[data.id]
				local entry = opt and opt[tag]
				if entry and entry.list then
					total = total + 1
					if not data.owned then uncollected = true end
				end
			end
		end
		local totalPart = total .. " 个玩具"
		if RT.IsShippedTag(tag) and total > 0 and not uncollected then
			totalPart = GREEN_FONT_COLOR:WrapTextInColorCode(totalPart)
		end
		countText = active .. "/" .. totalPart .. " 启用"
	end
	GameTooltip:AddLine(countText, 0.7, 0.7, 0.7)
	GameTooltip:Show()
end

-- Painting/unpainting a custom tag on one toy. Adding seeds pool = true
-- so a newly tagged toy is immediately active in that tag's rotation,
-- matching how shipped defaults seed; removing drops the whole entry to
-- keep saves sparse --
local function ToggleCustomToyTag(toyID, tag)
	local opt = rtOptions[toyID]
	if not opt then
		opt = {}
		rtOptions[toyID] = opt
	end
	local entry = opt[tag]
	if entry and entry.list then
		opt[tag] = nil
	else
		opt[tag] = {list = true, pool = true}
	end
	RT.ResyncPoolsForSetEdit(tag)
	RT.SelectToyCategory(rtCurrentCategory)
end

-- Config-mode-only editing of a shipped tag's assignment; unlike custom
-- tags the entry is kept on removal, preserving the toy's pool state
-- exactly like unchecking it in the config-mode grid does --
local function ToggleShippedToyTag(toyID, tag)
	local opt = rtOptions[toyID]
	if not opt then
		opt = {}
		rtOptions[toyID] = opt
	end
	local entry = opt[tag]
	if not entry then
		opt[tag] = {list = true, pool = true}
	else
		entry.list = not entry.list
	end
	RT.SelectToyCategory(rtCurrentCategory)
end

local function AddCustomTag(name, applyToToyID)
	table.insert(rtSettings.customTags, name)
	if applyToToyID then
		ToggleCustomToyTag(applyToToyID, name)
	end
	RT.RebuildTagRail()
	if not applyToToyID then
		-- Created from the rail button: jump straight into the new (empty)
		-- tag, ready to be filled via right-click from other views --
		RT.SelectToyCategory(name)
	end
end

-- Config mode's counterpart to AddCustomTag: "+ New Set" while curating
-- defaults should draft a new SHIPPED set, not a custom one -- custom sets
-- are deliberately excluded from the defaults export and hidden from config
-- mode entirely (see the rail's rendering), so a custom tag created here
-- would be created, then immediately invisible. This appends straight to the
-- live RT_DEFAULT_TAGS -- not a SavedVariable, so the draft only lasts the
-- session, same as any other in-memory edit -- until it's promoted for real
-- by hand-editing RT_DEFAULT_TAGS/DEFAULT_SET_GROUPS and shipping the
-- Export Defaults output. Marked reserved immediately so nothing else can
-- collide with the name while it's still a draft --
local function AddDefaultTag(name)
	table.insert(RT_DEFAULT_TAGS, name)
	RESERVED_TAG_NAMES[name:lower()] = true
	RT.RebuildTagRail()
	RT.SelectToyCategory(name)
end

local function RenameCustomTag(oldName, newName)
	for i, t in ipairs(rtSettings.customTags) do
		if t == oldName then
			rtSettings.customTags[i] = newName
			break
		end
	end
	for _, opt in pairs(rtOptions) do
		if type(opt) == "table" and opt[oldName] ~= nil then
			opt[newName] = opt[oldName]
			opt[oldName] = nil
		end
	end
	-- Carry the macro across the rename: move the enabled flag, retire
	-- the old name's macro and rotation state, and (if it was active)
	-- bring the macro back up under the new name [ReleaseSetRotationState
	-- and the pool functions are globals defined further down] --
	if rtSettings.categoryEnabled[oldName] ~= nil then
		rtSettings.categoryEnabled[newName] = rtSettings.categoryEnabled[oldName]
		rtSettings.categoryEnabled[oldName] = nil
	end
	if rtSettings.hiddenSets[oldName] then
		rtSettings.hiddenSets[newName] = true
		rtSettings.hiddenSets[oldName] = nil
	end
	RT.DeleteCategoryMacro(oldName)
	RT.ReleaseSetRotationState(oldName)
	if rtSettings.categoryEnabled[newName] then
		RT.RefreshCategoryPool(newName)
		RT.SelectRandomToysToy(newName)
	end
	if rtCurrentCategory == oldName then
		rtCurrentCategory = newName
	end
	RT.RebuildTagRail()
	RT.SelectToyCategory(rtCurrentCategory)
end

local function DeleteCustomTag(name)
	for i, t in ipairs(rtSettings.customTags) do
		if t == name then
			table.remove(rtSettings.customTags, i)
			break
		end
	end
	for _, opt in pairs(rtOptions) do
		if type(opt) == "table" then
			opt[name] = nil
		end
	end
	-- Retire the set's macro and settings along with it --
	rtSettings.categoryEnabled[name] = nil
	rtSettings.hiddenSets[name] = nil
	RT.DeleteCategoryMacro(name)
	RT.ReleaseSetRotationState(name)
	RT.RebuildTagRail()
	-- SelectToyCategory falls back to a sane default if the deleted tag
	-- was the one being viewed --
	RT.SelectToyCategory(rtCurrentCategory)
end

-- The rebirth of the old "Any" idea, as a one-shot copy instead of a
-- live union: pulls every toy that's list-true under sourceTag into
-- targetTag, carrying over sourceTag's pool (active) flag for those
-- toys. Merges rather than replaces -- toys already in targetTag but
-- not in sourceTag are left untouched --
local function InheritSetInto(targetTag, sourceTag)
	for _, opt in pairs(rtOptions) do
		if type(opt) == "table" then
			local srcEntry = opt[sourceTag]
			if srcEntry and srcEntry.list then
				opt[targetTag] = {list = true, pool = srcEntry.pool and true or false}
			end
		end
	end
	-- Resync rather than a bare RefreshCategoryPool: the inherited toys
	-- change what Any may draw too, and the macro may be holding a toy this
	-- merge just displaced (see RT.ResyncPoolsForSetEdit) --
	RT.ResyncPoolsForSetEdit(targetTag)
	RT.SelectToyCategory(rtCurrentCategory)
end

-- StaticPopup moved its editbox behind a getter in recent clients;
-- support both so a client-version bump doesn't silently break tags --
local function GetDialogEditBox(dialog)
	return dialog.GetEditBox and dialog:GetEditBox() or dialog.editBox
end

-- Shared by both "+ New Set" flows -- data.isDefaultTag routes to
-- AddDefaultTag (config mode, drafting a shipped set) instead of
-- AddCustomTag (normal mode, or config mode's per-toy tag menu isn't
-- reachable here anyway since it lists RT_DEFAULT_TAGS directly) --
local function AddNewTagFromDialog(name, data)
	if data and data.isDefaultTag then
		AddDefaultTag(name)
	else
		AddCustomTag(name, data and data.toyID)
	end
end

local function RefreshTagNameFeedback(editBox, excluding)
	local dialog = editBox:GetParent()
	local name, reason = RT.CheckTagName(editBox:GetText(), excluding)
	if dialog.SubText then
		dialog.SubText:SetText(reason or " ")
		dialog.SubText:SetTextColor(1, 0.33, 0.33)
		if StaticPopup_Resize and dialog.which then
			StaticPopup_Resize(dialog, dialog.which)
		end
	end
	-- Red while unusable, so it reads as rejected even at a glance --
	if name then
		editBox:SetTextColor(1, 1, 1)
	else
		editBox:SetTextColor(1, 0.4, 0.4)
	end
	local accept = dialog.button1 or (dialog.GetButton1 and dialog:GetButton1())
	if accept then accept:SetEnabled(name ~= nil) end
	return name
end

StaticPopupDialogs["RANDOMTOYS_NEW_TAG"] = {
	text = "输入新集合的名称：",
	subText = " ",
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	maxLetters = 24,
	OnShow = function(dialog)
		RefreshTagNameFeedback(GetDialogEditBox(dialog))
	end,
	EditBoxOnTextChanged = function(editBox)
		RefreshTagNameFeedback(editBox)
	end,
	OnAccept = function(dialog, data)
		local name = RT.ValidateNewTagName(GetDialogEditBox(dialog):GetText())
		if name then
			AddNewTagFromDialog(name, data)
		end
	end,
	EditBoxOnEnterPressed = function(editBox)
		local dialog = editBox:GetParent()
		local name = RT.ValidateNewTagName(editBox:GetText())
		if name then
			AddNewTagFromDialog(name, dialog.data)
		else
			-- Keep the dialog up with the reason showing, rather than
			-- silently throwing the typed name away --
			return
		end
		dialog:Hide()
	end,
	EditBoxOnEscapePressed = function(editBox) editBox:GetParent():Hide() end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1,
}

StaticPopupDialogs["RANDOMTOYS_RENAME_TAG"] = {
	text = '将集合 "%s" 重命名为：',
	subText = " ",
	button1 = ACCEPT,
	button2 = CANCEL,
	hasEditBox = 1,
	maxLetters = 24,
	OnShow = function(dialog, data)
		local editBox = GetDialogEditBox(dialog)
		editBox:SetText(data or "")
		editBox:HighlightText()
		RefreshTagNameFeedback(editBox, data)
	end,
	EditBoxOnTextChanged = function(editBox)
		RefreshTagNameFeedback(editBox, editBox:GetParent().data)
	end,
	OnAccept = function(dialog, data)
		local name = RT.ValidateNewTagName(GetDialogEditBox(dialog):GetText(), data)
		-- An unchanged name is a no-op, not a rename: RenameCustomTag moves
		-- state from the old key to the new one, which would clear its own
		-- work if both keys are the same string --
		if name and name ~= data then
			RenameCustomTag(data, name)
		end
	end,
	EditBoxOnEnterPressed = function(editBox)
		local dialog = editBox:GetParent()
		local name = RT.ValidateNewTagName(editBox:GetText(), dialog.data)
		if not name then return end
		if name ~= dialog.data then
			RenameCustomTag(dialog.data, name)
		end
		dialog:Hide()
	end,
	EditBoxOnEscapePressed = function(editBox) editBox:GetParent():Hide() end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1,
}

StaticPopupDialogs["RANDOMTOYS_DELETE_TAG"] = {
	text = '删除集合 "%s"？玩具会保留其所属的其他集合。',
	button1 = OKAY,
	button2 = CANCEL,
	OnAccept = function(dialog, data)
		DeleteCustomTag(data)
	end,
	timeout = 0,
	whileDead = 1,
	hideOnEscape = 1,
}

-- Right-click menu on a toy cell: the user's custom sets as checkboxes
-- plus "New set...". Config mode swaps this out for the shipped set
-- assignments instead (locked otherwise) -- custom sets are hidden
-- there entirely, matching the config-mode rail, so the two never get
-- mixed up while curating defaults --
function RT.OpenToyTagMenu(cellButton)
	local toyID = cellButton.toyID
	local toy = rtToysByID[toyID]
	MenuUtil.CreateContextMenu(cellButton, function(owner, rootDescription)
		rootDescription:CreateTitle(toy and toy.name or "Toy")
		-- Favorite toggle, above the sets. Owned toys only -- you can't
		-- favorite a toy you haven't collected. Label reflects current state,
		-- and toggling updates the cell's star (and re-sorts if "Favorites
		-- first" is on) so the grid stays in step with the Toy Box --
		if cellButton.owned then
			local isFav = C_ToyBox.GetIsFavorite(toyID)
			rootDescription:CreateButton(isFav and "取消收藏" or "收藏", function()
				C_ToyBox.SetIsFavorite(toyID, not isFav)
				if toy then toy.favorited = not isFav end
				cellButton.favorite:SetShown(not isFav)
				if rtSettings and rtSettings.favoritesFirst then
					RT.SortToyList()
					RT.RebuildToyRows()
				end
				-- Favs draws straight off the favorite flag, so its live
				-- rotation just went stale (see RT.ResyncPoolsForSetEdit) --
				RT.ResyncPoolsForSetEdit("Favs")
			end)
			rootDescription:CreateDivider()
		end
		if rtConfigMode then
			rootDescription:CreateTitle("默认集合")
			for _, tag in ipairs(RT_DEFAULT_TAGS) do
				rootDescription:CreateCheckbox(tag,
					function() return RT.ToyHasTag(toyID, tag) end,
					function() ToggleShippedToyTag(toyID, tag) end)
			end
		else
			for _, tag in ipairs(RT.GetCustomTags()) do
				rootDescription:CreateCheckbox(RT.CustomSetLabel(tag),
					function() return RT.ToyHasTag(toyID, tag) end,
					function() ToggleCustomToyTag(toyID, tag) end)
			end
			rootDescription:CreateButton("新建集合...", function()
				StaticPopup_Show("RANDOMTOYS_NEW_TAG", nil, nil, {toyID = toyID})
			end)
		end
	end)
end

-- Right-click menu on a custom tag's rail entry --
function RT.OpenRailTagMenu(railButton)
	local tag = railButton.tagName
	MenuUtil.CreateContextMenu(railButton, function(owner, rootDescription)
		rootDescription:CreateTitle(tag)
		-- "Inherit" flies out into every other set on hover; picking one
		-- merges its toys (and their active/pool state) into this set --
		local inheritMenu = rootDescription:CreateButton("Inherit")
		inheritMenu:CreateButton("All", function() InheritSetInto(tag, "All") end)
		inheritMenu:CreateDivider()
		-- Same nesting the Sets menu uses (see rtSetsButton's OnClick): each
		-- group is a genuine submenu, flying out under its own arrow, instead
		-- of a flat title + indented rows dumped straight into Inherit --
		for _, group in ipairs(DEFAULT_SET_GROUPS) do
			local groupMenu = inheritMenu:CreateButton(group.header)
			for _, srcTag in ipairs(group.tags) do
				local button = groupMenu:CreateButton(srcTag, function() InheritSetInto(tag, srcTag) end)
				-- Name + description, anchored right of the row (see
				-- AttachSetInfoTooltip) --
				AttachSetInfoTooltip(button, srcTag)
			end
		end
		-- Shipped sets not yet slotted into a group (see GetUngroupedDefaultTags
		-- and the matching section in the Sets menu) -- in practice, only a set
		-- just drafted this session via config mode's "+ New Default Set" --
		local ungroupedDefaults = RT.GetUngroupedDefaultTags()
		if #ungroupedDefaults > 0 then
			inheritMenu:CreateDivider()
			for _, srcTag in ipairs(ungroupedDefaults) do
				local button = inheritMenu:CreateButton(srcTag, function() InheritSetInto(tag, srcTag) end)
				AttachSetInfoTooltip(button, srcTag)
			end
		end
		local otherCustomTags = RT.GetCustomTags()
		if #otherCustomTags > 0 then
			inheritMenu:CreateDivider()
			for _, srcTag in ipairs(otherCustomTags) do
				if srcTag ~= tag then
					inheritMenu:CreateButton(RT.CustomSetLabel(srcTag), function()
						InheritSetInto(tag, srcTag)
					end)
				end
			end
		end
		rootDescription:CreateDivider()
		rootDescription:CreateButton("重命名...", function()
			StaticPopup_Show("RANDOMTOYS_RENAME_TAG", tag, nil, tag)
		end)
		rootDescription:CreateButton("删除", function()
			StaticPopup_Show("RANDOMTOYS_DELETE_TAG", tag, nil, tag)
		end)
	end)
end

-- Right-click menu on a shipped tag's rail entry -- trimmed down to just
-- Hide, since Rename/Delete/Inherit don't apply to shipped sets. Behaves
-- exactly like unchecking the set in the Sets menu (see ToggleSetVisibility) --
function RT.OpenDefaultSetRailMenu(railButton)
	local tag = railButton.tagName
	MenuUtil.CreateContextMenu(railButton, function(owner, rootDescription)
		rootDescription:CreateTitle(tag)
		rootDescription:CreateButton("隐藏", function() ToggleSetVisibility(tag) end)
	end)
end

-- Description [its own line in the header band, above the Sets button
-- and the search box]
local rtDesc = rtTabToys:CreateFontString(nil, "OVERLAY", "GameFontNormal")
rtDesc:SetPoint("TOPLEFT", rtContent, "TOPLEFT", 15, -48)
rtDesc:SetPoint("RIGHT", rtContent, "TOPRIGHT", -15, 0)
rtDesc:SetJustifyH("CENTER")
rtDesc:SetText(rtConfigMode
	and "切换哪些玩具可加入所选集合的列表。右键点击玩具可管理其集合。"
	or "选择或创建集合：左键切换，右键配置，中键使用。")
rtDesc:SetFont(STANDARD_TEXT_FONT, 13)

-- Collection progress bar for the current set, top-center of the header,
-- styled after Blizzard's own toy-box progress bar: a green fill with the
-- count centered on top, the unfilled remainder showing the dark backing.
-- Reflects the selected set rather than the whole collection. Its total and
-- value (and the label) are set by RT.ReflowToyGrid on every set change /
-- rescan --
local rtSetBar = CreateFrame("StatusBar", nil, rtContent)
rtSetBar:SetSize(200, 10)
rtSetBar:SetPoint("TOP", rtContent, "TOP", 0, -15)
rtSetBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
rtSetBar:SetStatusBarColor(0.13, 0.63, 0.11)
rtSetBar:SetMinMaxValues(0, 1)
rtSetBar:SetValue(1)
-- Dark backing behind the fill, so the unfilled remainder reads dark --
local rtSetBarBg = rtSetBar:CreateTexture(nil, "BACKGROUND")
rtSetBarBg:SetAllPoints()
rtSetBarBg:SetColorTexture(0, 0, 0, 0.7)
-- Soft default border framing the bar (tooltip edge asset), instead of the
-- sharp raw edges --
local rtSetBarBorder = CreateFrame("Frame", nil, rtSetBar, "BackdropTemplate")
rtSetBarBorder:SetPoint("TOPLEFT", -5, 5)
rtSetBarBorder:SetPoint("BOTTOMRIGHT", 5, -5)
rtSetBarBorder:SetBackdrop({
	edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
	edgeSize = 14,
})
rtSetBarBorder:SetBackdropBorderColor(1, 1, 1, 0.85)
local rtSetCount = rtSetBar:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
rtSetCount:SetPoint("CENTER", rtSetBar, "CENTER", 0, 0)

-- Three grid toggles beside the search box, laid out left-to-right: show
-- uncollected toys, float Toy Box favorites to the top, and narrow to toys
-- currently on cooldown (that filter lives in ReflowToyGrid). All are wired
-- up further down, once BuildToyList/RebuildToyRows exist --
local function MakeGridCheck(label, anchorTo, anchorPoint)
	local check = CreateFrame("CheckButton", nil, rtTabToys, "UICheckButtonTemplate")
	check:SetPoint("LEFT", anchorTo, anchorPoint, 8, 0)
	check:SetSize(25, 25)
	check.Text:SetText(label)
	check.Text:SetTextColor(1, 1, 1, 1)
	check.Text:SetFont(STANDARD_TEXT_FONT, 12)
	return check
end
local rtShowAllToysCheck    = MakeGridCheck(" 未收集", rtSearchBox, "RIGHT")
local rtFavoritesFirstCheck = MakeGridCheck(" 偏 好", rtShowAllToysCheck.Text, "RIGHT")
local rtOnCooldownCheck     = MakeGridCheck(" 冷却中", rtFavoritesFirstCheck.Text, "RIGHT")

-- The 25px squares are small targets, so make each label a hotspot too: an
-- invisible button sized to the label forwards the click to its checkbox and
-- runs the very same OnClick the square would. That handler is assigned much
-- further down, so it's looked up at click time rather than captured here.
-- No sound, matching the squares -- their SetScript replaces the template's
-- OnClick, so a direct square click is silent as well.
local function MakeCheckLabelClickable(check)
	local hit = CreateFrame("Button", nil, check:GetParent())
	hit:SetAllPoints(check.Text)
	hit:SetFrameLevel(check:GetFrameLevel() + 1)
	hit:RegisterForClicks("LeftButtonUp")
	hit:SetScript("OnClick", function()
		check:SetChecked(not check:GetChecked())
		local onClick = check:GetScript("OnClick")
		if onClick then onClick(check) end
	end)
	-- Same highlight cue the squares give, so the label reads as clickable --
	hit:SetScript("OnEnter", function() check.Text:SetTextColor(1, 0.82, 0) end)
	hit:SetScript("OnLeave", function() check.Text:SetTextColor(1, 1, 1, 1) end)
	return hit
end
MakeCheckLabelClickable(rtShowAllToysCheck)
MakeCheckLabelClickable(rtFavoritesFirstCheck)
MakeCheckLabelClickable(rtOnCooldownCheck)

-- Scroll Frame [starts to the right of the set rail, with breathing
-- room so the grid doesn't crowd the rail's edge]
local rtOptionsScroll = CreateFrame("ScrollFrame", nil, rtTabToys, "UIPanelScrollFrameTemplate")
rtOptionsScroll:SetPoint("TOPLEFT", RAIL_WIDTH + 25, -28)
-- 45, not lower: rtDivider hangs 10px below this frame and must clear
-- the Select/Deselect buttons (top edge at y=30) --
rtOptionsScroll:SetPoint("BOTTOMRIGHT", -20, 45)

-- Divider
local rtDivider = rtOptionsScroll:CreateLine()
rtDivider:SetStartPoint("BOTTOMLEFT", 0, -10)
rtDivider:SetEndPoint("BOTTOMRIGHT", 0, -10)
rtDivider:SetColorTexture(0.25, 0.25, 0.25, 1)
rtDivider:SetThickness(1.2)

-- Scroll Frame child
local rtScrollChild = CreateFrame("Frame")
rtOptionsScroll:SetScrollChild(rtScrollChild)
rtScrollChild:SetWidth(COMMON_WIDTH - 65 - RAIL_WIDTH)
rtScrollChild:SetHeight(1)

--------------------------------------------------------------------
-- Toy row buttons [visual copied from OPie's Custom Ring "Toys" slice
-- picker: quickslot-bordered icon + name + tooltip, in a 2-column grid],
-- but click behaves as an on/off toggle rather than "add to ring", and
-- toggles membership in whichever category is currently selected above.
--------------------------------------------------------------------

local COLS = 3
local GRID_WIDTH = COMMON_WIDTH - 65 - RAIL_WIDTH
local COL_OFFSET = math.floor(GRID_WIDTH / COLS)
local ROW_WIDTH, ROW_HEIGHT, ROW_STEP, ICON_SIZE = COL_OFFSET - 6, 42, 44, 28

-- Mouse wheel over the grid: the default UIPanelScrollFrameTemplate step
-- (half the scroll frame's own height) flew past several rows per notch --
-- half a row reads as a much gentler, more controllable scroll. Same
-- clamped-to-range pattern as the rail's own override above --
rtOptionsScroll:EnableMouseWheel(true)
rtOptionsScroll:SetScript("OnMouseWheel", function(self, delta)
	local maxScroll = math.max(0, rtScrollChild:GetHeight() - self:GetHeight())
	self:SetVerticalScroll(math.min(maxScroll, math.max(0, self:GetVerticalScroll() - delta * ROW_STEP)))
end)

local function PlayCheckboxSound(self)
	PlaySound(SOUNDKIT[self:GetChecked() and "IG_MAINMENU_OPTION_CHECKBOX_ON" or "IG_MAINMENU_OPTION_CHECKBOX_OFF"])
end

-- Sole visual for the checked state (the cells deliberately set no
-- checked texture -- a glow over the icon was too loud): frames the
-- whole cell, border and background both, so a checked toy is obviously
-- checked without anything covering its icon.
local DEFAULT_BORDER_COLOR = {0.3, 0.3, 0.3, 1}
local SELECTED_BORDER_COLOR = {1, 0.82, 0, 1}
local function UpdateToyCellSelection(f)
	local selected = f:GetChecked() and true or false
	local color = selected and SELECTED_BORDER_COLOR or DEFAULT_BORDER_COLOR
	f.borderTop:SetColorTexture(unpack(color))
	f.borderBottom:SetColorTexture(unpack(color))
	f.borderLeft:SetColorTexture(unpack(color))
	f.borderRight:SetColorTexture(unpack(color))
	f.cellBg:SetColorTexture(1, 1, 1, selected and 0.14 or 0.06)
end

local function UpdateToyCellDoubt(f)
	if not (f.toyID and f.owned) then
		f.doubted, f.doubtObserved = false, false
		f.unusable:Hide()
		return
	end
	local doubted, observed = RT.ToyDoubt(f.toyID)
	f.doubted, f.doubtObserved = doubted, observed
	f.unusable:SetShown(doubted)
end
local function UndoCurationFromDrag(f)
	local undo = f.rtCurationUndo
	f.rtCurationUndo = nil
	if not undo then return end
	local entry = rtOptions[f.toyID] and rtOptions[f.toyID][undo.cat]
	if not entry then return end
	entry[undo.field] = undo.prev
	f:SetChecked(undo.prev and true or false)
	UpdateToyCellSelection(f)
	if not rtConfigMode then
		-- Put the rotation back where it was too, or the drag would leave the
		-- live pool disagreeing with the grid --
		RT.ResyncPoolsForSetEdit(undo.cat)
		RT.ReflowToyGrid()
	end
end

-- The item-cooldown global was folded into C_Item; keep a fallback --
local GetToyCooldown = (C_Item and C_Item.GetItemCooldown) or GetItemCooldown

-- Whether a toy is actively on cooldown, plus the raw start/duration for the
-- swipe. Shared by the cell display and the "On cooldown" grid filter so the
-- two never disagree about what counts as on cooldown --
local function ToyCooldownInfo(id)
	local start, duration, enable = GetToyCooldown(id)
	if enable ~= 0 and start and start > 0 and duration and duration > 0 then
		return true, start, duration
	end
	return false
end

-- Mirrors a toy's item cooldown onto its cell icon. Only owned toys have a
-- real cooldown; everything else clears the swipe. Safe in combat (SetCooldown
-- and Clear are display-only, not protected) so cooldowns keep updating even
-- while the protected grid layout is frozen mid-combat --
local function UpdateToyCellCooldown(f)
	if not (f.toyID and f.owned) then
		f.cooldown:Clear()
		return
	end
	local onCD, start, duration = ToyCooldownInfo(f.toyID)
	if onCD then
		f.cooldown:SetCooldown(start, duration)
	else
		f.cooldown:Clear()
	end
end

-- Cooldown countdown numbers default to a size tuned for action-bar buttons,
-- which overruns a 28px toy icon -- give them a smaller font object of our
-- own. Outlined so they stay legible against the dark cooldown swipe --
local rtCooldownFont = CreateFont("RandomToysCooldownFont")
rtCooldownFont:SetFont(STANDARD_TEXT_FONT, 11, "OUTLINE")
rtCooldownFont:SetTextColor(1, 0.82, 0)

-- Darker background for the toy hover tooltip -- the default is fairly
-- see-through against the grid, which hurts readability. A plain texture
-- drawn over GameTooltip's own background, rather than recoloring its
-- backdrop directly -- this client's tooltip doesn't consistently expose
-- SetBackdropColor/GetBackdropColor, and this way there's no shared state
-- to restore on other tooltips either: just Show/Hide alongside ours --
local rtTooltipDarken = GameTooltip:CreateTexture(nil, "BACKGROUND", nil, 1)
rtTooltipDarken:SetAllPoints(GameTooltip)
rtTooltipDarken:SetColorTexture(0, 0, 0, 0.6)
rtTooltipDarken:Hide()
local InsertChatLink = (ChatFrameUtil and ChatFrameUtil.InsertLink) or ChatEdit_InsertLink
local function InsertToyChatLink(toyID)
	if not (toyID and InsertChatLink) then return end
	local link = C_ToyBox.GetToyLink(toyID)
	if link then InsertChatLink(link) end
end

local function CreateToyButtonShell(parent)
	-- SecureActionButtonTemplate so a middle-click can fire the toy directly
	-- (using a toy is a protected action). The "toy"/type3 attributes are set
	-- per-cell in RebuildToyRows; our own curation clicks ride on a HookScript
	-- below so the template's secure OnClick isn't clobbered. The cost of the
	-- secure template: the cell is now a protected frame, so it can't be moved
	-- or shown/hidden in combat -- RebuildToyRows and ReflowToyGrid both bail
	-- out during combat and re-run once it ends (see PLAYER_REGEN_ENABLED) --
	local f = CreateFrame("CheckButton", nil, parent, "SecureActionButtonTemplate")
	f:SetSize(ROW_WIDTH, ROW_HEIGHT)

	-- Subtle cell background so each toy reads as a contained card
	-- instead of floating in empty space --
	f.cellBg = f:CreateTexture(nil, "BACKGROUND", nil, -2)
	f.cellBg:SetAllPoints(f)
	f.cellBg:SetColorTexture(1, 1, 1, 0.06)

	-- Thin border, same grey tone as the dividers used elsewhere in this UI
	-- -- stored on f (by name) so UpdateToyCellSelection can recolor them
	-- when this cell is checked --
	for _, spec in ipairs({
		{key = "borderTop",    p1 = "TOPLEFT",  p2 = "TOPRIGHT",    dim = "SetHeight"},
		{key = "borderBottom", p1 = "BOTTOMLEFT", p2 = "BOTTOMRIGHT", dim = "SetHeight"},
		{key = "borderLeft",   p1 = "TOPLEFT",  p2 = "BOTTOMLEFT",  dim = "SetWidth"},
		{key = "borderRight",  p1 = "TOPRIGHT", p2 = "BOTTOMRIGHT", dim = "SetWidth"},
	}) do
		local tex = f:CreateTexture(nil, "BORDER")
		tex:SetColorTexture(unpack(DEFAULT_BORDER_COLOR))
		tex:SetPoint(spec.p1, 0, 0)
		tex:SetPoint(spec.p2, 0, 0)
		tex[spec.dim](tex, 1)
		f[spec.key] = tex
	end

	f.icon = f:CreateTexture(nil, "ARTWORK")
	f.icon:SetSize(ICON_SIZE, ICON_SIZE)
	f.icon:SetPoint("LEFT", 3, 0)

	-- Quickslot-style border, same textures OPie uses for its action icons,
	-- dimmed to half so the ring doesn't outshine the icon itself --
	local bg = f:CreateTexture(nil, "BACKGROUND", nil, -1)
	bg:SetTexture("Interface/Buttons/UI-EmptySlot-Disabled")
	bg:SetPoint("CENTER", f.icon, "CENTER")
	bg:SetSize(1.5*ICON_SIZE, 1.5*ICON_SIZE)
	bg:SetVertexColor(0.5, 0.5, 0.5)
	local edge = f:CreateTexture(nil, "OVERLAY", nil, -1)
	edge:SetTexture("Interface/Buttons/UI-Quickslot2")
	edge:SetSize(1.625*ICON_SIZE, 1.625*ICON_SIZE)
	edge:SetPoint("CENTER", f.icon, "CENTER", 0.25, -0.25)
	edge:SetVertexColor(0.5, 0.5, 0.5)
	local mask = f:CreateMaskTexture()
	mask:SetTexture("Interface/FrameGeneral/UIFrameIconMask")
	mask:SetAllPoints(f.icon)
	f.icon:AddMaskTexture(mask)

	-- Cooldown swipe mirroring the toy's item cooldown. The Cooldown frame
	-- animates itself down once given a start/duration, so it only needs
	-- (re)setting when a cooldown starts or the cell is rebound to another
	-- toy -- see UpdateToyCellCooldown / RefreshToyCooldowns. Mouse stays
	-- disabled so it never eats the cell's clicks or hover --
	f.cooldown = CreateFrame("Cooldown", nil, f, "CooldownFrameTemplate")
	f.cooldown:SetAllPoints(f.icon)
	f.cooldown:SetDrawEdge(false)
	f.cooldown:EnableMouse(false)
	if f.cooldown.SetCountdownFont then
		f.cooldown:SetCountdownFont("RandomToysCooldownFont")
	end

	-- Toy Box favorite star, riding the icon's top-left corner -- same
	-- texture the Collections Journal stamps on favorites. Shown per-toy
	-- in RebuildToyRows --
	f.favorite = f:CreateTexture(nil, "OVERLAY")
	f.favorite:SetTexture("Interface/Common/FavoritesIcon")
	f.favorite:SetSize(18, 18)
	f.favorite:SetPoint("CENTER", f.icon, "TOPLEFT", 3, -3)
	f.favorite:Hide()

	-- Marks a toy that probably won't fire: either the client says it's
	-- unusable, or the rotation has watched it fail (see RT.ToyLooksUnusable).
	-- Neither opinion removes the toy from anything -- the point is to make
	-- the reason VISIBLE, since until now a faction-locked toy looked exactly
	-- like any other and you'd only notice by wondering why it never came up.
	-- Opposite corner from the favorite star so the two never collide --
	f.unusable = f:CreateTexture(nil, "OVERLAY")
	f.unusable:SetTexture("Interface/DialogFrame/UI-Dialog-Icon-AlertNew")
	f.unusable:SetSize(16, 16)
	f.unusable:SetPoint("CENTER", f.icon, "BOTTOMRIGHT", -2, 2)
	f.unusable:Hide()

	-- Hover highlight only -- no checked glow over the icon; selected
	-- state is carried entirely by the cell border and background (see
	-- UpdateToyCellSelection) --
	f:SetHighlightTexture("Interface/Buttons/ButtonHilight-Square")
	f:GetHighlightTexture():SetBlendMode("ADD")
	f:GetHighlightTexture():SetAllPoints(f.icon)

	-- Full width of the column, allowed to wrap to a second line instead
	-- of being truncated. In config mode, a small tag line sits below it
	-- listing which other categories this toy is already assigned to --
	-- outside config mode there's no tag line, so the name is vertically
	-- centered against the icon instead of pinned to the top.
	f.name = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
	f.name:SetNonSpaceWrap(true)
	f.name:SetPoint("RIGHT", -2, 0)
	f.name:SetJustifyH("LEFT")
	if rtConfigMode then
		f.name:SetHeight(ROW_HEIGHT - 14)
		f.name:SetJustifyV("TOP")
		f.name:SetPoint("TOPLEFT", ICON_SIZE + 7, -2)
	else
		f.name:SetHeight(ROW_HEIGHT - 4)
		f.name:SetJustifyV("MIDDLE")
		f.name:SetPoint("LEFT", ICON_SIZE + 7, 0)
	end

	-- Abbreviation tags are only meaningful while curating lists in config
	-- mode -- outside it they're just clutter, so the fontstring is never
	-- shown (and RebuildToyRows skips computing its text entirely) --
	f.tags = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
	f.tags:SetPoint("BOTTOMLEFT", ICON_SIZE + 7, 2)
	f.tags:SetPoint("RIGHT", -2, 0)
	f.tags:SetJustifyH("LEFT")
	f.tags:SetShown(rtConfigMode)

	f:SetScript("OnEnter", function(self)
		GameTooltip:SetOwner(self, "ANCHOR_NONE")
		GameTooltip:SetPoint("TOPLEFT", self, "TOPRIGHT")
		GameTooltip:SetToyByItemID(self.toyID)
		-- Say what the warning marker means, and be honest about how sure we
		-- are: the client's own flag is unreliable (it's wrong for toys that
		-- work fine), while a toy we've watched fail is a real observation --
		if self.doubted and self.doubtObserved then
			GameTooltip:AddLine("Hasn't worked when used, so the rotation saves it for last. Use it successfully once and this clears.", 1, 0.5, 0.5, true)
		elseif self.doubted then
			GameTooltip:AddLine("Your client reports this toy as unusable, so the rotation saves it for last. That report is sometimes wrong -- use the toy once and this clears for good.", 1, 0.5, 0.5, true)
		end
		rtTooltipDarken:Show()
		GameTooltip:Show()
	end)
	f:SetScript("OnLeave", function()
		GameTooltip:Hide()
		rtTooltipDarken:Hide()
	end)

	-- Left-drag a toy onto an action bar, exactly as Blizzard's own Toy Box
	-- does it (ToySpellButton_OnDrag calls the same one function).
	--
	-- The wrinkle is ours: curation fires on the DOWN phase, and a drag has
	-- to start with a press, so the click has already toggled this toy's pool
	-- membership by the time OnDragStart runs. Dragging a toy to your bars
	-- must not quietly edit your sets, so the toggle is rolled back here --
	-- the click handler records what it changed for exactly this purpose.
	--
	-- Picking an item onto the cursor is protected, so this is out-of-combat
	-- only; the client wouldn't let you drop it on a bar mid-fight anyway --
	f:RegisterForDrag("LeftButton")
	f:SetScript("OnDragStart", function(self)
		UndoCurationFromDrag(self)
		if InCombatLockdown() or not (self.toyID and self.owned) then return end
		C_ToyBox.PickupToyBoxItem(self.toyID)
	end)

	-- Right-clicks (menu) fire on the up phase. Everything that can fire a toy
	-- goes on the DOWN phase -- like the set macros' secure buttons, toys only
	-- trigger on down -- so both the plain left-click (curation, no secure
	-- action) and the toy-firing clicks (middle, and Ctrl+Left via the
	-- ctrl-type1 attribute set in RebuildToyRows) live on LeftButtonDown /
	-- MiddleButtonDown. Single-phase per button keeps the CheckButton's auto
	-- toggle firing exactly once --
	f:RegisterForClicks("LeftButtonDown", "RightButtonUp", "MiddleButtonDown")
	-- HookScript, not SetScript: the secure template installs its own OnClick
	-- to fire the toy (middle-click and Ctrl+Left, owned toys only -- see
	-- RebuildToyRows), and this insecure hook runs after it for curation and
	-- the uncollected fallback --
	f:HookScript("OnClick", function(self, mouseButton)
		-- Any click that turns out NOT to curate leaves nothing for a drag to
		-- roll back, so clear the record up front (see UndoCurationFromDrag);
		-- the curation branch at the bottom sets a fresh one --
		self.rtCurationUndo = nil
		if mouseButton == "MiddleButton" or (mouseButton == "LeftButton" and IsControlKeyDown()) then
			-- Middle-click and Ctrl+Left both mean "use this toy". Owned toys
			-- were just fired by the secure OnClick above; nothing to do but
			-- undo the CheckButton's automatic toggle (neither curates). Unowned
			-- toys have no secure action, so fall back to opening the Toy Box
			-- searched to this toy instead --
			self:SetChecked(not self:GetChecked())
			if not self.owned then
				RT.OpenToyInToyBox(self.toyID)
			end
			return
		end
		if mouseButton == "LeftButton" and IsModifiedClick("CHATLINK") then
			-- Shift+Left (whatever CHATLINK is bound to) links the toy into
			-- whatever text field is open, exactly like shift-clicking a toy
			-- in Blizzard's own grid (ToySpellButton_OnModifiedClick). This
			-- is why "use" moved to Ctrl: a shift-type1 secure action here
			-- consumed the click and no link ever reached the edit box.
			-- Undo the CheckButton's automatic toggle -- linking never
			-- curates --
			self:SetChecked(not self:GetChecked())
			InsertToyChatLink(self.toyID)
			return
		end
		if mouseButton == "RightButton" then
			-- A CheckButton flips its checked state on ANY registered click
			-- before OnClick runs -- undo that, right-click is menu-only --
			self:SetChecked(not self:GetChecked())
			UpdateToyCellSelection(self)
			RT.OpenToyTagMenu(self)
			return
		end
		if rtCurrentCategory == UNTAGGED or rtCurrentCategory == "Any" or rtCurrentCategory == "Favs" then
			-- Pure filter views, nothing stored under any of these -- No
			-- Set has no pool to toggle, Any is fully derived from whichever
			-- sets are enabled, and Favs mirrors Blizzard's own favorite
			-- flag; right-click a toy's own set(s) from another view to
			-- change what lands here, or star/unstar it to change Favs --
			self:SetChecked(false)
			UpdateToyCellSelection(self)
			return
		end
		if not self.owned and not rtConfigMode then
			-- Uncollected toys can only be pre-added to a category's list
			-- (config mode), not activated in its pool -- you can't use
			-- a toy you don't have yet --
			self:SetChecked(false)
			UpdateToyCellSelection(self)
			return
		end
		PlayCheckboxSound(self)
		local opt = rtOptions[self.toyID]
		if not opt then
			opt = {}
			rtOptions[self.toyID] = opt
		end
		local entry = opt[rtCurrentCategory]
		if not entry then
			entry = {list = false, pool = false}
			opt[rtCurrentCategory] = entry
		end
		-- Remember what this press changed, in case it turns out to be the
		-- start of a drag rather than a click (see UndoCurationFromDrag) --
		local field = rtConfigMode and "list" or "pool"
		self.rtCurationUndo = {cat = rtCurrentCategory, field = field, prev = entry[field]}
		entry[field] = self:GetChecked() and true or false
		UpdateToyCellSelection(self)
		if not rtConfigMode then
			-- The set's rotation runs off a snapshot of its pool -- tell it
			-- the pool just changed, or this toggle wouldn't reach the macro
			-- until that snapshot ran dry (see RT.ResyncPoolsForSetEdit) --
			RT.ResyncPoolsForSetEdit(rtCurrentCategory)
			RT.ReflowToyGrid()
		end
	end)

	return f
end

-- Reuse row frames across rescans instead of recreating them --
local rtToyButtons = {}

-- Re-marks every cell. Called when the rotation learns something -- a toy
-- that just fired stops being doubted, and the grid has to stop saying so
-- while the player is looking straight at it --
function RT.RefreshToyDoubtMarkers()
	for i = 1, #rtToyButtons do
		UpdateToyCellDoubt(rtToyButtons[i])
	end
end
local function GetOrCreateToyButton(i)
	if not rtToyButtons[i] then
		rtToyButtons[i] = CreateToyButtonShell(rtScrollChild)
	end
	return rtToyButtons[i]
end

--------------------------------------------------------------------
-- Positions only the rows matching the current search text, in order,
-- and hides the rest. Select All / Deselect All still act on every toy.
--------------------------------------------------------------------

-- Shown when the grid comes up empty -- mostly so a brand-new custom
-- tag explains how to fill itself instead of just sitting blank --
local rtEmptyHint = rtTabToys:CreateFontString(nil, "OVERLAY", "GameFontDisable")
rtEmptyHint:SetPoint("CENTER", rtOptionsScroll, "CENTER", 0, 20)
rtEmptyHint:SetJustifyH("CENTER")
rtEmptyHint:Hide()

-- Search also matches a toy's tooltip text -- both its compact tooltip
-- lines (flavor text, "Use:" line) AND its linked spell's full
-- description, which is where most of the actual detail lives (a
-- toy's own tooltip line is often just a short "Use:" summary; the
-- spell description behind it -- data.spellId, already resolved in
-- BuildToyList -- is the fuller text, and is what a word like "cook"/
-- "cooking" is actually far more likely to appear in). Neither source
-- is cheap to fetch, so the combined text is memoized per toy the first
-- time it's needed -- a search box redraw shouldn't be re-querying on
-- every keystroke for toys whose name already matched (the "or" below
-- short-circuits past this call whenever it can). Only cached once both
-- sources have actually resolved; toys still loading are retried on the
-- next search rather than getting stuck with a half-empty cache entry --
local rtToyTooltipTextCache = {}
local function GetToyTooltipText(toyID, spellId)
	local cached = rtToyTooltipTextCache[toyID]
	if cached then return cached end
	local parts = {}
	local haveTooltip = false
	local info = C_TooltipInfo.GetToyByItemID(toyID)
	if info and info.lines then
		haveTooltip = true
		for _, line in ipairs(info.lines) do
			if line.leftText then table.insert(parts, line.leftText) end
			if line.rightText then table.insert(parts, line.rightText) end
		end
	end
	local haveDesc = not spellId
	if spellId then
		local desc = C_Spell.GetSpellDescription(spellId)
		if desc and desc ~= "" then
			table.insert(parts, desc)
			haveDesc = true
		end
	end
	local text = table.concat(parts, " "):lower()
	if haveTooltip and haveDesc then
		rtToyTooltipTextCache[toyID] = text
	end
	return text
end

-- Search also matches by set/tag name -- e.g. typing "flair" turns up
-- every toy currently in the Flair set, regardless of which view you're
-- browsing. Only sets the toy actually belongs to (list = true) count --
local function ToyMatchesSetNameSearch(toyID, query)
	local opt = rtOptions[toyID]
	if not opt then return false end
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		local e = opt[tag]
		if e and e.list and tag:lower():find(query, 1, true) then return true end
	end
	for _, tag in ipairs(RT.GetCustomTags()) do
		local e = opt[tag]
		if e and e.list and tag:lower():find(query, 1, true) then return true end
	end
	return false
end

-- Whether a toy belongs to the currently selected set/view. Shared by the
-- grid's display filter and the header count, so both agree. Owned toys
-- always have a synced rtOptions entry; unowned toys outside the current
-- scan may not, so shipped-set membership falls back to the defaults table
-- for them (All is universal; Untagged/Any are derived and nil-safe) --
local function ToyInSet(id)
	local cat = rtCurrentCategory
	if cat == "All" then return true end
	local opt = rtOptions[id]
	if cat == UNTAGGED then return RT.ToyIsUntagged(opt) end
	if cat == "Any" then return RT.IsToyInAny(opt) end
	if cat == "Favs" then return RT.ToyIsFavorited(id) end
	local entry = opt and opt[cat]
	if entry ~= nil then return entry.list and true or false end
	local shipped = RT_DEFAULT_TOY_LISTS[id]
	return shipped and shipped[cat] and true or false
end

-- Set true when a grid refresh is requested during combat, since the toy
-- cells are protected (secure template) and can't be moved/shown/hidden then.
-- PLAYER_REGEN_ENABLED replays the refresh once combat ends --
local rtRefreshDeferred = false

function RT.ReflowToyGrid()
	if InCombatLockdown() then rtRefreshDeferred = true return end
	local query = rtSearchBox:GetText():lower()
	local shown = 0
	for i = 1, #rtToys do
		local row = rtToyButtons[i]
		local data = rtToys[i]
		local matchesSearch = query == "" or data.name:lower():find(query, 1, true)
			or GetToyTooltipText(data.id, data.spellId):find(query, 1, true)
			or ToyMatchesSetNameSearch(data.id, query)
		local inList = ToyInSet(data.id)
		-- "On cooldown" filter: only owned toys that are actively ticking. An
		-- unowned toy is never on cooldown, so it's always excluded here --
		local passesCooldown = true
		if rtSettings.onCooldownOnly then
			passesCooldown = data.owned and ToyCooldownInfo(data.id) or false
		end
		if matchesSearch and inList and passesCooldown then
			shown = shown + 1
			local col = (shown - 1) % COLS
			local r = math.floor((shown - 1) / COLS)
			row:ClearAllPoints()
			row:SetPoint("TOPLEFT", 2 + col * COL_OFFSET, -(r * ROW_STEP))
			row:Show()
		else
			row:Hide()
		end
	end
	rtScrollChild:SetHeight(math.max(1, math.ceil(shown / COLS) * ROW_STEP))

	-- Header progress bar. Counted over the FULL toy universe (rtAllToys),
	-- so collected/total is accurate for the set no matter how the Uncollected
	-- toggle is set, and ignoring the search box so it's the set's size. When
	-- the set has no uncollected toys, show just the one number in the
	-- uncommon-green quality color; otherwise show "collected / total" --
	local setTotal, setOwned = 0, 0
	for i = 1, #rtAllToys do
		local t = rtAllToys[i]
		if ToyInSet(t.id) then
			setTotal = setTotal + 1
			if t.owned then setOwned = setOwned + 1 end
		end
	end
	rtSetBar:SetMinMaxValues(0, math.max(setTotal, 1))
	rtSetBar:SetValue(setOwned)
	if setOwned >= setTotal then
		rtSetCount:SetText(tostring(setTotal))
		rtSetCount:SetTextColor(0.12, 1.0, 0.0) -- uncommon green: fully collected
	else
		rtSetCount:SetText(setOwned .. " / " .. setTotal)
		rtSetCount:SetTextColor(1, 1, 1)
	end

	if shown == 0 then
		local railBtn = rtRailButtonByTag[rtCurrentCategory]
		if rtSettings.onCooldownOnly and query == "" then
			rtEmptyHint:SetText("当前没有正在冷却的玩具。")
		elseif query ~= "" then
			rtEmptyHint:SetText("没有匹配的玩具。")
		elseif rtCurrentCategory == UNTAGGED then
			rtEmptyHint:SetText("每个玩具都已属于至少一个集合。")
		elseif rtCurrentCategory == "Any" then
			rtEmptyHint:SetText("当前没有集合正在维护宏。")
		elseif rtCurrentCategory == "Favs" then
			rtEmptyHint:SetText("你收藏的玩具会显示在这里。")
		elseif railBtn and railBtn.isCustom then
			rtEmptyHint:SetText('集合 "' .. rtCurrentCategory .. '" 中还没有玩具。\n\n在其他视图中右键点击任意玩具，\n即可将其加入或移出集合。')
		else
			rtEmptyHint:SetText("这里还没有玩具。")
		end
		rtEmptyHint:Show()
	else
		rtEmptyHint:Hide()
	end
end
rtSearchBox:SetScript("OnTextChanged", function(self)
	SearchBoxTemplate_OnTextChanged(self)
	RT.ReflowToyGrid()
end)

--------------------------------------------------------------------
-- Binds current rtToys/rtOptions data (for rtCurrentCategory) onto
-- the (reused) row frames
--------------------------------------------------------------------
-- Re-syncs every cell's cooldown swipe. Cheap and non-protected, so unlike
-- RebuildToyRows it's fine to run in combat; it bails immediately when the
-- panel is hidden, which is the common case while these cooldown events (which
-- fire constantly) are flying. All cells are updated -- not just visible ones --
-- so that when the "On cooldown" filter later shows a cell it already carries
-- the right swipe --
local function RefreshToyCooldowns()
	if not rtContent:IsVisible() then return end
	for i = 1, #rtToyButtons do
		UpdateToyCellCooldown(rtToyButtons[i])
	end
end

-- SPELL_UPDATE_COOLDOWN can fire many times a second, so coalesce the bursts
-- into at most one grid-wide refresh per 100ms. Skips scheduling entirely
-- while the panel is hidden (the swipes animate themselves regardless). While
-- the "On cooldown" filter is active, a cooldown starting or ending changes
-- which toys qualify, so re-run the grid filter too --
local rtCooldownRefreshScheduled = false
local function RequestCooldownRefresh()
	if rtCooldownRefreshScheduled or not rtContent:IsVisible() then return end
	rtCooldownRefreshScheduled = true
	C_Timer.After(0.1, function()
		rtCooldownRefreshScheduled = false
		RefreshToyCooldowns()
		if rtSettings.onCooldownOnly then
			RT.ReflowToyGrid()
		end
	end)
end

function RT.RebuildToyRows()
	if InCombatLockdown() then rtRefreshDeferred = true return end
	for i = 1, #rtToys do
		local data = rtToys[i]
		local f = GetOrCreateToyButton(i)
		f.toyID = data.id
		f.owned = data.owned
		-- Bind (or clear) the "use this toy" secure action for both triggers:
		-- middle-click (type3) and Ctrl+Left (ctrl-type1), sharing the one
		-- "toy" value. Only owned toys get it -- you can't use one you don't
		-- have, so those fall through to the Toy Box search in the click hook.
		-- Safe to SetAttribute here because RebuildToyRows bails while in combat --
		SetToyClickAction(f, data.owned and data.id or nil)
		f.icon:SetTexture(data.icon)
		f.icon:SetDesaturated(not data.owned)
		f.favorite:SetShown(data.favorited and true or false)
		UpdateToyCellDoubt(f)
		f.name:SetText(data.name)
		f.name:SetTextColor(data.owned and 1 or 0.5, data.owned and 1 or 0.5, data.owned and 1 or 0.5)
		local opt = rtOptions[data.id]
		local flag
		if rtCurrentCategory == "Any" then
			flag = RT.IsToyInAny(opt)
		elseif rtCurrentCategory == "Favs" then
			flag = RT.ToyIsFavorited(data.id)
		else
			local entry = opt and opt[rtCurrentCategory]
			flag = entry and (rtConfigMode and entry.list or entry.pool)
		end
		f:SetChecked((data.owned or rtConfigMode) and flag and true or false)
		UpdateToyCellSelection(f)

		if rtConfigMode then
			-- Iterating RT_DEFAULT_TAGS rather than CATEGORIES also keeps
			-- "All" out of this line -- every toy has it, so it says nothing --
			local otherCats = {}
			if opt then
				for _, c in ipairs(RT_DEFAULT_TAGS) do
					if c ~= rtCurrentCategory then
						local e = opt[c]
						if e and e.list then table.insert(otherCats, CATEGORY_ABBR[c] or c) end
					end
				end
				for _, c in ipairs(RT.GetCustomTags()) do
					if c ~= rtCurrentCategory then
						local e = opt[c]
						if e and e.list then table.insert(otherCats, c) end
					end
				end
			end
			f.tags:SetText(#otherCats > 0 and ("同时属于：" .. table.concat(otherCats, ", ")) or "")
		end
	end
	for i = #rtToys + 1, #rtToyButtons do
		rtToyButtons[i]:Hide()
	end
	RT.ReflowToyGrid()
	RefreshToyCooldowns()
end

-- Global so closures compiled earlier in the file can call it by name --
-- Lua resolves that lookup at call time, not when they were created --
function RT.SelectToyCategory(tag)
	-- Falls back to a sane view when asked for a set with no rail entry
	-- (just hidden via the Sets menu, or a custom set that was just
	-- deleted): "All" when visible, else the first entry still in the
	-- rail --
	if not rtRailButtonByTag[tag] then
		tag = rtConfigMode and "Full" or "All"
		if not rtRailButtonByTag[tag] then
			-- Even "All" is hidden -- take the rail's topmost entry
			-- (rtRailButtons is in display order) --
			for _, b in ipairs(rtRailButtons) do
				if b:IsShown() then tag = b.tagName break end
			end
		end
	end
	rtCurrentCategory = tag
	for name, b in pairs(rtRailButtonByTag) do
		if name == tag then b:LockHighlight() else b:UnlockHighlight() end
	end
	RT.RebuildToyRows()
end

-- First build of the rail: custom tags aren't loaded yet (saved
-- variables arrive at ADDON_LOADED, which rebuilds), but this puts the
-- shipped tags up and applies the initial selection highlight --
RT.RebuildTagRail()


-- Moves saved data from a renamed category's old key to its new one
-- (see CATEGORY_RENAMES above), and deletes the now-stale macro left
-- behind under the old name --
local function MigrateCategoryRenames()
	for oldName, newName in pairs(CATEGORY_RENAMES) do
		if rtSettings.categoryEnabled[oldName] ~= nil then
			if rtSettings.categoryEnabled[newName] == nil then
				rtSettings.categoryEnabled[newName] = rtSettings.categoryEnabled[oldName]
			end
			rtSettings.categoryEnabled[oldName] = nil
		end

		for _, opt in pairs(rtOptions) do
			if type(opt) == "table" and opt[oldName] ~= nil then
				if opt[newName] == nil then
					opt[newName] = opt[oldName]
				end
				opt[oldName] = nil
			end
		end

		local oldMacroIndex = GetMacroIndexByName(oldName)
		if oldMacroIndex > 0 then
			DeleteMacro(oldMacroIndex)
		end
	end
end

rtShowAllToysCheck:SetScript("OnClick", function(self)
	rtSettings.showAllToys = self:GetChecked() and true or false
	RT.BuildToyList()
	RT.SyncNewToysIntoOptions()
	RT.RebuildToyRows()
end)

rtFavoritesFirstCheck:SetScript("OnClick", function(self)
	rtSettings.favoritesFirst = self:GetChecked() and true or false
	-- Only the order changes -- re-sort what's already scanned --
	RT.SortToyList()
	RT.RebuildToyRows()
end)

rtOnCooldownCheck:SetScript("OnClick", function(self)
	rtSettings.onCooldownOnly = self:GetChecked() and true or false
	-- Pure visibility filter -- no rescan or re-sort, just re-run the grid.
	-- Refresh swipes first so cells the filter is about to reveal are current --
	RefreshToyCooldowns()
	RT.ReflowToyGrid()
end)

-- Select All button --
-- Bulk sets every toy's selection in the current set to `value` (config
-- mode toggles list membership; pool mode toggles the active pool, and
-- only for toys already in the list). Select-all leaves toys you don't
-- own untouched -- activating one you can't use makes no sense -- while
-- deselect-all clears everything. Untagged/Any/Favs are pure filter views
-- with no storage of their own, so bulk edits there are refused --
local function BulkSetSelection(value)
	if rtCurrentCategory == UNTAGGED or rtCurrentCategory == "Any" or rtCurrentCategory == "Favs" then return end
	for i = 1, #rtToys do
		local data = rtToys[i]
		if not (value and not data.owned) then
			local opt = rtOptions[data.id]
			if not opt then
				opt = {}
				rtOptions[data.id] = opt
			end
			local entry = opt[rtCurrentCategory]
			if not entry then
				entry = {list = false, pool = false}
				opt[rtCurrentCategory] = entry
			end
			if rtConfigMode then
				entry.list = value
				rtToyButtons[i]:SetChecked(value)
				UpdateToyCellSelection(rtToyButtons[i])
			elseif entry.list then
				entry.pool = value
				rtToyButtons[i]:SetChecked(value)
				UpdateToyCellSelection(rtToyButtons[i])
			end
		end
	end
	if not rtConfigMode then
		RT.ResyncPoolsForSetEdit(rtCurrentCategory)
		RT.ReflowToyGrid()
	end
end

local rtSelectAll = CreateFrame("Button", nil, rtTabToys, "UIPanelButtonTemplate")
rtSelectAll:SetPoint("BOTTOMLEFT", RAIL_WIDTH + 30, 5)
rtSelectAll:SetSize(100, 25)
rtSelectAll:SetText("全选")
-- Bulk-selecting straight from the full collection is too blunt while
-- curating the list itself -- only offered in pool mode --
rtSelectAll:SetShown(not rtConfigMode)
rtSelectAll:SetScript("OnClick", function(self) BulkSetSelection(true) end)

-- Deselect All button --
local rtDeselectAll = CreateFrame("Button", nil, rtTabToys, "UIPanelButtonTemplate")
rtDeselectAll:SetPoint("BOTTOMLEFT", RAIL_WIDTH + 145, 5)
rtDeselectAll:SetSize(100, 25)
rtDeselectAll:SetText("反选")
rtDeselectAll:SetShown(not rtConfigMode)
rtDeselectAll:SetScript("OnClick", function(self) BulkSetSelection(false) end)

--------------------------------------------------------------------
-- Export Defaults [config mode only, since that's where list membership
-- is actually curated -- dumps current picks in the exact format
-- RandomToysDefaults.lua expects, ready to copy/paste in]
--------------------------------------------------------------------

local rtExportWindow = CreateFrame("Frame", "RandomToysExportWindow", UIParent, "BasicFrameTemplateWithInset")
rtExportWindow:SetSize(500, 500)
rtExportWindow:SetPoint("CENTER")
rtExportWindow:SetFrameStrata("DIALOG")
rtExportWindow:SetClampedToScreen(true)
rtExportWindow:Hide()
rtExportWindow.TitleText:SetText("Random Toys - 导出默认值")
rtExportWindow:SetMovable(true)
rtExportWindow:EnableMouse(true)
rtExportWindow:RegisterForDrag("LeftButton")
rtExportWindow:SetScript("OnDragStart", rtExportWindow.StartMoving)
rtExportWindow:SetScript("OnDragStop", rtExportWindow.StopMovingOrSizing)
tinsert(UISpecialFrames, "RandomToysExportWindow")

local rtExportHint = rtExportWindow:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
rtExportHint:SetPoint("TOPLEFT", 12, -30)
rtExportHint:SetPoint("RIGHT", -12, 0)
rtExportHint:SetJustifyH("LEFT")
rtExportHint:SetText("按 Ctrl+A 全选、Ctrl+C 复制；用 RT_DEFAULT_TOY_LISTS 表覆盖 RandomToysDefaults.lua 中的同名表，并用 RT_DEFAULT_TAGS 表覆盖 RandomToys.lua 中的同名表（在 CATEGORY_DESCRIPTIONS 中为新集合添加简介），然后执行 /reload。仅导出内置集合，不包含自定义集合。")

local rtExportScroll = CreateFrame("ScrollFrame", nil, rtExportWindow, "UIPanelScrollFrameTemplate")
rtExportScroll:SetPoint("TOPLEFT", 12, -50)
rtExportScroll:SetPoint("BOTTOMRIGHT", -30, 40)

local rtExportBox = CreateFrame("EditBox", nil, rtExportScroll)
rtExportBox:SetMultiLine(true)
rtExportBox:SetFontObject(ChatFontNormal)
rtExportBox:SetWidth(440)
rtExportBox:SetAutoFocus(false)
rtExportBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
rtExportScroll:SetScrollChild(rtExportBox)

local rtExportClose = CreateFrame("Button", nil, rtExportWindow, "UIPanelButtonTemplate")
rtExportClose:SetPoint("BOTTOM", 0, 10)
rtExportClose:SetSize(90, 22)
rtExportClose:SetText("关闭")
rtExportClose:SetScript("OnClick", function() rtExportWindow:Hide() end)

-- Only list membership is exported -- pool is a runtime "currently
-- active" flag, not a curation choice. On import, a shipped list entry
-- seeds pool to match (see SyncNewToysIntoOptions), so exporting pool
-- separately would be redundant --

-- Custom tag names can hold anything (spaces, punctuation), so names
-- that aren't clean Lua identifiers get the ["bracket"] key form --
local function TagKeyForExport(tag)
	if tag:match("^[%a_][%w_]*$") then
		return tag
	end
	return string.format("[%q]", tag)
end

local function BuildDefaultsExportString()
	-- Shipped tags only -- custom sets are deliberately excluded so they
	-- never bleed into the shipped defaults blob (they're also hidden
	-- from config mode entirely). Promoting a set to shipped is now a
	-- manual step: add its name to RT_DEFAULT_TAGS by hand --
	local exportTags = {}
	for _, tag in ipairs(RT_DEFAULT_TAGS) do
		table.insert(exportTags, tag)
	end

	local ids = {}
	for id, opt in pairs(rtOptions) do
		if type(opt) == "table" then
			table.insert(ids, id)
		end
	end
	table.sort(ids)

	local lines = {"RT_DEFAULT_TAGS = {"}
	for _, tag in ipairs(exportTags) do
		table.insert(lines, string.format("\t%q,", tag))
	end
	table.insert(lines, "}")
	table.insert(lines, "")
	table.insert(lines, "RT_DEFAULT_TOY_LISTS = {")
	for _, id in ipairs(ids) do
		local opt = rtOptions[id]
		local catsOut = {}
		-- exportTags already excludes "All" (forced list = true on every
		-- toy -- shipping that would just be noise) --
		for _, tag in ipairs(exportTags) do
			local entry = opt[tag]
			if entry and entry.list then
				table.insert(catsOut, TagKeyForExport(tag) .. " = true")
			end
		end
		if #catsOut > 0 then
			local toy = rtToysByID[id]
			local comment = toy and (" -- " .. toy.name) or ""
			table.insert(lines, string.format("\t[%d] = { %s },%s", id, table.concat(catsOut, ", "), comment))
		end
	end
	table.insert(lines, "}")
	return table.concat(lines, "\n")
end

local rtExportButton = CreateFrame("Button", nil, rtTabToys, "UIPanelButtonTemplate")
rtExportButton:SetPoint("BOTTOMLEFT", RAIL_WIDTH + 30, 5)
rtExportButton:SetSize(140, 25)
rtExportButton:SetText("默认")
rtExportButton:SetShown(rtConfigMode)
rtExportButton:SetScript("OnClick", function()
	rtExportBox:SetText(BuildDefaultsExportString())
	rtExportBox:HighlightText()
	rtExportBox:SetFocus()
	rtExportWindow:Show()
end)


--------------------------------------------------------------------
-- Init/Awake AddonLoaded Msg Handling & Loading
--------------------------------------------------------------------
local rtListener = CreateFrame("Frame")
rtListener:RegisterEvent("ADDON_LOADED")
rtListener:RegisterEvent("NEW_TOY_ADDED")
rtListener:RegisterEvent("TOYS_UPDATED")
rtListener:RegisterEvent("PLAYER_REGEN_ENABLED")
rtListener:RegisterEvent("SPELL_UPDATE_COOLDOWN")
rtListener:RegisterEvent("BAG_UPDATE_COOLDOWN")
rtListener:SetScript("OnEvent", function(self, event, arg1)
	if event == "SPELL_UPDATE_COOLDOWN" or event == "BAG_UPDATE_COOLDOWN" then
		RequestCooldownRefresh()
		return
	end
	if event == "PLAYER_REGEN_ENABLED" then
		-- Leaving combat: replay any grid refresh that was skipped because the
		-- protected toy cells couldn't be moved/shown/hidden while locked down --
		if rtRefreshDeferred and rtOptions then
			rtRefreshDeferred = false
			RT.RebuildToyRows()
		end
		-- Same, for a rail refresh skipped because the protected rail
		-- buttons couldn't be moved/shown/hidden while locked down --
		if rtRailRefreshDeferred then
			rtRailRefreshDeferred = false
			RT.RebuildTagRail()
		end
		-- Same, for a Toy Box embed/restore that couldn't reparent or
		-- show/hide the protected cells (and Blizzard's own toy buttons)
		-- while locked down --
		if RT.ReplayDeferredToyBoxEmbed then RT.ReplayDeferredToyBoxEmbed() end
		-- Same, for a "show me this toy" request from a new-toy alert that
		-- was clicked mid-combat (see RT.FocusToyInGrid) --
		if RT.ReplayDeferredToyFocus then RT.ReplayDeferredToyFocus() end
		-- Same, for a pool edit made mid-combat, when the macro and the
		-- secure button behind it couldn't be rewritten --
		if RT.ReplayDeferredPoolResync then RT.ReplayDeferredPoolResync() end
		-- Same, for a pool rebuild or a roll that a UI action asked for
		-- mid-combat, when the secure button and macro were untouchable --
		if RT.ReplayDeferredRotation then RT.ReplayDeferredRotation() end
		return
	end
	if event == "ADDON_LOADED" and arg1 == "OrzUI" then
		RT.EnsureCollectionsLoaded()
		rtOptions = rtOptions or {}
		rtSettings = rtSettings or {}
		rtSettings.categoryEnabled = rtSettings.categoryEnabled or {}
		rtSettings.customTags = rtSettings.customTags or {}
		-- In-place Toy Box takeover: on by default, so opening Collections >
		-- Toys shows this addon's grid straight away in place of Blizzard's.
		-- Toggle it live with /toys embed --
		if rtSettings.embedToyBox == nil then rtSettings.embedToyBox = true end
		-- A custom set whose name a dev later added to RT_DEFAULT_TAGS
		-- (manual promotion to shipped) stops being listed as custom
		-- here -- its per-toy data lives under the same name either way,
		-- so nothing else needs to move --
		for i = #rtSettings.customTags, 1, -1 do
			if RESERVED_TAG_NAMES[rtSettings.customTags[i]:lower()] then
				table.remove(rtSettings.customTags, i)
			end
		end
		MigrateCategoryRenames()
		for _, cat in ipairs(CATEGORIES) do
			if rtSettings.categoryEnabled[cat] == nil then
				-- New users start with only Full active; every other
				-- category's macro defaults to inactive --
				rtSettings.categoryEnabled[cat] = (cat == "Full")
			end
		end
		-- First load with the Sets menu: seed which sets are in use.
		-- DEFAULT_VISIBLE_SETS stays visible, every other shipped set
		-- starts hidden; custom sets are always the user's own choice,
		-- so they start visible --
		if rtSettings.hiddenSets == nil then
			rtSettings.hiddenSets = {}
			local visible = {}
			for _, tag in ipairs(DEFAULT_VISIBLE_SETS) do visible[tag] = true end
			for _, tag in ipairs(RAIL_TAGS) do
				if not visible[tag] then
					rtSettings.hiddenSets[tag] = true
				end
			end
		end
		-- Hidden means fully off -- a set hidden by the seed above (or by
		-- any out-of-band edit) never keeps a macro --
		for tag in pairs(rtSettings.hiddenSets) do
			if rtSettings.categoryEnabled[tag] then
				rtSettings.categoryEnabled[tag] = false
				RT.DeleteCategoryMacro(tag)
			end
		end
		-- Reconcile the shipped-sets recency order against reality: drop
		-- anything now hidden or no longer a real shipped tag (renamed/
		-- removed), then append -- in RT_DEFAULT_TAGS order -- any
		-- visible shipped tag missing from it (first-ever load, or a
		-- defaults update that added a new one; both land at the bottom,
		-- same as any other freshly-shown set) --
		rtSettings.shippedOrder = rtSettings.shippedOrder or {}
		for i = #rtSettings.shippedOrder, 1, -1 do
			local tag = rtSettings.shippedOrder[i]
			if RT.IsSetHidden(tag) or not RT.IsShippedTag(tag) then
				table.remove(rtSettings.shippedOrder, i)
			end
		end
		local present = {}
		for _, tag in ipairs(rtSettings.shippedOrder) do present[tag] = true end
		for _, tag in ipairs(RT_DEFAULT_TAGS) do
			if not present[tag] and not RT.IsSetHidden(tag) then
				table.insert(rtSettings.shippedOrder, tag)
			end
		end
		rtShowAllToysCheck:SetChecked(rtSettings.showAllToys == true)
		rtFavoritesFirstCheck:SetChecked(rtSettings.favoritesFirst == true)
		rtOnCooldownCheck:SetChecked(rtSettings.onCooldownOnly == true)
		RT.RebuildTagRail() -- custom tags are only known once saved variables load
		RT.BuildToyList()
		RT.SyncNewToysIntoOptions()
		RT.RebuildToyRows()
		-- Wire up (and, if the Toys tab is already open, apply) the in-place
		-- Toy Box takeover now that saved vars are ready and
		-- Blizzard_Collections has been force-loaded above --
		RT.SetupToyBoxEmbed()
		self:UnregisterEvent("ADDON_LOADED")
	elseif (event == "NEW_TOY_ADDED" or event == "TOYS_UPDATED") and rtOptions then
		-- TOYS_UPDATED is often just the echo of our own BuildToyList
		-- refiltering; ignore the ones that land inside the suppression
		-- window so we don't loop (see rtSuppressRescanUntil). NEW_TOY_ADDED
		-- is always a genuine change, so it's never suppressed.
		if event == "TOYS_UPDATED" and GetTime() < RT.suppressRescanUntil then
			return
		end
		RT.RequestRescan()
	end
end)

--------------------------------------------------------------------
-- Toy collection data isn't always fully populated by the time
-- ADDON_LOADED fires, so a rescan on ADDON_LOADED alone can render an
-- empty/stale list on first open. Rescanning every time a live home is
-- shown (the floating window below, and the Toy Box embed) guarantees it
-- reflects current data.
--------------------------------------------------------------------

-- Floating window: rescans, and moves rtContent into itself (and back to
-- the parking frame when it closes). rtContent has only one parent, so the
-- window and the Toy Box embed can't both hold it -- opening the window
-- suspends an active embed (Blizzard's grid comes back in the journal), and
-- closing it pops the content back into the journal if that's still the
-- preference and the Toys tab is open. The embed *preference* is never
-- touched here, so this is a temporary detach, not a disable --
rtFloatingWindow:SetScript("OnShow", function()
	RT.SuspendToyBoxEmbedForFloating()
	rtContent:SetParent(rtFloatingWindow)
	rtContent:ClearAllPoints()
	rtContent:SetPoint("TOPLEFT", 8, -30)
	rtContent:SetPoint("BOTTOMRIGHT", -8, 8)
	if rtOptions then
		RT.BuildToyList()
		RT.SyncNewToysIntoOptions()
		RT.RebuildToyRows()
	end
end)
rtFloatingWindow:SetScript("OnHide", function()
	RT.RCOptionsOkay()
	rtContent:SetParent(rtOptionsPanel)
	rtContent:ClearAllPoints()
	rtContent:SetPoint("TOPLEFT")
	rtContent:SetPoint("BOTTOMRIGHT")
	RT.ResumeToyBoxEmbedIfActive()
end)


--------------------------------------------------------------------
-- In-place Toy Box takeover [renders this addon's grid + set rail
-- inside the real Collections Journal > Toys tab, in place of
-- Blizzard's stock paged toy grid. rtContent gains a third home here,
-- alongside the Settings canvas (rtOptionsPanel) and the /toys floating
-- window; only one of the three holds it at any time.
--
-- It's a cosmetic swap in the same screen space, the same approach
-- ToyBoxEnhanced uses: nothing of Blizzard's is destroyed, only hidden.
-- rtContent covers the toy area (its own opaque backdrop masks ToyBox's
-- search/progress header behind it) and Blizzard's own toy buttons +
-- paging controls are hidden.
--
-- Everything that moves, shows, or hides a protected frame is gated on
-- InCombatLockdown(): rtContent's toy cells and rail rows are secure
-- buttons (they carry a "toy" attribute for click-to-use), as are
-- Blizzard's own toy buttons, so none of them can be reparented or
-- shown/hidden mid-combat. A deferred request is replayed on
-- PLAYER_REGEN_ENABLED (see RT.ReplayDeferredToyBoxEmbed), the same
-- pattern the grid and rail refreshes use.
--------------------------------------------------------------------

local rtToyBoxHooked = false      -- one-time hooks installed?
local rtToyBoxEmbedded = false    -- is rtContent currently living in ToyBox?
local rtToyBoxDeferred = nil      -- "embed"/"restore" queued past combat, or nil
local rtDefaultUIToggle = nil     -- checkbox in Blizzard's own toy box (turns embed ON)
local rtEmbedToggle = nil         -- checkbox in our embedded view (turns embed OFF)

-- Toggle Blizzard's own toy grid (the paged spell buttons + paging
-- frame). These are secure frames, so every caller is combat-gated --
local function SetBlizzardToyGridShown(show)
	if not ToyBox then return end
	if ToyBox.PagingFrame then ToyBox.PagingFrame:SetShown(show) end
	local icons = ToyBox.iconsFrame
	if icons then
		local i = 1
		while icons["spellButton" .. i] do
			icons["spellButton" .. i]:SetShown(show)
			i = i + 1
		end
	end
end

-- Toggle Blizzard's own toy-box chrome (search box, filter dropdown,
-- progress bar). Hidden while embedded so it doesn't show behind our
-- content -- and, crucially, so the toy box's own background shows
-- through cleanly instead of us painting one. All non-secure, so this
-- is safe in combat; names are guarded since they vary by client build --
local function SetToyBoxNativeChromeShown(show)
	if not ToyBox then return end
	if ToyBox.searchBox then ToyBox.searchBox:SetShown(show) end
	if ToyBox.progressBar then ToyBox.progressBar:SetShown(show) end
	if ToyBox.FilterDropdown then ToyBox.FilterDropdown:SetShown(show) end
	if ToyBox.FilterButton then ToyBox.FilterButton:SetShown(show) end
	if _G.ToyBoxFilterButton then _G.ToyBoxFilterButton:SetShown(show) end
end

-- Sync both toggle checkboxes (and the "Toy Collection" button) to the
-- current embedded/enabled state. The default-UI toggle only shows over
-- Blizzard's grid (embed off); our in-view toggle only shows while
-- embedded; the "Toy Collection" button is redundant inside the journal,
-- so it's hidden there and restored everywhere else --
function RT.RefreshEmbedToggles()
	local embedded = rtToyBoxEmbedded
	-- The header toggle already reads "Random Toys" while embedded, so our
	-- own title is redundant there -- hide it (kept for the other homes) --
	if rtTitle then rtTitle:SetShown(not embedded) end
	-- Checked state follows the LIVE embed, not the saved preference: while
	-- the content is popped out into the /toys window the preference stays on
	-- but we're not embedded, and the default-UI toggle must read unchecked
	-- over Blizzard's restored grid rather than falsely "on" --
	if rtEmbedToggle then
		rtEmbedToggle:SetChecked(embedded)
		rtEmbedToggle:SetShown(embedded)
	end
	if rtToyBoxButton then rtToyBoxButton:SetShown(not embedded) end
	if rtDefaultUIToggle then
		rtDefaultUIToggle:SetChecked(embedded)
		rtDefaultUIToggle:SetShown(ToyBox and ToyBox:IsShown() and not embedded or false)
	end
end

-- Inset of our content within ToyBox. The top inset clears the journal's
-- thick top border/header strip so our grid doesn't crowd it; the small
-- side/bottom insets keep clear of the inset frame's edges --
local TOYBOX_INSET_TOP = -24
local TOYBOX_INSET_SIDE = 6
local TOYBOX_INSET_BOTTOM = 4

-- Header toggle placement, measured from ToyBox's top-left. LEFT clears the
-- journal's portrait icon; Y nudges it up (+) into the header band that sits
-- above ToyBox's content top, level with the Macros button. Easy to nudge --
local TOYBOX_TOGGLE_LEFT = 95
local TOYBOX_TOGGLE_Y = -80

local function EmbedIntoToyBox()
	-- rtToyBoxHooked is only set by RT.SetupToyBoxEmbed; if it's false, setup
	-- never ran (Blizzard_Collections unavailable), so there's nothing to
	-- take over --
	if rtToyBoxEmbedded or not ToyBox or not rtToyBoxHooked then return end
	if InCombatLockdown() then rtToyBoxDeferred = "embed" return end
	rtToyBoxDeferred = nil
	-- rtContent can only have one parent; vacate whichever home holds it.
	-- Hiding the floating window runs its OnHide, which parks rtContent back
	-- on rtOptionsPanel -- we take it from there a line later. That OnHide
	-- also calls ResumeToyBoxEmbedIfActive, which can re-enter here and embed
	-- first; if so, bail rather than redo the work --
	if rtFloatingWindow:IsShown() then rtFloatingWindow:Hide() end
	if rtToyBoxEmbedded then return end
	rtContent:SetParent(ToyBox)
	rtContent:ClearAllPoints()
	rtContent:SetPoint("TOPLEFT", ToyBox, "TOPLEFT", TOYBOX_INSET_SIDE, TOYBOX_INSET_TOP)
	rtContent:SetPoint("BOTTOMRIGHT", ToyBox, "BOTTOMRIGHT", -TOYBOX_INSET_SIDE, TOYBOX_INSET_BOTTOM)
	-- Sit above any of ToyBox's own leftover widgets --
	rtContent:SetFrameLevel(ToyBox:GetFrameLevel() + 10)
	rtContent:Show()
	-- Hide Blizzard's grid AND its chrome, so the toy box's native
	-- background shows through behind our content (no backdrop of our own) --
	SetBlizzardToyGridShown(false)
	SetToyBoxNativeChromeShown(false)
	rtToyBoxEmbedded = true
	RT.RefreshEmbedToggles()
	if rtOptions then
		RT.BuildToyList()
		RT.SyncNewToysIntoOptions()
		RT.RebuildToyRows()
	end
end

local function RestoreFromToyBox()
	if not rtToyBoxEmbedded then return end
	if InCombatLockdown() then rtToyBoxDeferred = "restore" return end
	rtToyBoxDeferred = nil
	-- Back to the default home (the Settings canvas), matching the floating
	-- window's own OnHide restore --
	rtContent:SetParent(rtOptionsPanel)
	rtContent:ClearAllPoints()
	rtContent:SetPoint("TOPLEFT")
	rtContent:SetPoint("BOTTOMRIGHT")
	SetBlizzardToyGridShown(true)
	SetToyBoxNativeChromeShown(true)
	rtToyBoxEmbedded = false
	RT.RefreshEmbedToggles()
end

-- Called from the PLAYER_REGEN_ENABLED handler up top (by name, so it
-- resolves even though it's defined further down the file) to replay an
-- embed/restore that was deferred while locked down --
function RT.ReplayDeferredToyBoxEmbed()
	local action = rtToyBoxDeferred
	if action then
		rtToyBoxDeferred = nil
		if action == "embed" then
			-- Only re-embed if the Toys tab is still the open view and the
			-- feature is still enabled --
			if rtSettings and rtSettings.embedToyBox ~= false and ToyBox and ToyBox:IsShown() then
				EmbedIntoToyBox()
			end
		else
			RestoreFromToyBox()
		end
	elseif rtToyBoxEmbedded then
		-- Still embedded and nothing queued: re-hide Blizzard's grid in case a
		-- refresh re-showed it mid-combat, when the re-hide hook had to bow out --
		SetBlizzardToyGridShown(false)
	end
end

-- Public: flip the feature on/off live (persisted in rtSettings.embedToyBox);
-- applies immediately if the Toy Box is open. Returns the new state --
function RT.SetToyBoxEmbedEnabled(enabled)
	enabled = enabled and true or false
	rtSettings.embedToyBox = enabled
	if enabled then
		if ToyBox and ToyBox:IsShown() then EmbedIntoToyBox() end
	else
		RestoreFromToyBox()
	end
	return enabled
end

-- Called when the /toys floating window opens: rtContent can only live in
-- one home, so hand the Toy Box back to Blizzard and detach if we're
-- currently embedded. The persistent preference (rtSettings.embedToyBox) is
-- deliberately left alone -- this is a temporary pop-out, and closing the
-- window pops us back in via RT.ResumeToyBoxEmbedIfActive --
function RT.SuspendToyBoxEmbedForFloating()
	if rtToyBoxEmbedded then RestoreFromToyBox() end
end

-- Called when the /toys floating window closes: re-embed into the Toy Box if
-- that's still the preference and the Toys tab is open. If the journal was
-- closed meanwhile, ToyBox:OnShow will re-embed on its next open instead --
function RT.ResumeToyBoxEmbedIfActive()
	if rtSettings.embedToyBox ~= false and ToyBox and ToyBox:IsShown() then
		EmbedIntoToyBox()
	end
end

-- One-time setup, called from ADDON_LOADED once Blizzard_Collections has
-- been force-loaded so the ToyBox frame exists --
function RT.SetupToyBoxEmbed()
	if rtToyBoxHooked then return end
	if not ToyBox then return end -- Blizzard_Collections unavailable; leave the stock UI alone
	rtToyBoxHooked = true

	-- Toggle #1, in Blizzard's own toy box: turns the takeover ON. Parented
	-- to ToyBox (so it's gone when the journal closes), anchored into the
	-- thick top border above the search box where there's clear space. Only
	-- shown while the stock grid is up (see RT.RefreshEmbedToggles) --
	-- Both toggles share one spot: top-left of the toy box header, both
	-- parented to ToyBox and labeled "Random Toys". Only one is ever shown
	-- at a time (default-UI toggle over the stock grid, embed toggle while
	-- embedded -- see RT.RefreshEmbedToggles), so together they read as a
	-- single persistent "Random Toys" checkbox whose checked state follows
	-- the takeover. High frame levels keep whichever is showing above both
	-- Blizzard's header and our own inset content --
	-- Anchor to the journal's portrait icon so we align to it automatically
	-- instead of guessing pixels: sit just to its right, vertically centered
	-- on it, which lands us squarely in the header band. Fall back to a
	-- ToyBox-relative offset only if the portrait can't be found on this
	-- client build --
	local portrait = (CollectionsJournal and CollectionsJournal.PortraitContainer
			and CollectionsJournal.PortraitContainer.portrait)
		or _G.CollectionsJournalPortrait
		or (CollectionsJournal and CollectionsJournal.portrait)

	local function StyleHeaderToggle(cb, onClick)
		cb:SetSize(24, 24)
		if portrait then
			-- Just right of the icon, its bottom aligned to the icon's bottom --
			cb:SetPoint("BOTTOMLEFT", portrait, "BOTTOMRIGHT", 6, 0)
		else
			cb:SetPoint("LEFT", ToyBox, "TOPLEFT", TOYBOX_TOGGLE_LEFT, TOYBOX_TOGGLE_Y)
		end
		-- High strata so it clears the journal's frame art (a low strata here
		-- renders behind it and vanishes) --
		cb:SetFrameStrata("DIALOG")
		cb:SetFrameLevel(ToyBox:GetFrameLevel() + 50)
		cb.Text:SetText(RandomCosplayLocal)  --"Random Toys"
		cb.Text:SetTextColor(1, 0.82, 0)
		cb:SetScript("OnClick", onClick)
		cb:SetScript("OnEnter", function(self)
			-- LockHighlight forces the square's highlight texture up even when
			-- the hover came in through the label's hit area below (a separate
			-- frame -- the mouse is never actually over cb itself then, so the
			-- template's own automatic hover highlight wouldn't otherwise fire) --
			self:LockHighlight()
			self.Text:SetTextColor(1, 1, 1)
			GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
			GameTooltip:SetText(RandomCosplayLocal)  --"Random Toys"
			GameTooltip:AddLine("在 随机玩具汇总 网格与暴雪默认玩具网格之间切换。", 1, 1, 1, true)
			GameTooltip:Show()
		end)
		cb:SetScript("OnLeave", function(self)
			self:UnlockHighlight()
			self.Text:SetTextColor(1, 0.82, 0)
			GameTooltip:Hide()
		end)

		-- The 24px square is a small target, so make "Random Toys" itself a
		-- hotspot too -- same treatment as the grid's Uncollected/Favorites/
		-- On Cooldown labels (see MakeCheckLabelClickable), forwarding both
		-- the click and the hover so the tooltip shows and the toggle fires
		-- from anywhere over the text, not just the checkbox square. Parented
		-- to cb (not ToyBox) so it shows/hides right along with it --
		local hit = CreateFrame("Button", nil, cb)
		hit:SetAllPoints(cb.Text)
		hit:SetFrameStrata("DIALOG")
		hit:SetFrameLevel(cb:GetFrameLevel() + 1)
		hit:RegisterForClicks("LeftButtonUp")
		hit:SetScript("OnClick", function()
			local click = cb:GetScript("OnClick")
			if click then click(cb) end
		end)
		hit:SetScript("OnEnter", function()
			local enter = cb:GetScript("OnEnter")
			if enter then enter(cb) end
		end)
		hit:SetScript("OnLeave", function()
			local leave = cb:GetScript("OnLeave")
			if leave then leave(cb) end
		end)

		cb:Hide()
	end

	-- Toggle #1, shown over Blizzard's grid: turns the takeover ON --
	rtDefaultUIToggle = CreateFrame("CheckButton", nil, ToyBox, "UICheckButtonTemplate")
	StyleHeaderToggle(rtDefaultUIToggle, function() RT.SetToyBoxEmbedEnabled(true) end)

	-- Toggle #2, shown while embedded: turns the takeover OFF --
	rtEmbedToggle = CreateFrame("CheckButton", nil, ToyBox, "UICheckButtonTemplate")
	StyleHeaderToggle(rtEmbedToggle, function() RT.SetToyBoxEmbedEnabled(false) end)

	-- Show/hide of the Toys tab drives the takeover: ToyBox:OnShow means the
	-- Toys tab just became the active view (journal opened to Toys, or
	-- switched to it), OnHide means it left (tab switch or journal close) --
	ToyBox:HookScript("OnShow", function()
		if rtSettings.embedToyBox ~= false then
			EmbedIntoToyBox()
		else
			-- Staying on Blizzard's grid -- surface the "show here" toggle --
			RT.RefreshEmbedToggles()
		end
	end)
	ToyBox:HookScript("OnHide", function()
		RestoreFromToyBox()
	end)

	-- Blizzard re-shows its toy buttons whenever it refreshes them (page
	-- turn, filter change, favorite toggle). Re-hide them each time while
	-- we're embedded so they can't reappear beneath our grid. Combat-gated:
	-- during a lockdown we simply leave them be (they're covered by the
	-- opaque backdrop anyway) and resync once combat ends --
	if _G.ToyBox_UpdateButtons then
		hooksecurefunc("ToyBox_UpdateButtons", function()
			if rtToyBoxEmbedded and not InCombatLockdown() then
				SetBlizzardToyGridShown(false)
			end
		end)
	end

	-- Already on the Toys tab at setup time (e.g. /reload with Collections
	-- open on Toys) -- OnShow won't fire for it, so apply now --
	if rtSettings.embedToyBox ~= false and ToyBox:IsShown() then
		EmbedIntoToyBox()
	else
		RT.RefreshEmbedToggles()
	end
end


--------------------------------------------------------------------
local rtPendingFocusToyID = nil

-- Scrolls the grid to the cell holding toyID. ReflowToyGrid lays each
-- visible cell out at TOPLEFT with a negative y of its row's pixel top,
-- so that offset is the scroll position that brings it to the top edge --
local function ScrollToyIntoView(toyID)
	for i = 1, #rtToyButtons do
		local f = rtToyButtons[i]
		if f.toyID == toyID and f:IsShown() then
			local _, _, _, _, y = f:GetPoint(1)
			local maxScroll = math.max(0, rtScrollChild:GetHeight() - rtOptionsScroll:GetHeight())
			rtOptionsScroll:SetVerticalScroll(math.min(maxScroll, math.max(0, -(y or 0))))
			return true
		end
	end
	return false
end

function RT.FocusToyInGrid(toyID)
	if not (toyID and rtOptions) then return end
	-- Not embedded means Blizzard's own grid is the one on screen, and its
	-- own paging already put the toy in front of the player -- nothing for
	-- us to do (and nothing of ours visible to do it in) --
	if not rtToyBoxEmbedded then return end
	-- The cells are protected, so the grid can't be re-laid-out while
	-- locked down (see ReflowToyGrid) -- park it for PLAYER_REGEN_ENABLED --
	if InCombatLockdown() then rtPendingFocusToyID = toyID return end
	rtPendingFocusToyID = nil

	-- A toy earned moments ago may not be in the scanned list yet (the
	-- rescan is debounced), and a toy with no entry has no cell to find --
	if not rtToysByID[toyID] then
		RT.BuildToyList()
		RT.SyncNewToysIntoOptions()
		RT.RebuildToyRows()
		if not rtToysByID[toyID] then return end
	end

	-- "On cooldown" would filter the toy right back out -- one you just
	-- collected has never been used, so it isn't ticking --
	if rtSettings.onCooldownOnly then
		rtSettings.onCooldownOnly = false
		rtOnCooldownCheck:SetChecked(false)
	end

	-- Stay put if the set being browsed already covers the toy (a new
	-- toy often lands in a shipped set straight away) -- switching views
	-- out from under the player when it isn't needed is just disorienting.
	-- Otherwise fall back to All, the one view that always shows it; if
	-- it's been hidden from the rail, bring it back, since without it
	-- there's no guaranteed home to show the toy in --
	if not ToyInSet(toyID) then
		if RT.IsSetHidden("All") then ToggleSetVisibility("All") end
		RT.SelectToyCategory("All")
	end

	-- Narrow to the toy by name. SetText fires OnTextChanged, which
	-- reflows -- but only when the text actually changed, so reflow
	-- explicitly too and be sure the layout is current before scrolling --
	rtSearchBox:SetText(rtToysByID[toyID].name)
	RT.ReflowToyGrid()
	ScrollToyIntoView(toyID)
end

-- Called from the PLAYER_REGEN_ENABLED handler up top (by name, so it
-- resolves despite being defined further down the file) --
function RT.ReplayDeferredToyFocus()
	local toyID = rtPendingFocusToyID
	rtPendingFocusToyID = nil
	if toyID then RT.FocusToyInGrid(toyID) end
end

-- Fires for the new-toy alert (which passes the toy id) and for the plain
-- "open the toy collection" paths (which pass nothing) -- FocusToyInGrid
-- ignores the latter.
--
-- Handled a frame later (C_Timer.After(0)) rather than inline: this hook
-- body runs inside Blizzard's own click path, and the work below rebinds
-- secure attributes on our toy cells -- the same reason
-- RT.OpenToyInToyBox defers its filtering. It also lets the journal's own
-- OnShow work (ours included) finish first, so we're reflowing a grid
-- that's already settled --
if ToggleToyCollection then
	hooksecurefunc("ToggleToyCollection", function(toyID)
		if not toyID then return end
		C_Timer.After(0, function() RT.FocusToyInGrid(toyID) end)
	end)
end


--------------------------------------------------------------------
-- Create slash command
--------------------------------------------------------------------
SLASH_RandomToys1 = "/toys"
function SlashCmdList.RandomToys(msg, editbox)
	local arg = (msg or ""):lower():match("^%s*(%S*)")
	-- "/toys embed" (and friendlier aliases) toggles the in-place Toy Box
	-- takeover on/off, for anyone who'd rather keep Blizzard's stock grid --
	-- "/toys debug" turns the rotation trace on/off (see Trace in
	-- RandomToysMacros.lua) -- prints which path decided to roll, and when,
	-- so an unexpected extra roll can be read off the chat log --
	-- "/toys why [set]" reports why each toy in a set did or didn't make its
	-- rotation pool -- the answer to a macro that rolls /cry with toys that
	-- look active in the grid. Defaults to the set currently being viewed --
	-- "/toys combat" turns the in-combat rotation on/off. It's the one part
	-- of the rotation that runs inside WoW's restricted environment, so a
	-- switch that doesn't need a reload is worth having --
	if arg == "combat" then
		local on = RT.SetCombatRotationEnabled(rtSettings.combatRotation == false)
		print("|cff58C6FARandom Toys|r: rotating toys during combat is " ..
			(on and "|cff40ff40on|r -- the macro icon still can't update until the fight ends."
			or "|cffff4040off|r -- a fight stays on whichever toy was loaded when it started."))
		return
	end
	if arg == "why" or arg == "pool" then
		local wanted = (msg or ""):match("^%s*%S+%s+(.-)%s*$")
		RT.ExplainCategoryPool(wanted ~= "" and wanted or rtCurrentCategory)
		return
	end
	if arg == "embed" or arg == "toybox" or arg == "blizz" or arg == "default" then
		local on = RT.SetToyBoxEmbedEnabled(rtSettings.embedToyBox == false)
		print("|cff58C6FARandom Toys|r：原位替换玩具箱" ..
			(on and "|cff40ff40已启用|r（打开藏品 > 玩具）。" or "|cffff4040已禁用|r（已恢复暴雪玩具网格）。"))
		return
	end
	if rtFloatingWindow:IsShown() then
		rtFloatingWindow:Hide()
	else
		rtFloatingWindow:Show()
	end
end
