local pluginName, pluginShared = ...
local Lockouts = pluginShared.Lockouts

local guarded = pluginShared.guarded
local reported = pluginShared.reported or guarded
local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

function Lockouts:ResetLines()
    self.lineCount = 0
    self.contentHeight = 8
    -- what this draw does to the lines already there, for the draw log
    self.drawChanged, self.drawRetrieving = 0, 0
    for _, line in ipairs(self.lines) do
        line:Hide()
    end
    if self.routeToggle then
        self.routeToggle:Hide()
    end
end

-- Panel geometry (the LibMatic window; see Lockouts:EnsurePanel)
local PANEL_WIDTH, PANEL_HEIGHT = 460, 540
local PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT = 380, 240
-- the toolbar sits under the medallion (72 px, centred at 20,-26) so its row spans the whole panel:
-- the search box and the character picker split it evenly
local TOOLBAR_LEFT, TOOLBAR_TOP = 17, -64 -- 5 px more on the left than the right: the search box's magnifier sits flush
local TOOLBAR_RIGHT, TOOLBAR_GAP = 12, 6
local FILTER_LEFT, FILTER_TOP = 14, -90 -- the show boxes, a second toolbar row
local FILTER_BOX, FILTER_GAP = 22, 10
local CONTENT_TOP = 118
local MINIMIZED_HEIGHT = 60
local PANEL_MEDALLION = "Interface\\AddOns\\AppearanceMatic\\Media\\Textures\\AppearanceMatic-icon.png"
-- the panel's text colour: a cream white, for the title, rows, links and the Routing header
local CREAM = { 1, 0.97, 0.86 }
local PANEL_BRAND = "|cfffff8dc"
-- What the panel shows, one box each, all on unless switched off: the current expansion's raids and
-- dungeons, legacy raids and dungeons, the Vendors, Mounts and Other starred items sections.
local SHOW_KEYS = { "current", "legacy", "vendors", "mounts", "other" }
local ITEM_ICON = 18 -- item icon on a row, a quarter up from the 14 the font line gives
local ROUTING_ICON = 18 -- the pin on the Routing header
-- the switch beside the Routing header: on (gold, knob right) and off (steel, knob left)
local ROUTE_TOGGLE_ON = "Interface\\AddOns\\AppearanceMatic\\Media\\Textures\\route-toggle-on.tga"
local ROUTE_TOGGLE_OFF = "Interface\\AddOns\\AppearanceMatic\\Media\\Textures\\route-toggle-off.tga"
local ROUTE_TOGGLE_WIDTH, ROUTE_TOGGLE_HEIGHT = 40, 20

-- The switch is one button, made once, placed after the Routing header on every rebuild.
function Lockouts:EnsureRouteToggle()
    if self.routeToggle then
        return self.routeToggle
    end
    local toggle = CreateFrame("Button", nil, self.content)
    toggle:SetSize(ROUTE_TOGGLE_WIDTH, ROUTE_TOGGLE_HEIGHT)
    toggle:SetScript("OnClick", function()
        Lockouts:ToggleRoutePause()
    end)
    toggle:SetScript("OnEnter", function(button)
        GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
        GameTooltip:SetText(Lockouts.routePaused and L["lock_route_toggle_off"] or L["lock_route_toggle_on"])
        GameTooltip:AddLine(L["lock_route_toggle_hint"], 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    toggle:SetScript("OnLeave", GameTooltip_Hide)
    self.routeToggle = toggle
    return toggle
end

-- Sits just after the header's text on the same line, drawn on or off from the pause state
function Lockouts:PlaceRouteToggle(headerLine)
    if self.captureLines or not (headerLine and self.content and type(CreateFrame) == "function") then
        return
    end
    local toggle = self:EnsureRouteToggle()
    local width = headerLine.GetUnboundedStringWidth and headerLine:GetUnboundedStringWidth() or headerLine:GetStringWidth()
    toggle:ClearAllPoints()
    toggle:SetPoint("LEFT", headerLine, "LEFT", (tonumber(width) or 0) + 8, 0)
    toggle:SetNormalTexture(self.routePaused and ROUTE_TOGGLE_OFF or ROUTE_TOGGLE_ON)
    toggle:Show()
end

-- Which panel font role a line's base font object stands for (LockoutsFont.lua sizes the roles)
local LINE_ROLES = { GameFontNormal = "header", GameFontHighlight = "boss" }

function Lockouts:AddLine(text, indent, fontObject, r, g, b)
    if self.captureLines then
        -- dry run: collect what a row would show without drawing it
        self.captureLines[#self.captureLines + 1] = tostring(text)
        return
    end
    self.lineCount = self.lineCount + 1
    local line = self.lines[self.lineCount]
    if not line then
        line = self.content:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
        line:SetJustifyH("LEFT")
        line:SetWordWrap(true)
        -- lines carry hyperlinks, and the game's link hit-testing does not follow a text scale: a
        -- FitText-shrunk header only answers clicks where the scaled and unscaled text overlap
        -- (verified in game 2026-08-29), so lines wrap instead and only the title and toolbar fit
        self.lines[self.lineCount] = line
    end
    local leftPad = 6 + (indent or 0) * 14
    local width = self.content:GetWidth() - leftPad - 6
    local top = self.contentHeight
    local isSecret = type(issecretvalue) == "function" and issecretvalue(text)
    -- Almost every line of a rebuild comes out exactly as the one already sitting there. A line that
    -- would be drawn the way it already is keeps what it has: no font, placement, width, colour, text
    -- or measurement work, and its measured height is reused.
    if not isSecret and line.amReady
        and line.amText == text and line.amFont == fontObject
        and line.amPad == leftPad and line.amWidth == width and line.amTop == top
        and line.amR == r and line.amG == g and line.amB == b then
        line:Show()
        self.contentHeight = top + line.amHeight + 4
        self.lastLineText = text
        if line.amRetrieving then
            self.drawRetrieving = (self.drawRetrieving or 0) + 1
        end
        return
    end
    self.drawChanged = (self.drawChanged or 0) + 1
    line:SetFontObject(fontObject or "GameFontHighlightSmall")
    local role = LINE_ROLES[fontObject] or "row"
    if self.ApplyFont then
        self:ApplyFont(line, role)
    end
    line:ClearAllPoints()
    line:SetPoint("TOPLEFT", self.content, "TOPLEFT", leftPad, -top)
    line:SetWidth(width)
    if isSecret then
        -- A secret string can be displayed but not measured or concatenated; note it for diagnostics.
        if AppearanceMaticDB then
            AppearanceMaticDB.diag = AppearanceMaticDB.diag or {}
            AppearanceMaticDB.diag.secretLine = self.lineCount
            AppearanceMaticDB.diag.secretPrevText = self.lastLineText
        end
    end
    line:SetText(text or "")
    line:SetTextColor(r or CREAM[1], g or CREAM[2], b or CREAM[3])
    line:Show()
    local height = line:GetStringHeight()
    if type(height) ~= "number" or (type(issecretvalue) == "function" and issecretvalue(height)) then
        local _, fontHeight = line:GetFont()
        height = (type(fontHeight) == "number" and not (type(issecretvalue) == "function" and issecretvalue(fontHeight)))
            and fontHeight or 12
    end
    self.contentHeight = top + height + 4
    if isSecret then
        -- a secret string cannot be compared, so this line is drawn afresh every time
        line.amReady = nil
    else
        line.amText, line.amFont, line.amPad, line.amWidth = text, fontObject, leftPad, width
        line.amTop, line.amR, line.amG, line.amB = top, r, g, b
        line.amHeight, line.amReady = height, true
        self.lastLineText = text
        -- a row still waiting on its item's name: counted per draw, so the log says when they show
        local retrieving = RETRIEVING_ITEM_INFO or L["lock_retrieving_item_info"]
        line.amRetrieving = type(text) == "string" and type(retrieving) == "string" and text:find(retrieving, 1, true) ~= nil or nil
        if line.amRetrieving then
            self.drawRetrieving = (self.drawRetrieving or 0) + 1
        end
    end
end

-- After a font change what a line was drawn as no longer describes it, its measured height included.
function Lockouts:ForgetLineCache()
    for _, line in ipairs(self.lines or {}) do
        line.amReady = nil
    end
end

-- The shared LibMatic-IgnoreCharacters-1.0 handle for this addon's roster, built once and reused.
-- store lives in AppearanceMaticDB.ignoredCharacters; characters come from the lockout snapshots
-- (SnapshotCharacter, in Lockouts.lua), so class comes along for the multiselect labels for free.
function Lockouts:IgnoreCharacters()
    if self.ignoreCharacters then
        return self.ignoreCharacters
    end
    local lib = LibStub("LibMatic-IgnoreCharacters-1.0", true)
    local addon = self
    local handle
    handle = lib:New({
        store = function(create)
            if create then
                AppearanceMaticDB = AppearanceMaticDB or {}
                AppearanceMaticDB.ignoredCharacters = AppearanceMaticDB.ignoredCharacters or {}
            end
            return AppearanceMaticDB and AppearanceMaticDB.ignoredCharacters
        end,
        characters = function()
            return AppearanceMaticDB and AppearanceMaticDB.lockouts
        end,
        strings = {
            desc = L["opt_ignore_desc"],
            empty = L["opt_ignore_empty"],
            others = L["opt_ignore_others"],
            othersDesc = L["opt_ignore_others_desc"],
            list = L["opt_ignore_list"],
            listDesc = L["opt_ignore_list_desc"],
            tab = L["opt_ignore_tab_name"],
        },
        onChange = function()
            if addon.charDropdown and addon.charDropdown.GenerateMenu then
                addon.charDropdown:GenerateMenu()
            end
            -- a character just ignored from the settings tab might still be ticked as an extra
            -- alt on the dropdown; SortedCharacterKeys already hides it from every view, but the
            -- tick itself has to come off too or it comes back the moment it is un-ignored.
            if addon.extraChars then
                for charKey in pairs(addon.extraChars) do
                    if handle:IsIgnored(charKey) then
                        addon.extraChars[charKey] = nil
                    end
                end
            end
            addon:ScheduleRebuild()
        end,
    })
    self.ignoreCharacters = handle
    return handle
end

function Lockouts:SortedCharacterKeys()
    local store = AppearanceMaticDB and AppearanceMaticDB.lockouts
    if type(store) ~= "table" then
        return nil
    end
    local ignore = self:IgnoreCharacters()
    local current = self:CurrentCharKey()
    local charKeys = {}
    for charKey in pairs(store) do
        if charKey == current or not ignore:IsIgnored(charKey) then
            charKeys[#charKeys + 1] = charKey
        end
    end
    if #charKeys == 0 then
        return nil
    end
    table.sort(charKeys)
    return charKeys
end

-- Which characters the status lines show: nil = this character, "all", or one charKey.
function Lockouts:CharacterView()
    return self.charView
end

function Lockouts:SetCharacterView(view)
    self.charView = view
    if self.charDropdown and self.charDropdown.GenerateMenu then
        self.charDropdown:GenerateMenu()
    end
    self:ScheduleRebuild()
end

-- Which alts ride along with the current character in the "This character" view; "all" ignores this.
function Lockouts:SetExtraCharacter(charKey, on)
    self.extraChars = self.extraChars or {}
    self.extraChars[charKey] = on and true or nil
    self:SaveWidgetState()
    if self.charDropdown and self.charDropdown.GenerateMenu then
        self.charDropdown:GenerateMenu()
    end
    self:ScheduleRebuild()
end

function Lockouts:CharacterLabel(charKey)
    local store = AppearanceMaticDB and AppearanceMaticDB.lockouts
    local snapshot = store and store[charKey]
    local color = snapshot and snapshot.class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[snapshot.class]
    local name = charKey:match("^(.-)%-") or charKey
    return color and ("|c" .. color.colorStr .. name .. "|r") or name
end

-- Character keys for a view, current character first.
function Lockouts:ViewCharacterKeys(view)
    local all = self:SortedCharacterKeys()
    if not all then return nil end
    local current = self:CurrentCharKey()
    if view == "all" then
        local out = {}
        if current then
            for _, k in ipairs(all) do if k == current then out[#out + 1] = k end end
        end
        for _, k in ipairs(all) do if k ~= current then out[#out + 1] = k end end
        return out
    end
    if view == nil then
        -- "This character": the current character, then whichever ticked alts still exist, sorted
        if not current then
            return { all[1] }
        end
        local out = { current }
        local extra = self.extraChars
        for _, k in ipairs(all) do
            if k ~= current and extra and extra[k] then
                out[#out + 1] = k
            end
        end
        return out
    end
    for _, k in ipairs(all) do
        if k == view then return { k } end
    end
    return current and { current } or { all[1] }
end

-- A search token that names a character (prefix, case-insensitive) picks that character's view.
function Lockouts:CharacterFromQuery(query)
    local all = self:SortedCharacterKeys()
    if not (all and query) then return nil, query end
    local rest = {}
    local picked
    for token in query:gmatch("%S+") do
        local hit
        if not picked and #token >= 2 then
            for _, k in ipairs(all) do
                local name = (k:match("^(.-)%-") or k):lower()
                if name:sub(1, #token) == token then hit = k break end
            end
        end
        if hit then picked = hit else rest[#rest + 1] = token end
    end
    local remaining = table.concat(rest, " ")
    return picked, remaining ~= "" and remaining or nil
end

function Lockouts:BossStatusLines(snapshot, raidName, bossName, difficulties, now)
    local lines = {}
    local wanted = {}
    for difficultyName in pairs(difficulties or {}) do wanted[#wanted + 1] = difficultyName end
    table.sort(wanted)
    if #wanted == 0 then
        local down, entry = self:BossLockState(snapshot, raidName, bossName, nil, now)
        if down and entry then
            lines[#lines + 1] = { text = string.format(L["lock_boss_down"], entry.difficulty or "?", SecondsToTime(entry.resetAt - now) or ""), r = 1, g = 0.35, b = 0.35 }
        else
            lines[#lines + 1] = { text = L["lock_status_available"], r = 0.4, g = 1, b = 0.4 }
        end
        return lines
    end
    -- difficulties that share one lock (Wrath 25N/25H, Cata 10/25 N/H) share one line
    local groups, order = {}, {}
    for _, difficultyName in ipairs(wanted) do
        local key = self:LockoutGroup(raidName, self:DifficultyIdByName(difficultyName)) or difficultyName
        if not groups[key] then
            groups[key] = { names = {}, first = difficultyName }
            order[#order + 1] = key
        end
        table.insert(groups[key].names, difficultyName)
    end
    for _, key in ipairs(order) do
        local group = groups[key]
        local label = "[" .. table.concat(group.names, ", ") .. "]"
        local down, entry = self:BossLockState(snapshot, raidName, bossName, group.first, now)
        if down and entry then
            local covered = false
            for _, n in ipairs(group.names) do if n == entry.difficulty then covered = true end end
            local via = (entry.difficulty and not covered) and string.format(L["lock_group_locked_by"], entry.difficulty) or ""
            lines[#lines + 1] = { text = string.format(L["lock_group_down"], label, via, SecondsToTime(entry.resetAt - now) or ""), r = 1, g = 0.35, b = 0.35 }
        elseif entry then
            lines[#lines + 1] = { text = string.format(L["lock_group_available_resets"], label, SecondsToTime(entry.resetAt - now) or ""), r = 0.4, g = 1, b = 0.4 }
        else
            lines[#lines + 1] = { text = string.format(L["lock_group_available"], label), r = 0.4, g = 1, b = 0.4 }
        end
    end
    return lines
end

-- Tooltip body for hovering a character name: every character's state for that boss.
function Lockouts:FillCharacterTooltip(tooltip, raidName, bossName)
    local store = AppearanceMaticDB and AppearanceMaticDB.lockouts
    local all = self:ViewCharacterKeys("all")
    if not (store and all) then return end
    tooltip:SetText(bossName .. ", " .. raidName)
    local now = time()
    local raid = self.lastRaids and self.lastRaids[raidName]
    local difficulties = raid and raid.bosses[bossName] and raid.bosses[bossName].difficulties or nil
    for _, charKey in ipairs(all) do
        local snapshot = store[charKey]
        if snapshot then
            tooltip:AddLine(self:CharacterLabel(charKey))
            for _, line in ipairs(self:BossStatusLines(snapshot, raidName, bossName, difficulties, now)) do
                tooltip:AddLine("  " .. line.text, line.r, line.g, line.b)
            end
        end
    end
end

-- Under an item row: each character's state for exactly the difficulties that item drops on
function Lockouts:AddCharacterStatus(raidName, bossName, difficulties, charKeys, indent)
    indent = indent or 2
    local store = AppearanceMaticDB and AppearanceMaticDB.lockouts
    if not (charKeys and type(store) == "table") then
        self:AddLine(L["lock_no_character_data"], indent, nil, 0.7, 0.7, 0.7)
        return
    end
    local now = time()
    for _, charKey in ipairs(charKeys) do
        local snapshot = store[charKey]
        if snapshot then
            -- the name is a link: hovering shows every character's state for this boss
            local shownName = "|Hw2tschar:" .. raidName .. ":" .. bossName .. "|h" .. self:CharacterLabel(charKey) .. "|h"
            for _, line in ipairs(self:BossStatusLines(snapshot, raidName, bossName, difficulties, now)) do
                self:AddLine(shownName .. " " .. line.text, indent, nil, line.r, line.g, line.b)
            end
        end
    end
end

-- "routing", "route", "rout", "routng": either word or a close typo of it
local function closeTo(q, word)
    if word:find(q, 1, true) then return true end
    if math.abs(#q - #word) > 2 then return false end
    -- Levenshtein distance, two edits allowed
    local prev = {}
    for j = 0, #word do prev[j] = j end
    for i = 1, #q do
        local cur = { [0] = i }
        for j = 1, #word do
            local cost = (q:sub(i, i) == word:sub(j, j)) and 0 or 1
            cur[j] = math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
        end
        prev = cur
    end
    return prev[#word] <= 2
end

function Lockouts:IsRoutingQuery(query)
    if type(query) ~= "string" then return false end
    local q = query:lower():gsub("^%s+", ""):gsub("%s+$", "")
    if #q < 3 then return false end
    return closeTo(q, "routing") or closeTo(q, "route")
end

-- The option: starred rows nobody can obtain any more (removed from the game, never implemented)
-- stay out of the panel.
function Lockouts:HideUnattainable()
    local profile = pluginShared.db and pluginShared.db.profile
    return (profile and profile.generalSettings and profile.generalSettings.hideUnattainable == true) or false
end

-- The acquisition record behind a starred row: the one it carries, else the shipped vendor row
function Lockouts:AcquisitionRecord(item)
    if type(item) ~= "table" then
        return nil
    end
    if item.acq then
        return item.acq
    end
    local acq = pluginShared.Acquisition
    if acq and item.itemID then
        local ok, rec = pcall(acq.GetVendor, acq, item.itemID)
        if ok then
            return rec
        end
    end
    return nil
end

-- The rows still obtainable, and how many were left out
function Lockouts:AttainableOnly(list)
    local acq = pluginShared.Acquisition
    if not (acq and acq.IsUnattainable) then
        return list or {}, 0
    end
    local kept, hidden = {}, 0
    for _, item in ipairs(list or {}) do
        if acq:IsUnattainable(self:AcquisitionRecord(item)) then
            hidden = hidden + 1
        else
            kept[#kept + 1] = item
        end
    end
    return kept, hidden
end

-- Raid drops: a mount whose record says removed leaves its boss; empty bosses and raids go too
function Lockouts:PruneUnattainableDrops(raids, raidOrder)
    local acq = pluginShared.Acquisition
    if not (acq and acq.IsUnattainable) then
        return 0
    end
    local hidden = 0
    for i = #raidOrder, 1, -1 do
        local raidName = raidOrder[i]
        local raid = raids[raidName]
        for b = #(raid and raid.bossOrder or {}), 1, -1 do
            local bossName = raid.bossOrder[b]
            local boss = raid.bosses[bossName]
            local kept = {}
            for _, item in ipairs(boss and boss.items or {}) do
                if item.acq and acq:IsUnattainable(item.acq) then
                    hidden = hidden + 1
                else
                    kept[#kept + 1] = item
                end
            end
            if boss then
                boss.items = kept
                if #kept == 0 then
                    raid.bosses[bossName] = nil
                    table.remove(raid.bossOrder, b)
                end
            end
        end
        if raid and #raid.bossOrder == 0 then
            raids[raidName] = nil
            table.remove(raidOrder, i)
        end
    end
    return hidden
end

function Lockouts:BuildContent()
    local track = pluginShared.Tracking
    local TRACK_APPEARANCE = track and track.APPEARANCE or 0
    local TRACK_MOUNT = track and track.MOUNT or 1
    self.routeLinks = {}
    local raids, raidOrder, starredCount, vendors, others = self:CollectStarredDrops()
    local otherMounts = {}
    starredCount = starredCount + self:CollectFavoriteMounts(raids, raidOrder, vendors, otherMounts)
    local hiddenRaids = {}
    if self:HideUnattainable() then
        local hidden
        vendors, hidden = self:AttainableOnly(vendors)
        starredCount = starredCount - hidden
        others, hidden = self:AttainableOnly(others)
        starredCount = starredCount - hidden
        otherMounts, hidden = self:AttainableOnly(otherMounts)
        starredCount = starredCount - hidden
        local before = {}
        for _, name in ipairs(raidOrder) do before[name] = true end
        starredCount = starredCount - self:PruneUnattainableDrops(raids, raidOrder)
        for name in pairs(before) do
            if not raids[name] then hiddenRaids[name] = true end
        end
    end
    -- stale route entries go: an item whose star is gone, and a raid with nothing starred left once
    -- the collection settled and no drop data is still loading (a raid the unattainable filter hid stays)
    local routeList = pluginShared.RouteList
    if routeList then
        local favorites = pluginShared.Favorites
        local settled = self.routeSettled and not self.dropsIncomplete
        for _, entry in ipairs(routeList:Ordered()) do
            if entry.raid then
                if not raids[entry.raid] and settled and not hiddenRaids[entry.raid] then
                    routeList:RemoveRaid(entry.raid)
                end
            elseif favorites and favorites.IsFavorite and favorites.MountIsFavorite then
                local starred
                if entry.isMount then
                    starred = favorites:MountIsFavorite(entry.id)
                else
                    starred = favorites:IsFavorite(entry.id)
                end
                if not starred then
                    routeList:Remove(entry.isMount, entry.id)
                end
            end
        end
    end
    -- the list keeps one fixed order (by name); only the Routing section above it is travel-ordered
    table.sort(raidOrder)
    local priorityRaid, priorityWhy = self:PriorityRaid(raidOrder)
    if priorityRaid then
        for i, name in ipairs(raidOrder) do
            if name == priorityRaid then
                table.remove(raidOrder, i)
                break
            end
        end
        table.insert(raidOrder, 1, priorityRaid)
    end
    for _, raidName in ipairs(raidOrder) do
        self:SortBossOrder(raidName, raids[raidName].bossOrder)
    end
    -- Raids fully cleared on this character this lockout sink to the bottom; the raid you are standing in stays on top.
    local doneRaids, pending, done = {}, {}, {}
    for _, raidName in ipairs(raidOrder) do
        if raidName ~= priorityRaid or priorityWhy ~= "inside" then
            if self:RaidDoneForCurrentChar(raidName, raids[raidName]) then
                doneRaids[raidName] = true
                done[#done + 1] = raidName
            else
                pending[#pending + 1] = raidName
            end
        else
            pending[#pending + 1] = raidName
        end
    end
    raidOrder = {}
    for _, n in ipairs(pending) do raidOrder[#raidOrder + 1] = n end
    for _, n in ipairs(done) do raidOrder[#raidOrder + 1] = n end
    if starredCount == 0 then
        self.routeOrder, self.routeRows = {}, 0
        self:AddLine(L["lock_no_starred_items"], 0, "GameFontNormal")
        self:AddLine(L["lock_star_hint"], 0, nil, 0.7, 0.7, 0.7)
        if routeList and routeList:Count() > 0 then
            -- route entries waiting on drop data still load: the player can always empty the route
            self:AddLine(self:RouteClearLink(), 0, nil, 1, 0.4, 0.4)
        end
        return
    end
    local query = self:SearchQuery()
    local queryChar
    queryChar, query = self:CharacterFromQuery(query)
    local charKeys = self:ViewCharacterKeys(queryChar or self:CharacterView())
    self.lastRaids = raids
    self:UpdateFilterBoxes()
    local hiddenCount = 0
    if queryChar then
        self:AddLine(string.format(L["lock_character_label"], self:CharacterLabel(queryChar)), 0, nil, 0.7, 0.7, 0.7)
    end
    local function plainText(link)
        if type(link) ~= "string" then return "" end
        return link:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|H.-|h", ""):gsub("|h", ""):gsub("|T.-|t", ""):lower()
    end
    local function matches(...)
        if not query then return true end
        for i = 1, select("#", ...) do
            local v = select(i, ...)
            if v and plainText(tostring(v)):find(query, 1, true) then
                return true
            end
        end
        return false
    end
    -- what a row IS also answers the search: mounts answer "mount", gear answers its own localized
    -- type and subtype ("plate", "cloth", "sword"), straight from the item data, no list to maintain
    local function itemTypeWords(item)
        if type(item) ~= "table" then
            return nil
        end
        if item.isMount then
            return L["lock_search_type_mount"]
        end
        local key = item.link or item.itemID
        if key and type(C_Item) == "table" and type(C_Item.GetItemInfo) == "function" then
            local ok, _, _, _, _, _, itemType, itemSubType = pcall(C_Item.GetItemInfo, key)
            if ok and (itemSubType or itemType) then
                return tostring(itemSubType or "") .. " " .. tostring(itemType or "")
            end
        end
        return nil
    end
    if query then
        self:AddLine(string.format(L["lock_search_label"], query), 0, nil, 0.7, 0.7, 0.7)
    end
    -- Where to send the arrow for a resolved source: exact coordinates, else the zone
    local function placeFor(info)
        local acq = pluginShared.Acquisition
        if not (info and acq) then return nil end
        local who = info.vendorName or (info.npcID and acq:NpcName(info.npcID)) or nil
        if info.mapID and info.x and info.y then
            return { mapID = info.mapID, x = info.x, y = info.y, title = acq:PlaceTitle(who or (info.costRows and L["lock_vendor_location"]) or L["lock_location_title"], info.mapID) }
        end
        if info.zoneMapID then
            return { mapID = info.zoneMapID, x = 0.5, y = 0.5, title = acq:PlaceTitle(who, info.zoneMapID), zoneOnly = true }
        end
        return nil
    end
    -- Cost, source, location and note lines under an item
    local function addSourceLines(info, indent)
        if not info then return end
        local acq = pluginShared.Acquisition
        if info.costText then
            self:AddLine(string.format(L["lock_cost_label"], info.costText), indent, nil, 0.8, 0.8, 0.8)
        end
        local sourceText = acq and acq:SourceText(info)
        if sourceText then
            self:AddLine(sourceText, indent, nil, 0.8, 0.8, 0.8)
        end
        local place = placeFor(info)
        if place then
            local label = place.zoneOnly and string.format(L["lock_zone_label"], place.title) or place.title
            self:AddLine(self:WaypointLink(place.mapID, place.x, place.y, label), indent, nil, 0.6, 0.8, 1)
        elseif info.vendorName then
            self:AddLine(info.vendorName, indent, nil, 0.6, 0.8, 1)
        end
        local note = acq and acq:NoteText(info)
        if note then
            self:AddLine("|cffffd200" .. note .. "|r", indent, nil, 1, 0.82, 0)
        end
        -- what stands between this character and the source (quest, standing, faction, event, removed)
        for _, access in ipairs(acq and acq.AccessLines and acq:AccessLines(info) or {}) do
            if access.blocked then
                self:AddLine(string.format(L["lock_access_label"], access.text), indent, nil, 1, 0.35, 0.35)
            else
                self:AddLine(string.format(L["lock_access_label"], access.text), indent, nil, 0.7, 0.7, 0.7)
            end
        end
    end
    local function renderVendor(item)
        local iconText = item.icon and ("|T" .. item.icon .. ":" .. ITEM_ICON .. "|t ") or ""
        local label = item.link or (RETRIEVING_ITEM_INFO or L["lock_retrieving_item_info"])
        local vendorInfo
        if item.isMount then
            vendorInfo = item.acq or { costText = item.costText, vendorName = item.vendorName, mapID = item.mapID, x = item.x, y = item.y, note = item.note, noteKey = item.noteKey }
        elseif pluginShared.Acquisition then
            local okVendor, resolved = reported(pluginShared.Acquisition.GetVendor, pluginShared.Acquisition, item.itemID)
            if not okVendor then
                if AppearanceMaticDB then
                    AppearanceMaticDB.diag = AppearanceMaticDB.diag or {}
                    AppearanceMaticDB.diag.vendorError = tostring(resolved)
                    AppearanceMaticDB.diag.vendorErrorItem = item.itemID
                end
                resolved = nil
            end
            vendorInfo = resolved
        end
        local fallback = placeFor(vendorInfo)
        local vendorTarget = ""
        if item.isMount and item.mountID then
            vendorTarget = self:TrackTarget(TRACK_MOUNT, item.mountID, nil, nil, fallback)
        elseif item.sourceID then
            vendorTarget = self:TrackTarget(TRACK_APPEARANCE, item.sourceID, nil, nil, fallback)
        end
        local vendorRouteID = item.isMount and item.mountID or item.visualID
        self:NoteRouteLink(item.isMount, vendorRouteID, vendorTarget)
        self:AddLine(iconText .. label .. self:RouteItemLink(item.isMount, vendorRouteID, item.sourceID) .. self:UnstarLink(item.isMount, vendorRouteID, item.sourceID), 1, nil, CREAM[1], CREAM[2], CREAM[3])
        addSourceLines(vendorInfo, 2)
        self.contentHeight = self.contentHeight + 4
    end
    local function renderMount(m)
        local iconText = m.icon and ("|T" .. m.icon .. ":" .. ITEM_ICON .. "|t ") or ""
        local mountLocationFallback = string.format(L["lock_mount_location_fallback"], m.name)
        local mountTitle = pluginShared.Acquisition and pluginShared.Acquisition:PlaceTitle(m.locName or mountLocationFallback, m.mapID) or (m.locName or mountLocationFallback)
        local mountFallback = m.mapID and m.x and m.y and { mapID = m.mapID, x = m.x, y = m.y, title = mountTitle } or nil
        local mountTarget = m.mountID and self:TrackTarget(TRACK_MOUNT, m.mountID, nil, nil, mountFallback) or ""
        self:NoteRouteLink(true, m.mountID, mountTarget)
        self:AddLine(iconText .. (m.link or m.name) .. self:RouteItemLink(true, m.mountID) .. self:UnstarLink(true, m.mountID), 1, nil, CREAM[1], CREAM[2], CREAM[3])
        if m.source then
            self:AddLine(m.source, 2, nil, 0.8, 0.8, 0.8)
        end
        if m.mapID and m.x and m.y then
            local mountLabel = self:WaypointLink(m.mapID, m.x, m.y, mountTitle)
            self:AddLine(mountLabel, 2, nil, 0.6, 0.8, 1)
        end
        local mountNote = pluginShared.Acquisition and pluginShared.Acquisition:NoteText(m)
        if mountNote then
            self:AddLine("|cffffd200" .. mountNote .. "|r", 2, nil, 1, 0.82, 0)
        end
        self.contentHeight = self.contentHeight + 4
    end
    local function renderOther(item)
        local iconText = item.icon and ("|T" .. item.icon .. ":" .. ITEM_ICON .. "|t ") or ""
        local label = item.link or (RETRIEVING_ITEM_INFO or L["lock_retrieving_item_info"])
        local known
        if pluginShared.Acquisition and item.itemID then
            local okVendor, resolved = reported(pluginShared.Acquisition.GetVendor, pluginShared.Acquisition, item.itemID)
            known = okVendor and resolved or nil
        end
        local fallback = placeFor(known)
        local otherTarget = item.sourceID and self:TrackTarget(TRACK_APPEARANCE, item.sourceID, nil, nil, fallback) or ""
        self:NoteRouteLink(false, item.visualID, otherTarget)
        self:AddLine(iconText .. label .. self:RouteItemLink(false, item.visualID, item.sourceID) .. self:UnstarLink(false, item.visualID, item.sourceID), 1, nil, CREAM[1], CREAM[2], CREAM[3])
        if item.sourceText then
            self:AddLine(item.sourceText, 2, nil, 0.8, 0.8, 0.8)
        end
        addSourceLines(known, 2)
        self.contentHeight = self.contentHeight + 4
    end
    -- The route: the raids and single items the player picked, under Routing at the top of the panel,
    -- nearest first from where the player stands (the entry being guided stays first). A routed raid
    -- renders whole, boss rows inside; its items are never listed twice. Nothing else moves the list.
    local routedRaids = {}
    local routeEntries = {}
    local pulled = {}
    -- searching for anything but the route hides the Routing section; its raids and pulled rows
    -- then answer the search like any other line
    local routeHidden = (query and not self:IsRoutingQuery(query)) and true or false
    self.routeOrder = {}
    if routeList then
        for _, entry in ipairs(routeList:Ordered()) do
            if entry.raid and raids[entry.raid] then
                routedRaids[entry.raid] = true
                local continentID, wx, wy = self:EntranceWorld(entry.raid)
                routeEntries[#routeEntries + 1] = { raid = entry.raid, continentID = continentID, x = wx, y = wy, seq = entry.seq }
            end
        end
    end
    -- rows still listed under a raid once its single routed items moved up to the Routing section
    local function remaining(raid)
        local count = 0
        for _, bossName in ipairs(raid.bossOrder) do
            for _, item in ipairs(raid.bosses[bossName].items) do
                if not pulled[item] then count = count + 1 end
            end
        end
        return count
    end
    local function routePos(isMount, id)
        return (routeList and id and routeList:Position(isMount, id)) or nil
    end
    -- single items on the route leave their own lists for the Routing section
    local function pullRoute(list, isMountOf, idOf, worldOf, render)
        local kept = {}
        for _, entry in ipairs(list or {}) do
            local isMount, id = isMountOf(entry), idOf(entry)
            local pos = routePos(isMount, id)
            if pos then
                local continentID, wx, wy = worldOf(entry)
                routeEntries[#routeEntries + 1] = { isMount = isMount, id = id, entry = entry, render = render, seq = pos,
                    continentID = continentID, x = wx, y = wy }
            else
                kept[#kept + 1] = entry
            end
        end
        return kept
    end
    local function worldOfPlace(place)
        if not place then return nil end
        return self:MapWorld(place.mapID, place.x, place.y)
    end
    local function knownVendor(item)
        if item.isMount then
            return item.acq or { mapID = item.mapID, x = item.x, y = item.y, vendorName = item.vendorName, costText = item.costText }
        end
        if pluginShared.Acquisition and item.itemID then
            local ok, resolved = reported(pluginShared.Acquisition.GetVendor, pluginShared.Acquisition, item.itemID)
            return ok and resolved or nil
        end
        return nil
    end
    vendors = pullRoute(vendors, function(i) return i.isMount end, function(i) return i.isMount and i.mountID or i.visualID end,
        function(i) return worldOfPlace(placeFor(knownVendor(i))) end, renderVendor)
    otherMounts = pullRoute(otherMounts, function() return true end, function(m) return m.mountID end,
        function(m) return self:MapWorld(m.mapID, m.x, m.y) end, renderMount)
    others = pullRoute(others, function() return false end, function(i) return i.visualID end,
        function(i) return worldOfPlace(placeFor(knownVendor(i))) end, renderOther)
    -- a starred row's target: Blizzard's tracker, the raid entrance, the Raid Finder NPC or the spot
    local function dropTarget(item, raidName)
        -- a drop whose shipped record knows the spot carries it along: the last resort for a group
        -- with no entrance when the game refuses the track
        local acq = item.acq
        local spot = acq and acq.mapID and acq.x and acq.y and { mapID = acq.mapID, x = acq.x, y = acq.y } or nil
        if item.isMount and item.mountID then
            return self:TrackTarget(TRACK_MOUNT, item.mountID, raidName, item.difficulties, spot)
        elseif item.sourceID then
            return self:TrackTarget(TRACK_APPEARANCE, item.sourceID, raidName, item.difficulties, spot)
        end
        return ""
    end
    -- A raid drop row: the raid header is the route control; only a single drop that sits on the route
    -- by itself (added from the wardrobe) carries its own Remove from route.
    local function renderDropRow(item, raidName, indent, inRoutedRaid)
        local iconText = item.icon and ("|T" .. item.icon .. ":" .. ITEM_ICON .. "|t ") or ""
        local label = item.link or (RETRIEVING_ITEM_INFO or L["lock_retrieving_item_info"])
        local rowRouteID = item.isMount and item.mountID or item.visualID
        self:NoteRouteLink(item.isMount, rowRouteID, dropTarget(item, raidName), raidName)
        local routeLink = ""
        if not inRoutedRaid and routeList and routeList:Has(item.isMount, rowRouteID) then
            routeLink = self:RouteItemLink(item.isMount, rowRouteID, item.sourceID)
        end
        local diffNames = {}
        for difficultyName in pairs(item.difficulties or {}) do
            diffNames[#diffNames + 1] = difficultyName
        end
        table.sort(diffNames)
        local diffSuffix = (#diffNames > 0 and indent >= 2) and ("  |cffffd200" .. table.concat(diffNames, ", ") .. "|r") or ""
        self:AddLine(iconText .. label .. diffSuffix .. routeLink .. self:UnstarLink(item.isMount, rowRouteID, item.sourceID), indent, nil, CREAM[1], CREAM[2], CREAM[3])
    end
    -- a whole raid or dungeon: header with its route control, gate, bosses, rows, lockout status
    local function renderRaid(raidName, raid, onRoute, rowFilter)
        -- the title line: the raid name (hover it for the zone and expansion), "you are here" when
        -- standing in it, and the route control beside it
        local title = self:RaidTitleLink(raidName)
        if raidName == priorityRaid then
            title = title .. "  |cff40ff40" .. L["lock_you_are_here"] .. "|r"
        end
        self:AddLine(title .. self:RouteRaidLink(raidName), 0, "GameFontNormal", 1, 0.82, 0)
        if doneRaids[raidName] then
            -- its own line under the title: every starred boss here is down on this character
            self:AddLine(L["lock_done_this_lockout"], 1, nil, 1, 0.38, 0.38)
        end
        -- a plain call: "x and f()" keeps only f()'s first value and would drop the colour
        local gateText, gr, gg, gb
        if self.GateLine then gateText, gr, gg, gb = self:GateLine(raidName) end
        if gateText then
            self:AddLine(gateText, 1, nil, gr, gg, gb)
        end
        -- the week's active boss shows even when nothing is starred from it: the group's answer
        -- to "which boss is up" should never depend on the player's wishlist
        local activeExtra
        local wbEntry = self.BuildBossOrdinalIndex and self:BuildBossOrdinalIndex()[raidName:lower()] or nil
        if wbEntry and wbEntry.bossEncounters and self.IsActiveWorldBoss then
            for bossKey, encounterID in pairs(wbEntry.bossEncounters) do
                if self:IsActiveWorldBoss(raidName, bossKey) then
                    local listedHere = false
                    for _, listed in ipairs(raid.bossOrder) do
                        if listed:lower() == bossKey then
                            listedHere = true
                            break
                        end
                    end
                    if not listedHere then
                        activeExtra = bossKey
                        if type(EJ_GetEncounterInfo) == "function" then
                            local okName, proper = pcall(EJ_GetEncounterInfo, encounterID)
                            if okName and type(proper) == "string" and proper ~= "" then
                                activeExtra = proper
                            end
                        end
                    end
                    break
                end
            end
        end
        if activeExtra then
            self:AddLine(activeExtra .. "  |cff40ff40" .. L["lock_boss_active"] .. "|r", 1, "GameFontHighlight")
            self:AddLine(L["lock_boss_active_none"], 2, nil, 0.6, 0.6, 0.6)
        end
        for _, bossName in ipairs(raid.bossOrder) do
            local boss = raid.bosses[bossName]
            -- a group kept only by its contents shows only the rows that answer the search; a boss
            -- whose own name answers keeps all of its rows
            local bossAnswers = not rowFilter or matches(bossName)
            local rows = {}
            for _, item in ipairs(boss.items) do
                if (routeHidden or not pulled[item]) and (bossAnswers or rowFilter(item)) then
                    rows[#rows + 1] = item
                end
            end
            if #rows > 0 then
                local ordinal, ordinalTotal = self:BossOrdinal(raidName, bossName)
                local ordinalText = ordinal and ("  |cff808080" .. string.format(L["lock_boss_ordinal"], ordinal, ordinalTotal) .. "|r") or ""
                local activeText = self:IsActiveWorldBoss(raidName, bossName) and ("  |cff40ff40" .. L["lock_boss_active"] .. "|r") or ""
                self:AddLine(bossName .. activeText .. ordinalText, 1, "GameFontHighlight")
                local bossGate, bgr, bgg, bgb
                if self.BossGateText then
                    bossGate, bgr, bgg, bgb = self:BossGateText(raidName, bossName)
                end
                if bossGate then
                    self:AddLine(bossGate, 2, nil, bgr, bgg, bgb)
                end
                for _, item in ipairs(rows) do
                    renderDropRow(item, raidName, 2, onRoute)
                    local finderHint = self:RaidFinderHint(raidName, item.difficulties, bossName)
                    if finderHint ~= "" then
                        self:AddLine(finderHint, 3, nil, CREAM[1], CREAM[2], CREAM[3])
                    end
                    -- the characters' state for this item's own difficulties, right under the row
                    self:AddCharacterStatus(raidName, bossName, item.difficulties, charKeys, 3)
                end
                self.contentHeight = self.contentHeight + 6
            end
        end
        self.contentHeight = self.contentHeight + 8
    end
    -- a single raid drop on the route: the row plus where it is
    local function renderDrop(item, raidName)
        local bossName = item.routeBoss
        renderDropRow(item, raidName, 1, false)
        local zone = self:RaidZoneName(raidName)
        self:AddLine(bossName .. ", " .. raidName .. (zone and (", " .. zone) or ""), 2, nil, 0.8, 0.8, 0.8)
        local finderHint = self:RaidFinderHint(raidName, item.difficulties, bossName)
        if finderHint ~= "" then
            self:AddLine(finderHint, 2, nil, CREAM[1], CREAM[2], CREAM[3])
        end
        local gateText, gr, gg, gb
        if self.GateLine then gateText, gr, gg, gb = self:GateLine(raidName) end
        if gateText then
            self:AddLine(gateText, 2, nil, gr, gg, gb)
        end
        -- a single routed drop is an item row too: the characters' state for its difficulties under it
        self:AddCharacterStatus(raidName, bossName, item.difficulties, charKeys, 2)
        self.contentHeight = self.contentHeight + 4
    end
    for _, raidName in ipairs(raidOrder) do
        if not routedRaids[raidName] then
            local raid = raids[raidName]
            for _, bossName in ipairs(raid.bossOrder) do
                for _, item in ipairs(raid.bosses[bossName].items) do
                    local pos = routePos(item.isMount, item.isMount and item.mountID or item.visualID)
                    if pos then
                        -- the row moves up to the Routing section; its raid below keeps only the rest
                        item.routeBoss = bossName
                        pulled[item] = true
                        local continentID, wx, wy = self:EntranceWorld(raidName)
                        routeEntries[#routeEntries + 1] = { isMount = item.isMount, id = item.isMount and item.mountID or item.visualID,
                            entry = item, render = renderDrop, raidName = raidName, seq = pos, continentID = continentID, x = wx, y = wy }
                    end
                end
            end
        end
    end
    -- the entry being guided stays first; the rest follow nearest first from it (or from the player)
    table.sort(routeEntries, function(a, b) return a.seq < b.seq end)
    local guidedEntry
    for index, e in ipairs(routeEntries) do
        local hit
        if e.raid then
            -- the entrance route names the raid; a Raid Finder or tracked guide names one of its drops
            hit = self:IsGuidingRaid(e.raid)
            if not hit then
                local raid = raids[e.raid]
                for _, bossName in ipairs(raid.bossOrder) do
                    for _, item in ipairs(raid.bosses[bossName].items) do
                        if self:IsRouteItemGuided(item.isMount, item.isMount and item.mountID or item.visualID) then
                            hit = true
                            break
                        end
                    end
                    if hit then break end
                end
            end
        else
            hit = self:IsRouteItemGuided(e.isMount, e.id)
        end
        if hit then
            guidedEntry = table.remove(routeEntries, index)
            break
        end
    end
    local ordered = self:TravelOrder(routeEntries, guidedEntry)
    if guidedEntry then
        table.insert(ordered, 1, guidedEntry)
    end
    -- a raid done this lockout on this character (or a single drop from one) sinks to the bottom of
    -- the Routing section, guided or not: there is nothing to go there for until the reset, but it
    -- stays on the route, so it stays in the section rather than dropping to the bottom of the panel
    local open, finished = {}, {}
    local noChanceRaids = {}
    for _, e in ipairs(ordered) do
        local doneName = e.raid or e.raidName
        if doneName and noChanceRaids[doneName] == nil and self.WorldBossNoChance then
            noChanceRaids[doneName] = self:WorldBossNoChance(doneName, raids[doneName]) or false
        end
        if doneName and (doneRaids[doneName] or noChanceRaids[doneName]) then
            finished[#finished + 1] = e
        else
            open[#open + 1] = e
        end
    end
    for _, e in ipairs(finished) do
        open[#open + 1] = e
    end
    ordered = open
    for _, e in ipairs(ordered) do
        local record
        if e.raid then
            local items = {}
            local raid = raids[e.raid]
            for _, bossName in ipairs(raid.bossOrder) do
                for _, item in ipairs(raid.bosses[bossName].items) do
                    items[#items + 1] = { isMount = item.isMount, id = item.isMount and item.mountID or item.visualID }
                end
            end
            record = { raid = e.raid, items = items, done = doneRaids[e.raid] or false, noChance = noChanceRaids[e.raid] or nil }
        else
            record = { isMount = e.isMount, id = e.id, raidName = e.raidName, done = (e.raidName and doneRaids[e.raidName]) or false, noChance = (e.raidName and noChanceRaids[e.raidName]) or nil }
        end
        record.guided = (e == guidedEntry) or nil
        self.routeOrder[#self.routeOrder + 1] = record
    end
    self.routeRows = #ordered
    -- while the player searches for something else the Routing section steps aside; clearing the
    -- search brings it straight back (routeOrder is built above either way, so the guide keeps
    -- working mid-search)
    if #ordered > 0 and not routeHidden then
        self:AddLine("|T" .. routeList.ICON .. ":" .. ROUTING_ICON .. ":" .. ROUTING_ICON .. ":0:0|t " .. L["lock_routing_header"], 0, "GameFontNormal", CREAM[1], CREAM[2], CREAM[3])
        self:PlaceRouteToggle(self.lines and self.lines[self.lineCount])
        for _, e in ipairs(ordered) do
            if e.raid then
                renderRaid(e.raid, raids[e.raid], true)
            elseif e.raidName then
                e.render(e.entry, e.raidName)
            else
                e.render(e.entry)
            end
        end
        self:AddLine(self:RouteClearLink(), 0, nil, 1, 0.4, 0.4)
        self.contentHeight = self.contentHeight + 8
    end
    if query and self:IsRoutingQuery(query) then
        -- searching "routing" (or a near miss of it) shows only the route
        if #ordered == 0 then
            self:AddLine(L["lock_nothing_routing"], 0, nil, 0.7, 0.7, 0.7)
        end
        return
    end
    -- while searching, the best reading of what was typed leads: a query that starts a word in
    -- the raid's own name first ("und" means Undermine), then a name it merely sits inside
    -- (Foundry), then zone or expansion hits, and last the raids that only match through a boss
    -- or an item inside
    local searchRanked = query and {} or nil
    local queryPattern = query and ("%f[%w]" .. query:gsub("%W", "%%%0")) or nil
    for _, raidName in ipairs(raidOrder) do
        local raid = ((not routedRaids[raidName]) or routeHidden) and raids[raidName] or nil
        if raid and not routeHidden and remaining(raid) == 0 then
            -- every row of this raid sits in the Routing section as a single item
            raid = nil
        end
        local zoneName = self:RaidZoneName(raidName)
        local expansionName = self:RaidExpansionName(raidName)
        if raid and not self:PassesExpansionFilter(expansionName, raidName) then
            hiddenCount = hiddenCount + 1
            raid = nil
        end
        local rank = 0
        if raid and query then
            local nameText = plainText(tostring(raidName))
            if nameText:find(queryPattern) then
                rank = 0
            elseif nameText:find(query, 1, true) then
                rank = 1
            elseif matches(zoneName, expansionName) then
                rank = 2
            else
                local anyHit = false
                for _, bossName in ipairs(raid.bossOrder) do
                    if matches(bossName) then anyHit = true break end
                    for _, item in ipairs(raid.bosses[bossName].items) do
                        if matches(item.link, item.name, itemTypeWords(item)) then anyHit = true break end
                    end
                    if anyHit then break end
                end
                if not anyHit then
                    hiddenCount = hiddenCount + 1
                    raid = nil
                else
                    rank = 3
                end
            end
        end
        if raid then
            if searchRanked then
                searchRanked[#searchRanked + 1] = { raidName = raidName, raid = raid, rank = rank }
            else
                renderRaid(raidName, raid, false)
            end
        end
    end
    if searchRanked then
        local function rowAnswers(item)
            return matches(item.link, item.name, itemTypeWords(item))
        end
        for wanted = 0, 3 do
            for _, e in ipairs(searchRanked) do
                if e.rank == wanted then
                    renderRaid(e.raidName, e.raid, false, e.rank == 3 and rowAnswers or nil)
                end
            end
        end
    end
    if vendors and #vendors > 0 and self:ExpansionFilterActive() then
        local kept = {}
        for _, item in ipairs(vendors) do
            -- a mount sold by a vendor has no expansion of its own; only items are sorted by theirs
            if item.isMount or self:PassesExpansionFilter(self:ItemExpansionName(item.itemID)) then
                kept[#kept + 1] = item
            else
                hiddenCount = hiddenCount + 1
            end
        end
        vendors = kept
    end
    if vendors and #vendors > 0 and query then
        local kept = {}
        for _, item in ipairs(vendors) do
            if matches(item.link, item.name, item.vendorName, itemTypeWords(item)) then kept[#kept + 1] = item else hiddenCount = hiddenCount + 1 end
        end
        vendors = kept
    end
    if otherMounts and #otherMounts > 0 and query then
        local kept = {}
        for _, m in ipairs(otherMounts) do
            if matches(m.link, m.name, m.source, m.locName, L["lock_search_type_mount"]) then kept[#kept + 1] = m else hiddenCount = hiddenCount + 1 end
        end
        otherMounts = kept
    end
    if others and #others > 0 and query then
        local kept = {}
        for _, item in ipairs(others) do
            if matches(item.link, item.name, item.sourceText, itemTypeWords(item)) then kept[#kept + 1] = item else hiddenCount = hiddenCount + 1 end
        end
        others = kept
    end
    -- a section whose box is off leaves whole; its rows count as hidden
    local show = self:ShowFilter()
    if vendors and #vendors > 0 and not show.vendors then
        hiddenCount = hiddenCount + #vendors
        vendors = {}
    end
    if otherMounts and #otherMounts > 0 and not show.mounts then
        hiddenCount = hiddenCount + #otherMounts
        otherMounts = {}
    end
    if others and #others > 0 and not show.other then
        hiddenCount = hiddenCount + #others
        others = {}
    end
    if vendors and #vendors > 0 then
        self:AddLine(L["lock_vendors_header"], 0, "GameFontNormal", 1, 0.82, 0)
        for _, item in ipairs(vendors) do renderVendor(item) end
        self.contentHeight = self.contentHeight + 8
    end
    if otherMounts and #otherMounts > 0 then
        self:AddLine(L["lock_mounts_header"], 0, "GameFontNormal", 1, 0.82, 0)
        for _, m in ipairs(otherMounts) do renderMount(m) end
        self.contentHeight = self.contentHeight + 8
    end
    if others and #others > 0 then
        self:AddLine(L["lock_other_items_header"], 0, "GameFontNormal", 1, 0.82, 0)
        for _, item in ipairs(others) do renderOther(item) end
        self.contentHeight = self.contentHeight + 8
    end
    if hiddenCount > 0 then
        self:AddLine(string.format(L["lock_hidden_count"], hiddenCount), 0, nil, 0.6, 0.6, 0.6)
    end
    if #raidOrder == 0 and (not vendors or #vendors == 0) and #otherMounts == 0 and (not others or #others == 0) then
        self:AddLine(L["lock_none_from_raids_vendors"], 0, "GameFontNormal")
    end
end

-- The last few draws, for /am diag: when, who asked, how many lines, how many of them were actually
-- written (the rest were already there, drawn the same), how many still say "retrieving", the
-- content's height and width, what it cost, and whether it was the first draw since the panel opened.
-- A flash is two frames that differ: this says which draws differed and in what.
local DRAW_LOG_ENTRIES = 12
function Lockouts:NoteDraw(now, asker, cost, first)
    local log = self.drawLog or {}
    self.drawLog = log
    log[#log + 1] = string.format("%.1f %s lines=%d changed=%d retrieving=%d h=%d w=%d %.0fms%s", now, tostring(asker),
        self.lineCount or 0, self.drawChanged or 0, self.drawRetrieving or 0, self.contentHeight or 0,
        (self.content and tonumber(self.content:GetWidth())) or 0, cost or 0, first and " first" or "")
    while #log > DRAW_LOG_ENTRIES do
        table.remove(log, 1)
    end
end

function Lockouts:Rebuild()
    if not self.panel then
        return
    end
    local began = type(debugprofilestop) == "function" and debugprofilestop() or nil
    local asker = self.Asker and self:Asker(3) or "?"
    local first = not self.panelRevealed
    self:ResetLines()
    self.gateStates = nil
    self.combatHidden = nil
    -- Swallowing a broken draw is what kept a real bug hidden: the panel keeps drawing what it can, but
    -- the error still goes to the game's own handler so BugGrabber records it with a traceback. The
    -- handler runs at the point of the error, which is where the stack still exists.
    local ok, err = xpcall(self.BuildContent, function(raised)
        if type(CallErrorHandler) == "function" then
            CallErrorHandler(raised)
        elseif type(geterrorhandler) == "function" then
            local handler = geterrorhandler()
            if type(handler) == "function" then
                handler(raised)
            end
        end
        return raised
    end, self)
    if not ok then
        -- Mid-fight the game withholds values the panel reads, and touching one raises. Telling the
        -- player that is useful; a raw Lua error is not. The error itself still goes to the diagnostics.
        -- Being in combat is not the test, though: a real bug that happens to raise mid-fight is still
        -- a real bug and is shown as one. The test is whether the game actually held something back
        -- this build, or the error is the engine's own complaint about a withheld value.
        local text = tostring(err):lower()
        local withheld = self.combatHidden or text:find("secret", 1, true) ~= nil
        if withheld then
            self:AddLine(L["lock_combat_hidden"], 0, "GameFontNormal", 1, 0.82, 0)
        else
            self:AddLine(L["lock_build_error"], 0, "GameFontNormal", 1, 0.4, 0.4)
            self:AddLine(tostring(err), 1, nil, 1, 0.5, 0.5)
        end
        if AppearanceMaticDB then
            AppearanceMaticDB.diag = AppearanceMaticDB.diag or {}
            AppearanceMaticDB.diag.rebuildError = tostring(err)
            AppearanceMaticDB.diag.rebuildErrorLine = self.lineCount
            AppearanceMaticDB.diag.rebuildErrorPrevText = self.lastLineText
        end
    elseif AppearanceMaticDB and AppearanceMaticDB.diag then
        AppearanceMaticDB.diag.rebuildError = nil
        AppearanceMaticDB.diag.rebuildErrorLine = nil
        AppearanceMaticDB.diag.rebuildErrorPrevText = nil
    end
    if self.combatHidden and ok then
        -- some of the list is genuinely missing rather than empty: say so where the player can see it
        self:AddLine(L["lock_combat_hidden"], 0, nil, 1, 0.82, 0)
    end
    self.content:SetHeight(self.contentHeight + 16)
    self.builtWidth = self.content:GetWidth()
    self:NoteDraw(type(GetTime) == "function" and GetTime() or 0, asker, began and (debugprofilestop() - began) or nil, first)
    if AppearanceMaticDB then
        AppearanceMaticDB.diag = AppearanceMaticDB.diag or {}
        AppearanceMaticDB.diag.panelOpens = (AppearanceMaticDB.diag.panelOpens or 0) + 1
        AppearanceMaticDB.diag.panelLines = self.lineCount
    end
    if self.UpdateLoadingCover then
        self:UpdateLoadingCover()
    end
end

function Lockouts:SaveWidgetState()
    if not (AppearanceMaticDB and self.panel) then
        return
    end
    local widget = AppearanceMaticDB.widget or {}
    AppearanceMaticDB.widget = widget
    widget.shown = self.panel:IsShown() and true or false
    widget.minimized = self.minimized and true or false
    local point, _, relPoint, x, y = self.panel:GetPoint()
    if point then
        widget.point = point
        widget.relPoint = relPoint
        widget.x = x
        widget.y = y
    end
    widget.w = self.panel:GetWidth()
    widget.h = self.expandedHeight
    widget.extraChars = self.extraChars
end

-- The read side of widget.extraChars, called from Init; kept as its own method so it can be
-- exercised without building the whole panel.
function Lockouts:LoadExtraChars()
    local widget = AppearanceMaticDB and AppearanceMaticDB.widget
    self.extraChars = (widget and type(widget.extraChars) == "table") and widget.extraChars or {}
    return self.extraChars
end

local function frameSettings()
    return pluginShared.db and pluginShared.db.profile and pluginShared.db.profile.lockoutsFrame
end

function Lockouts:IsLocked()
    local fs = frameSettings()
    return fs and fs.locked and true or false
end

function Lockouts:SetLocked(locked)
    local fs = frameSettings()
    if fs then
        fs.locked = locked and true or false
    end
end

function Lockouts:SetScale(scale)
    scale = tonumber(scale) or 1
    local fs = frameSettings()
    if fs then
        fs.scale = scale
    end
    if self.panel then
        self.panel:SetScale(scale)
    end
end

function Lockouts:GetScale()
    local fs = frameSettings()
    return (fs and fs.scale) or 1
end

function Lockouts:SetAlpha(alpha)
    alpha = tonumber(alpha) or 1
    local fs = frameSettings()
    if fs then
        fs.alpha = alpha
    end
    if self.panel then
        self.panel:SetAlpha(alpha)
    end
end

function Lockouts:GetAlpha()
    local fs = frameSettings()
    return (fs and fs.alpha) or 1
end

function Lockouts:ResetPosition()
    if AppearanceMaticDB and AppearanceMaticDB.widget then
        AppearanceMaticDB.widget.point = nil
        AppearanceMaticDB.widget.relPoint = nil
        AppearanceMaticDB.widget.x = nil
        AppearanceMaticDB.widget.y = nil
        AppearanceMaticDB.widget.w = nil
        AppearanceMaticDB.widget.h = nil
    end
    if self.panel then
        self.panel:ClearAllPoints()
        self.panel:SetPoint("CENTER", UIParent, "CENTER", 280, 20)
        self.expandedHeight = PANEL_HEIGHT
        self.panel:SetSize(PANEL_WIDTH, self.minimized and MINIMIZED_HEIGHT or PANEL_HEIGHT)
        self:SaveWidgetState()
    end
end

function Lockouts:ApplyMinimized(minimized)
    self.minimized = minimized and true or false
    if not self.panel then
        return
    end
    local toolbar = { self.scroll, self.searchBox, self.charDropdown, self.window and self.window._grip }
    for _, key in ipairs(SHOW_KEYS) do
        toolbar[#toolbar + 1] = self.filterBoxes and self.filterBoxes[key]
    end
    if self.minimized then
        for _, widget in ipairs(toolbar) do
            widget:Hide()
        end
        self.panel:SetHeight(MINIMIZED_HEIGHT)
        if self.minButton then
            self.minButton:SetText("+")
        end
    else
        for _, widget in ipairs(toolbar) do
            widget:Show()
        end
        self.panel:SetHeight(self.expandedHeight or PANEL_HEIGHT)
        if self.minButton then
            self.minButton:SetText("-")
        end
        if self.panel:IsShown() then
            self:Rebuild()
        end
    end
    self:SaveWidgetState()
end

-- Wipes the search box and its query, so the whole panel returns after a filtered click.
function Lockouts:ClearSearch()
    if (self.searchText or "") == "" then
        return
    end
    self.searchText = ""
    local box = self.searchBox
    if box then
        if box.SetText then box:SetText("") end
        if box.ClearFocus then box:ClearFocus() end
    end
    if self.ScheduleRebuild then
        self:ScheduleRebuild()
    end
end

function Lockouts:SearchQuery()
    local text = self.searchText
    if type(text) ~= "string" then return nil end
    text = text:gsub("^%s+", ""):gsub("%s+$", ""):lower()
    if text == "" then return nil end
    return text
end

-- The search box and the character picker each take half of the toolbar row (the row runs from
-- the medallion's right to the panel's right edge); re-run on every resize.
function Lockouts:LayoutToolbar()
    local panel = self.panel or (self.window and self.window.GetFrame and self.window:GetFrame())
    if not (panel and self.searchBox and self.charDropdown) then
        return
    end
    local width = tonumber(panel:GetWidth()) or PANEL_WIDTH
    local half = math.floor((width - TOOLBAR_LEFT - TOOLBAR_RIGHT - TOOLBAR_GAP) / 2)
    if half < 40 then
        half = 40
    end
    self.searchBox:SetWidth(half)
    self.charDropdown:SetWidth(half)
    self:FitFilterBoxes(width)
end

-- The show boxes run left to right from FILTER_LEFT; when their labels at the panel font size would
-- pass the right edge (a narrow panel, long localized names), every label shrinks by the same factor.
local FILTER_MIN_FONT = 8
function Lockouts:FitFilterBoxes(panelWidth)
    if not (self.filterBoxes and self.PanelFontSize and self.PanelFontPath) then
        return
    end
    local base = self:PanelFontSize("control")
    local path, outline = self:PanelFontPath(), self:PanelFontOutline()
    local needed = FILTER_LEFT + TOOLBAR_RIGHT
    local labels = {}
    for _, key in ipairs(SHOW_KEYS) do
        local box = self.filterBoxes[key]
        local label = box and type(box.Text) == "table" and box.Text or nil
        needed = needed + FILTER_BOX + 2 + FILTER_GAP
        if label and label.SetFont and label.GetStringWidth then
            label:SetFont(path, base, outline)
            local w = label:GetStringWidth()
            if type(w) ~= "number" then
                return
            end
            needed = needed + w
            labels[#labels + 1] = label
        end
    end
    needed = needed - FILTER_GAP
    local width = tonumber(panelWidth) or PANEL_WIDTH
    if needed > width and needed > 0 then
        local size = math.max(FILTER_MIN_FONT, math.floor(base * width / needed))
        for _, label in ipairs(labels) do
            label:SetFont(path, size, outline)
        end
    end
end

-- The show filter lives in the saved widget state; a missing key means on.
function Lockouts:ShowFilter()
    local w = AppearanceMaticDB and AppearanceMaticDB.widget
    local saved = (w and type(w.show) == "table") and w.show or nil
    local show = {}
    for _, key in ipairs(SHOW_KEYS) do
        show[key] = not (saved and saved[key] == false)
    end
    return show
end

function Lockouts:SetShow(key, on)
    AppearanceMaticDB = AppearanceMaticDB or {}
    AppearanceMaticDB.widget = AppearanceMaticDB.widget or {}
    AppearanceMaticDB.widget.show = AppearanceMaticDB.widget.show or {}
    AppearanceMaticDB.widget.show[key] = on and true or false
    self:UpdateFilterBoxes()
    self:Rebuild()
end

-- true while one of the two expansion boxes is off (the raids and vendor rows then test their expansion)
function Lockouts:ExpansionFilterActive()
    local show = self:ShowFilter()
    return not (show.current and show.legacy)
end

-- The label the current-expansion box wears: the game's name for the expansion, "current" until known
function Lockouts:CurrentExpansionLabel()
    local name = self.CurrentExpansionName and self:CurrentExpansionName()
    return (type(name) == "string" and name ~= "") and name or L["lock_current_fallback"]
end

local FILTER_LABELS = {
    legacy = "lock_filter_legacy", vendors = "lock_vendors_header", mounts = "lock_mounts_header", other = "lock_filter_other",
}
local FILTER_HINTS = {
    current = "lock_filter_current_hint", legacy = "lock_filter_legacy_hint", vendors = "lock_filter_vendors_hint",
    mounts = "lock_filter_mounts_hint", other = "lock_filter_other_hint",
}

function Lockouts:FilterBoxLabel(key)
    if key == "current" then
        return self:CurrentExpansionLabel()
    end
    return L[FILTER_LABELS[key]]
end

function Lockouts:FilterBoxHint(key)
    local hint = L[FILTER_HINTS[key]]
    if key == "current" then
        return string.format(hint, self:CurrentExpansionLabel())
    end
    return hint
end

-- Labels and ticks follow the saved filter (and the expansion name, once the game knows it)
function Lockouts:UpdateFilterBoxes()
    if not self.filterBoxes then
        return
    end
    local show = self:ShowFilter()
    for _, key in ipairs(SHOW_KEYS) do
        local box = self.filterBoxes[key]
        if box then
            if type(box.Text) == "table" then
                box.Text:SetText(self:FilterBoxLabel(key))
            end
            box:SetChecked(show[key])
        end
    end
    local panel = self.panel or (self.window and self.window.GetFrame and self.window:GetFrame())
    if panel then
        self:FitFilterBoxes(panel:GetWidth())
    end
end

function Lockouts:ToggleMinimized()
    self:ApplyMinimized(not self.minimized)
end

-- The panel is a LibMatic window (the same chrome the other Infinite Loop Alchemist addons use): dark
-- backdrop, medallion, brand title, divider, close button and resize grip come from the library; the
-- search box, character dropdown and minimize button sit on a toolbar strip under the divider, the show
-- boxes on a second strip under that, and the library's scroll/content pair is where the lines render.

function Lockouts:EnsurePanel()
    if self.panel then
        return self.panel
    end
    local RosterFrame = LibStub and LibStub("LibMatic-RosterFrame-1.0", true)
    if not RosterFrame then
        return nil
    end
    local widget = AppearanceMaticDB and AppearanceMaticDB.widget
    self.expandedHeight = (widget and tonumber(widget.h)) or PANEL_HEIGHT
    local win = RosterFrame:New({
        name = "AppearanceMaticLockoutPanel",
        title = L["lock_panel_title"],
        titleColorHex = PANEL_BRAND,
        emblem = PANEL_MEDALLION,
        iconSize = 34,
        iconBG = { 0, 0, 0, 1 },
        width = (widget and tonumber(widget.w)) or PANEL_WIDTH,
        height = self.expandedHeight,
        minWidth = PANEL_MIN_WIDTH, minHeight = PANEL_MIN_HEIGHT,
        maxWidth = 900, maxHeight = 900,
        strata = "HIGH",
        defaultPoint = { point = "CENTER", x = 280, y = 20 },
        skipRows = true,
        applyFont = function(fs, size) Lockouts:ApplyFont(fs, "title", size) end,
        -- the library asks for a refresh on show and on every size change; lines only re-wrap when
        -- the content width actually changed (a plain show already rebuilds below)
        onRefresh = function()
            if Lockouts.minimized or not Lockouts.content then
                return
            end
            if Lockouts.content:GetWidth() ~= Lockouts.builtWidth then
                Lockouts:ScheduleRebuild()
            end
        end,
    })
    self.window = win
    local panel = win:GetFrame()
    panel:SetScript("OnDragStart", function(frame)
        if Lockouts:IsLocked() then
            return
        end
        frame:StartMoving()
    end)
    panel:SetScript("OnDragStop", function(frame)
        frame:StopMovingOrSizing()
        Lockouts:SaveWidgetState()
    end)
    -- the medallion: hover hint, right-click menu, drag moves the panel
    local emblem = win.GetEmblem and win:GetEmblem()
    if emblem and pluginShared.Launcher then
        pluginShared.Launcher:AttachMedallion(emblem, panel, function() return Lockouts:IsLocked() end, function() Lockouts:SaveWidgetState() end)
    end
    if win._grip then
        win._grip:SetScript("OnMouseDown", function()
            if not Lockouts:IsLocked() then
                panel:StartSizing("BOTTOMRIGHT")
            end
        end)
    end
    panel:HookScript("OnSizeChanged", function(_, _, height)
        if not Lockouts.minimized and type(height) == "number" and height > MINIMIZED_HEIGHT then
            Lockouts.expandedHeight = height
        end
        Lockouts:LayoutToolbar()
    end)
    local minButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    minButton:SetSize(24, 18)
    minButton:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -28, -8)
    minButton:SetText("-")
    minButton:SetScript("OnClick", function()
        Lockouts:ToggleMinimized()
    end)
    self.minButton = minButton
    if self.ApplyFont then
        self:ApplyFont(minButton:GetFontString(), "control")
    end
    local search = CreateFrame("EditBox", "AppearanceMaticLockoutSearch", panel, "SearchBoxTemplate")
    search:SetSize(140, 20) -- width set by LayoutToolbar
    search:SetPoint("TOPLEFT", panel, "TOPLEFT", TOOLBAR_LEFT, TOOLBAR_TOP)
    search:SetAutoFocus(false)
    search:SetScript("OnTextChanged", function(box, userInput)
        -- keep the template behaviour (placeholder hides, clear button shows) that SetScript replaced
        if type(SearchBoxTemplate_OnTextChanged) == "function" then
            SearchBoxTemplate_OnTextChanged(box)
        end
        -- the template's clear (X) button sets the text programmatically, so do not gate on userInput
        local text = box:GetText() or ""
        if text == (Lockouts.searchText or "") then return end
        Lockouts.searchText = text
        Lockouts:ScheduleRebuild()
    end)
    search:SetScript("OnEscapePressed", function(box)
        box:SetText("")
        box:ClearFocus()
        Lockouts.searchText = ""
        Lockouts:ScheduleRebuild()
    end)
    search:SetScript("OnEnterPressed", function(box) box:ClearFocus() end)
    if search.SetTextColor then
        search:SetTextColor(CREAM[1], CREAM[2], CREAM[3])
    end
    self.searchBox = search
    if self.ApplyFont then
        self:ApplyFont(search, "control")
    end
    local charDropdown = CreateFrame("DropdownButton", "AppearanceMaticLockoutCharacters", panel, "WowStyle1DropdownTemplate")
    charDropdown:SetPoint("LEFT", search, "RIGHT", TOOLBAR_GAP, 0)
    charDropdown:SetWidth(120) -- width set by LayoutToolbar
    if charDropdown.SetDefaultText then charDropdown:SetDefaultText(L["lock_this_character"]) end
    charDropdown:SetupMenu(function(_, root)
        local function isSelected(view) return Lockouts:CharacterView() == view end
        local function select(view) Lockouts:SetCharacterView(view) end
        root:CreateRadio(L["lock_this_character"], isSelected, select, nil)
        root:CreateRadio(L["lock_all_characters"], isSelected, select, "all")
        local current = Lockouts:CurrentCharKey()
        local others = {}
        for _, charKey in ipairs(Lockouts:SortedCharacterKeys() or {}) do
            if charKey ~= current then others[#others + 1] = charKey end
        end
        if #others > 0 then
            -- root:CreateDivider() (MenuTemplates.lua:528), root:CreateCheckbox(text, isSelected,
            -- onSelect, data) (MenuTemplates.lua:330) and elementDescription:SetEnabled(isEnabled)
            -- (Menu.lua:830) all confirmed against the local Blizzard_Menu source and the
            -- wow-api MCP's search_framexml.
            root:CreateDivider()
            local charactersEnabled = Lockouts:CharacterView() == nil
            local function isExtraSelected(charKey)
                return Lockouts.extraChars ~= nil and Lockouts.extraChars[charKey] == true
            end
            local function toggleExtra(charKey)
                Lockouts:SetExtraCharacter(charKey, not isExtraSelected(charKey))
            end
            for _, charKey in ipairs(others) do
                local checkbox = root:CreateCheckbox(Lockouts:CharacterLabel(charKey), isExtraSelected, toggleExtra, charKey)
                checkbox:SetEnabled(charactersEnabled)
            end
        end
    end)
    if type(charDropdown.Text) == "table" and charDropdown.Text.SetTextColor then
        charDropdown.Text:SetTextColor(CREAM[1], CREAM[2], CREAM[3])
    end
    self.charDropdown = charDropdown
    if self.ApplyFont and charDropdown.Text then
        self:ApplyFont(charDropdown.Text, "control")
    end
    self:LayoutToolbar()
    -- the show boxes: current expansion, legacy, vendors, mounts, other, left to right on their own strip
    local boxes = {}
    local previous
    for _, key in ipairs(SHOW_KEYS) do
        local box = CreateFrame("CheckButton", nil, panel, "UICheckButtonTemplate")
        box:SetSize(FILTER_BOX, FILTER_BOX)
        box.showKey = key
        if previous then
            box:SetPoint("LEFT", type(previous.Text) == "table" and previous.Text or previous, "RIGHT", FILTER_GAP, 0)
        else
            box:SetPoint("TOPLEFT", panel, "TOPLEFT", FILTER_LEFT, FILTER_TOP)
        end
        if type(box.Text) == "table" then
            box.Text:ClearAllPoints()
            box.Text:SetPoint("LEFT", box, "RIGHT", 2, 0)
            if self.ApplyFont then
                self:ApplyFont(box.Text, "control")
            end
        end
        box:SetScript("OnClick", function(b)
            Lockouts:SetShow(b.showKey, b:GetChecked() and true or false)
        end)
        box:SetScript("OnEnter", function(b)
            GameTooltip:SetOwner(b, "ANCHOR_TOP")
            GameTooltip:SetText(Lockouts:FilterBoxLabel(b.showKey))
            GameTooltip:AddLine(Lockouts:FilterBoxHint(b.showKey), 0.8, 0.8, 0.8, true)
            GameTooltip:Show()
        end)
        box:SetScript("OnLeave", GameTooltip_Hide)
        boxes[key] = box
        previous = box
    end
    self.filterBoxes = boxes
    self:UpdateFilterBoxes()
    local scroll = win:GetScroll()
    scroll:SetPoint("TOPLEFT", panel, "TOPLEFT", 8, -CONTENT_TOP)
    local content = win:GetContent()
    content:EnableMouse(true)
    if content.SetHyperlinksEnabled then
        content:SetHyperlinksEnabled(true)
    end
    content:SetScript("OnHyperlinkEnter", function(_, link)
        GameTooltip:SetOwner(content, "ANCHOR_CURSOR")
        if Lockouts:IsRaidTitleLink(link) then
            Lockouts:FillRaidTooltip(GameTooltip, link:sub(10))
            GameTooltip:Show()
            return
        end
        if type(link) == "string" and link:sub(1, 7) == "w2tswp:" then
            GameTooltip:SetText(L["lock_guide_hint_waypoint"])
            GameTooltip:Show()
            return
        end
        if link == "w2tsrouteclear" then
            GameTooltip:SetText(L["lock_route_clear"])
            GameTooltip:AddLine(L["lock_route_clear_hint"], 0.8, 0.8, 0.8, true)
            GameTooltip:Show()
            return
        end
        if type(link) == "string" and link:sub(1, 14) == "w2tsrouteraid:" then
            local raidName = link:match("^w2tsrouteraid:(.+)$")
            local routeList = pluginShared.RouteList
            local onRoute = routeList and raidName and routeList:HasRaid(raidName)
            GameTooltip:SetText(onRoute and L["route_tooltip_remove"] or L["route_tooltip_add"])
            GameTooltip:AddLine(L["lock_route_raid_hint"], 0.8, 0.8, 0.8, true)
            GameTooltip:Show()
            return
        end
        if type(link) == "string" and link:sub(1, 11) == "w2tsunstar:" then
            GameTooltip:SetText(L["lock_unstar_hint"])
            GameTooltip:Show()
            return
        end
        if type(link) == "string" and link:sub(1, 10) == "w2tsroute:" then
            local kind, id = link:match("^w2tsroute:(%a):(%d+)")
            local routeList = pluginShared.RouteList
            local onRoute = routeList and kind and routeList:Has(kind == "m", tonumber(id))
            GameTooltip:SetText(onRoute and L["route_tooltip_remove"] or L["route_tooltip_add"])
            GameTooltip:AddLine(L["route_tooltip_desc"], 0.8, 0.8, 0.8, true)
            GameTooltip:Show()
            return
        end
        if type(link) == "string" and link:sub(1, 9) == "w2tschar:" then
            local raidName, bossName = link:match("^w2tschar:(.-):(.*)$")
            if raidName then
                Lockouts:FillCharacterTooltip(GameTooltip, raidName, bossName)
                GameTooltip:Show()
            end
            return
        end
        local ok = pcall(GameTooltip.SetHyperlink, GameTooltip, link)
        if ok then
            GameTooltip:Show()
            Lockouts:ShowHoverPreview(link)
        end
    end)
    content:SetScript("OnHyperlinkLeave", function()
        GameTooltip:Hide()
        Lockouts:HideHoverPreview()
    end)
    content:SetScript("OnHyperlinkClick", function(_, link, text, button)
        if Lockouts:IsRaidTitleLink(link) then
            -- the name only carries the hover tooltip; the route control beside it is the click
            return
        end
        if Lockouts:HandleRouteLink(link) then
            return
        end
        if Lockouts:HandleUnstarLink(link) then
            return
        end
        if Lockouts:HandleTrackLink(link) then
            return
        end
        if Lockouts:HandleWaypointLink(link) then
            return
        end
        if type(SetItemRef) == "function" then
            SetItemRef(link, text, button)
        end
    end)
    self.scroll = scroll
    panel:HookScript("OnShow", function()
        if not Lockouts.minimized then
            Lockouts:Rebuild()
        end
        Lockouts:SaveWidgetState()
    end)
    panel:HookScript("OnHide", function()
        Lockouts:SaveWidgetState()
        if Lockouts.HideLoadingCover then
            Lockouts:HideLoadingCover()
        end
    end)
    panel:Hide()
    self.panel = panel
    self.content = content
    self.lines = {}
    self.lineCount = 0
    self.contentHeight = 8
    return panel
end

function Lockouts:ToggleWidget()
    local panel = self:EnsurePanel()
    if not panel then
        return
    end
    if panel:IsShown() then
        panel:Hide()
    else
        panel:Show()
    end
end

function Lockouts:Toggle()
    self:ToggleWidget()
end

function Lockouts:OnFavoritesChanged()
    if self.panel and self.panel:IsShown() and not self.minimized then
        self:ScheduleRebuild()
    end
end

-- A model beside the tooltip: the item worn on your character, or the mount itself.
local DRESS_SCENE_ID = 596
-- tuned in game: weapons turn slightly left, shields and off-hands to the right
local PREVIEW_YAW = { weapon = math.rad(-5), shield = math.rad(-105), offhand = math.rad(-80) }

-- Blizzard's own comparison tooltips sit against the item tooltip; the model goes past them
local SIDE_TOOLTIPS = { "ShoppingTooltip1", "ShoppingTooltip2" }

-- A picture, never a control: nothing on it is clicked, hovered or scrolled, so it hands the mouse
-- straight through to whatever it happens to be covering.
local function noMouse(region)
    if type(region) ~= "table" then
        return
    end
    if type(region.EnableMouse) == "function" then region:EnableMouse(false) end
    if type(region.EnableMouseWheel) == "function" then region:EnableMouseWheel(false) end
    if type(region.SetMouseClickEnabled) == "function" then region:SetMouseClickEnabled(false) end
    if type(region.SetMouseMotionEnabled) == "function" then region:SetMouseMotionEnabled(false) end
end

-- One model per tooltip: the chat-link tooltip stays open while the cursor tooltip comes and goes,
-- so a single shared frame would be taken from whichever tooltip is still on screen.
function Lockouts:EnsureHoverPreview(anchor)
    anchor = anchor or GameTooltip
    self.hoverPreviews = self.hoverPreviews or {}
    local existing = self.hoverPreviews[anchor]
    if existing then
        return existing
    end
    self.hoverPreviewCount = (self.hoverPreviewCount or 0) + 1
    local index = self.hoverPreviewCount
    local frame = CreateFrame("Frame", "AppearanceMaticLockoutPreview" .. (index > 1 and index or ""), UIParent, "BackdropTemplate")
    frame:SetSize(230, 320)
    frame:SetFrameStrata("TOOLTIP")
    -- Blizzard anchors a bag item's tooltip up and to the item's left once the item sits on the right
    -- half of the screen (ContainerFrame.lua:1447-1456), so "past the tooltip's right edge", where this
    -- goes, is the bag icons themselves. A frame there that takes the mouse takes it off the button
    -- underneath: that button's leave hides the tooltip, the tooltip's hide takes this down, the cursor
    -- lands back on the button and it all starts again, many times a second.
    noMouse(frame)
    -- the panel is taller than most tooltips: keep it on screen when the tooltip sits low or high
    if type(frame.SetClampedToScreen) == "function" then
        frame:SetClampedToScreen(true)
    end
    frame:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 16, edgeSize = 16, insets = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    frame:SetBackdropColor(0, 0, 0, 0.9)
    -- ModelSceneMixinTemplate is Blizzard's interactive variant: it inherits the plain scene and adds
    -- OnEnter, OnLeave, OnMouseUp, OnMouseDown and OnMouseWheel (ModelSceneMixin.xml:9-17), which is
    -- what was reaching for the mouse over the bags.
    local scene = CreateFrame("ModelScene", nil, frame, "ModelSceneMixinTemplate")
    noMouse(scene)
    scene:SetPoint("TOPLEFT", 6, -6)
    scene:SetPoint("BOTTOMRIGHT", -6, 6)
    frame.scene = scene
    frame:Hide()
    self.hoverPreviews[anchor] = frame
    return frame
end

-- How far past the tooltip a side is already taken by Blizzard's comparison tooltips
local function edgeOf(frame, toRight)
    if type(frame) ~= "table" or type(frame.IsShown) ~= "function" then
        return nil
    end
    local okShown, isShown = pcall(frame.IsShown, frame)
    if not (okShown and isShown) then
        return nil
    end
    local ok, edge = pcall(toRight and frame.GetRight or frame.GetLeft, frame)
    return (ok and type(edge) == "number") and edge or nil
end

function Lockouts:PreviewSideOffset(anchor, toRight)
    local own = edgeOf(anchor, toRight)
    if not own then
        return 0, nil
    end
    local outer = own
    for _, name in ipairs(SIDE_TOOLTIPS) do
        local edge = edgeOf(_G[name], toRight)
        if edge and ((toRight and edge > outer) or (not toRight and edge < outer)) then
            outer = edge
        end
    end
    local gap = toRight and (outer - own) or (own - outer)
    if gap < 0 then
        gap = 0
    end
    return gap, outer
end

function Lockouts:ShowHoverPreview(link, anchor)
    if type(link) ~= "string" then return end
    anchor = anchor or GameTooltip
    local frame = self:EnsureHoverPreview(anchor)
    -- item tooltips run their post call again on every refresh; the same item on the same tooltip
    -- keeps the model it already has instead of rebuilding the scene under the cursor
    local profile = pluginShared.db and pluginShared.db.profile
    local naked = profile and profile.generalSettings and profile.generalSettings.hoverPreviewNaked == true
    local key = link .. "\1" .. tostring(naked) .. "\1" .. tostring(self.hoverPreviewGeneration or 0)
    if frame.previewKey == key then
        if frame.previewRefused then
            -- asking again cannot change the answer, and item tooltips run their post call on every
            -- refresh: nothing to redo for something that cannot be worn
            return
        end
        -- The model this frame holds is still the right one, so it goes back up as it is. Blizzard
        -- re-owns a bag item's tooltip five times a second (ContainerFrame.lua:1479 hands every bag
        -- button an UpdateTooltip, GameTooltip.lua:452-456 calls it on the tooltip's interval, and
        -- ContainerFrame.lua:1539 opens that call with GameTooltip:SetOwner, which takes the tooltip
        -- down before SetBagItem puts it back up), so the frame is usually down by the time the refresh
        -- reaches here. Building the scene again on each of those was the strobe. A comparison tooltip
        -- may have opened or grown meanwhile, so it is placed again either way.
        self:PlaceHoverPreview(frame, anchor)
        frame:Show()
        return
    end
    frame.previewKey = key
    frame.previewRefused = nil
    local scene = frame.scene
    local immediate = CAMERA_TRANSITION_TYPE_IMMEDIATE or 1
    local discard = CAMERA_MODIFICATION_TYPE_DISCARD or 1
    local shown, pending = false, false
    local mountSpell = link:match("^mount:(%d+)")
    if mountSpell and C_MountJournal then
        local okID, mountID = pcall(C_MountJournal.GetMountFromSpell, tonumber(mountSpell))
        local okExtra, displayID, _, _, _, _, mountSceneID = false, nil
        if okID and mountID then
            okExtra, displayID, _, _, _, _, mountSceneID = pcall(C_MountJournal.GetMountInfoExtraByID, mountID)
        end
        if okExtra and type(displayID) == "number" and displayID > 0 then
            reported(scene.TransitionToModelSceneID, scene, mountSceneID or DRESS_SCENE_ID, immediate, discard, true)
            local actor = scene.GetActorByTag and scene:GetActorByTag("unwrapped") or nil
            if not actor and scene.GetPlayerActor then actor = scene:GetPlayerActor() end
            if actor then
                reported(actor.SetModelByCreatureDisplayID, actor, displayID)
                shown = true
            end
        end
    elseif link:match("^item:") then
        -- The cheap question first. Bags and vendors are hovered more than anything else in the game
        -- and most of what is in them is not equipment: a potion is refused here for the price of one
        -- lookup, before any scene or model work, rather than dressed and tried on to find that out.
        -- This is the same call the weapon angle needs below, so it is one decider, not a second gate.
        local T = Enum and Enum.InventoryType
        local invType
        if C_Item and type(C_Item.GetItemInventoryTypeByID) == "function" then
            local okType, found = pcall(C_Item.GetItemInventoryTypeByID, link)
            if okType then invType = found end
        end
        local wearable = not (T and T.IndexNonEquipType and invType == T.IndexNonEquipType)
        -- the scene is set up before its actor is asked for: there is no actor to hand out until then
        if wearable then
            reported(scene.TransitionToModelSceneID, scene, DRESS_SCENE_ID, immediate, discard, true)
        end
        local actor = wearable and scene.GetPlayerActor and scene:GetPlayerActor() or nil
        if actor then
            -- the scene's own facing is "front"; remember it before any weapon angle is applied
            if frame.baseYaw == nil then
                local okYaw, baseYaw = pcall(actor.GetYaw, actor)
                frame.baseYaw = (okYaw and type(baseYaw) == "number") and baseYaw or 0
            end
            -- like the wardrobe preview: dressed in your own gear with the piece tried on over it,
            -- weapons held rather than sheathed so they are visible
            local sheathe, autoDress, hideWeapons = false, not naked, false
            reported(actor.SetModelByUnit, actor, "player", sheathe, autoDress, hideWeapons)
            if naked then
                reported(actor.Undress, actor)
            elseif not (pluginShared.Preview and pluginShared.Preview.ShowTabard and pluginShared.Preview:ShowTabard()) then
                reported(actor.UndressSlot, actor, INVSLOT_TABARD or 19)
            end
            -- same angles as the wardrobe preview: main hand turned one way, off hand and shields the other
            local yaw, handSlot = frame.baseYaw or 0, nil
            if T and invType then
                do
                    if invType == T.IndexWeaponType or invType == T.IndexWeaponmainhandType then
                        -- one-handers always in the right hand
                        yaw, handSlot = PREVIEW_YAW.weapon, "MAINHANDSLOT"
                    elseif invType == T.Index2HweaponType or invType == T.IndexRangedType or invType == T.IndexRangedrightType then
                        yaw = PREVIEW_YAW.weapon
                    elseif invType == T.IndexShieldType then
                        yaw, handSlot = PREVIEW_YAW.shield, "SECONDARYHANDSLOT"
                    elseif invType == T.IndexWeaponoffhandType or invType == T.IndexHoldableType then
                        yaw, handSlot = PREVIEW_YAW.offhand, "SECONDARYHANDSLOT"
                    end
                end
            end
            pcall(actor.SetYaw, actor, yaw)
            local sourceID
            if C_TransmogCollection and type(C_TransmogCollection.GetItemInfo) == "function" then
                local ok, _, modifiedAppearanceID = pcall(C_TransmogCollection.GetItemInfo, link)
                if ok and type(modifiedAppearanceID) == "number" and modifiedAppearanceID > 0 then sourceID = modifiedAppearanceID end
            end
            local okTry, reason = pcall(actor.TryOn, actor, sourceID or ("|H" .. link .. "|h[]|h"), handSlot)
            self.lastTryOn = tostring(sourceID) .. " -> " .. tostring(okTry) .. "/" .. tostring(reason)
            -- the try-on says why it could not be worn: a race that cannot wear it and something that is
            -- not equipment at all are settled answers, but data that has not arrived yet is not an
            -- answer at all, so that one is asked again rather than held against the item
            local why = Enum and Enum.ItemTryOnReason
            shown = okTry and (reason == nil or not why or reason == why.Success) and true or false
            pending = (not shown) and okTry and why ~= nil and reason == why.DataPending
        end
    end
    if not shown then
        -- remembered rather than forgotten, so a tooltip refreshing five times a second over a potion
        -- costs nothing; an item whose data has not landed yet is not remembered, so it is asked again
        -- on the next refresh and comes up as soon as the game can answer
        frame.previewRefused = not pending
        if pending then
            frame.previewKey = nil
        end
        frame:Hide()
        return
    end
    self:PlaceHoverPreview(frame, anchor)
    frame:Show()
end

-- Beside the tooltip and clear of its comparison tooltips, on its left when the right would run off
-- the screen. Runs on every show, including the ones that keep the model they already built.
function Lockouts:PlaceHoverPreview(frame, anchor)
    frame.anchorTip = anchor
    local rightGap, rightEdge = self:PreviewSideOffset(anchor, true)
    local okScreen, screenRight = pcall(function() return UIParent:GetRight() end)
    local point, relPoint, offset
    if okScreen and type(rightEdge) == "number" and type(screenRight) == "number"
        and rightEdge + frame:GetWidth() + 4 > screenRight then
        point, relPoint, offset = "TOPRIGHT", "TOPLEFT", -4 - self:PreviewSideOffset(anchor, false)
    else
        point, relPoint, offset = "TOPLEFT", "TOPRIGHT", 4 + rightGap
    end
    if frame.placedAt == anchor and frame.placedPoint == point and frame.placedOffset == offset then
        return
    end
    frame.placedAt, frame.placedPoint, frame.placedOffset = anchor, point, offset
    frame:ClearAllPoints()
    frame:SetPoint(point, anchor, relPoint, offset, 0)
end

-- The item post call runs while the tooltip is still being built, so the rect the side is chosen
-- from has not settled when the model is first placed. A bag item survives a bad first guess because
-- Blizzard re-runs the whole tooltip five times a second (ContainerFrame.lua:1479), but a bank item's
-- tooltip is set once (BankFrame.lua:370-371, and that file has no UpdateTooltip at all), so its first
-- guess was the only one it ever got. The tooltip says when it has settled: its OnShow and
-- OnSizeChanged (GameTooltip.xml:96) run this, and so do the comparison tooltips beside it coming
-- and going. Given a tooltip, only its model is placed again; given none, every model on screen.
function Lockouts:SettleHoverPreview(tooltip)
    local frames = self.hoverPreviews
    if not frames then
        return
    end
    if tooltip then
        local frame = frames[tooltip]
        if frame and frame.anchorTip and frame:IsShown() then
            self:PlaceHoverPreview(frame, frame.anchorTip)
        end
        return
    end
    for _, frame in pairs(frames) do
        if frame.anchorTip and frame:IsShown() then
            self:PlaceHoverPreview(frame, frame.anchorTip)
        end
    end
end

-- Hides the model belonging to that tooltip; with no tooltip, every model
function Lockouts:HideHoverPreview(tooltip)
    local frames = self.hoverPreviews
    if not frames then
        return
    end
    if tooltip then
        local frame = frames[tooltip]
        if frame then
            frame:Hide()
        end
        return
    end
    for _, frame in pairs(frames) do
        frame:Hide()
    end
end

-- What a frame holds stays true while it is hidden, so the key it was built under is kept; the one
-- input to it that is not in that key is the player's own gear, which the model is dressed in. When
-- that changes every model already built is stale and the next hover builds again.
function Lockouts:ForgetHoverPreviews()
    self.hoverPreviewGeneration = (self.hoverPreviewGeneration or 0) + 1
end

function Lockouts:Init()
    -- before the login timers that would start guiding: a switch left off stays off
    if self.LoadRoutePause then
        self:LoadRoutePause()
    end
    self:EnsureDropData()
    local panel = self:EnsurePanel()
    if not panel then
        return
    end
    local widget = AppearanceMaticDB and AppearanceMaticDB.widget
    self:LoadExtraChars()
    local savedMinimized = widget and widget.minimized or false
    local savedShown = true
    if widget ~= nil then
        savedShown = widget.shown ~= false
    end
    if widget and widget.point then
        panel:ClearAllPoints()
        panel:SetPoint(widget.point, UIParent, widget.relPoint or widget.point, widget.x or 0, widget.y or 0)
    end
    panel:SetScale(self:GetScale())
    panel:SetAlpha(self:GetAlpha())
    self:ApplyMinimized(savedMinimized)
    if savedShown then
        panel:Show()
    else
        panel:Hide()
    end
end

function Lockouts:InstallHeaderButton()
    if self.headerButton or type(CreateFrame) ~= "function" then
        return
    end
    local wardrobe = WardrobeCollectionFrame
    local searchBox = wardrobe and wardrobe.SearchBox
    if not searchBox then
        return
    end
    local button = CreateFrame("Button", nil, wardrobe, "UIPanelButtonTemplate")
    button:SetSize(78, 22)
    button:SetPoint("RIGHT", searchBox, "LEFT", -8, 0)
    button:SetFrameLevel((searchBox:GetFrameLevel() or 1) + 1)
    button:SetText(L["lock_button_label"])
    button:SetScript("OnClick", function()
        Lockouts:ToggleWidget()
    end)
    button:SetScript("OnEnter", function(owner)
        GameTooltip:SetOwner(owner, "ANCHOR_TOP")
        GameTooltip:SetText(L["lock_button_label"])
        GameTooltip:AddLine(L["lock_button_tooltip_desc"], 1, 1, 1, true)
        GameTooltip:AddLine(L["lock_button_tooltip_slash"], 0.8, 0.8, 0.8, true)
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", GameTooltip_Hide)
    self.headerButton = button
end
