-- ============================================================================
-- Vamoose's Companion - Model Viewer
-- Tabbed model browser per companion. Open with /vc models
-- Click a model card to probe + show available animations.
-- ============================================================================

VC = VC or {}
VC.ModelViewer = {}

local WHITE = "Interface\\Buttons\\WHITE8x8"

-- Animation IDs to probe on each model (name lookup for known ones)
-- Modern AnimationData IDs (verified from wow.export AnimMapper.js, 2026-04-06)
local ANIM_NAMES = {
    [0]="Stand",[1]="Death",[2]="Spell",[3]="Stop",[4]="Walk",[5]="Run",
    [6]="Dead",[7]="Rise",[8]="StandWound",[9]="CombatWound",[10]="CombatCritical",
    [11]="ShuffleLeft",[12]="ShuffleRight",[13]="WalkBack",[14]="Stun",
    [15]="HandsClosed",[16]="AttackUnarmed",[17]="Attack1H",[18]="Attack2H",
    [19]="Attack2HL",[20]="ParryUnarmed",[21]="Parry1H",[22]="Parry2H",
    [23]="Parry2HL",[24]="ShieldBlock",[25]="ReadyUnarmed",[26]="Ready1H",
    [27]="Ready2H",[28]="Ready2HL",[29]="ReadyBow",[30]="Dodge",
    [31]="SpellPrecast",[32]="SpellCast",[33]="SpellCastDirected",
    [34]="SpellHit",[35]="SpellMiss",[36]="SpellChannelStart",
    [37]="JumpStart",[38]="Jump",[39]="JumpEnd",[40]="Fall",
    [41]="SwimIdle",[42]="Swim",[43]="SwimLeft",[44]="SwimRight",
    [45]="SwimBack",[46]="AttackBow",[47]="FireBow",[48]="ReadyRifle",
    [49]="AttackRifle",[50]="Loot",[51]="ReadySpellDirected",
    [52]="ReadySpellOmni",[53]="SpellCastDirected",[54]="SpellCastOmni",
    [55]="BattleRoar",[56]="ReadyAbility",[57]="Special1H",
    [58]="Special2H",[59]="ShieldBash",
    [60]="Talk",[61]="Eat",[62]="Work",[63]="UseStanding",
    [64]="Exclamation",[65]="Question",[66]="Bow",[67]="Wave",
    [68]="Cheer",[69]="Dance",[70]="Laugh",[71]="Sleep",[72]="SitGround",
    [73]="Rude",[74]="Roar",[75]="Kneel",[76]="Kiss",[77]="Cry",
    [78]="Chicken",[79]="Beg",[80]="Applaud",[81]="Shout",[82]="Flex",
    [83]="Shy",[84]="Point",
    [85]="Attack1HPierce",[86]="Attack2HLoosePierce",[87]="AttackOff",
    [88]="AttackOffPierce",[89]="Sheath",[90]="HipSheath",[91]="Mount",
    [92]="RunRight",[93]="RunLeft",[94]="MountSpecial",
    [95]="Kick",[96]="SitGroundDown",[97]="SitGround",[98]="SitGroundUp",
    [99]="SleepDown",[100]="Sleep",
    [110]="Knockdown",[111]="Knockdown2",[113]="Whirlwind",
    [135]="Fly",[136]="FlyBack",[137]="FlyLeft",[138]="FlyRight",
    [148]="Emerge",[158]="FlyCombat",[170]="SpellCastStrong",
    [188]="LootHold",[193]="Hover",[225]="Cower",
    [234]="FlyRun",
}
local MAX_PROBE_ID = 250

-- Sorted companion key order for tabs
local TAB_ORDER = { "scurry", "patches", "vamoose", "gazlowe", "dash", "vexia", "ember", "grimble", "breezy", "torque" }

-- ============================================================================
-- BUILD
-- ============================================================================

function VC.ModelViewer:Toggle()
    if self.frame then
        self.frame:SetShown(not self.frame:IsShown())
        return
    end
    self:Build()
end

function VC.ModelViewer:Build()
    local W, H = 1000, 750
    local c = VC.Theme:GetScheme()

    local f = CreateFrame("Frame", "VC_ModelViewer", UIParent, "BackdropTemplate")
    f:SetSize(W, H)
    f:SetPoint("CENTER")
    f:SetFrameStrata("MEDIUM")
    f:SetMovable(true)
    f:SetClampedToScreen(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)
    f:SetBackdrop(VC.Theme.BACKDROP_PANEL)
    f:SetBackdropColor(c.bg.r, c.bg.g, c.bg.b, c.bg.a)
    f:SetBackdropBorderColor(c.border.r, c.border.g, c.border.b, 1)
    tinsert(UISpecialFrames, "VC_ModelViewer")
    self.frame = f

    -- Title
    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetPoint("TOPLEFT", 10, -8)
    title:SetFont("Fonts\\MORPHEUS.ttf", 14, "")
    title:SetTextColor(c.text_header.r, c.text_header.g, c.text_header.b, 1)
    title:SetText("Model Viewer")

    -- Close button
    local closeBtn = CreateFrame("Button", nil, f)
    closeBtn:SetSize(16, 16)
    closeBtn:SetPoint("TOPRIGHT", -6, -8)
    local closeIcon = closeBtn:CreateTexture(nil, "OVERLAY")
    closeIcon:SetAllPoints()
    closeIcon:SetAtlas("XMarksTheSpot")
    closeIcon:SetVertexColor(c.text_dim.r, c.text_dim.g, c.text_dim.b, 1)
    closeBtn:SetScript("OnClick", function() f:Hide() end)
    closeBtn:SetScript("OnEnter", function()
        closeIcon:SetVertexColor(c.text_header.r, c.text_header.g, c.text_header.b, 1)
    end)
    closeBtn:SetScript("OnLeave", function()
        closeIcon:SetVertexColor(c.text_dim.r, c.text_dim.g, c.text_dim.b, 1)
    end)

    -- =====================================================================
    -- Companion tabs
    -- =====================================================================
    local TAB_Y = -28
    self._companionTabs = {}
    local tabX = 8
    for _, key in ipairs(TAB_ORDER) do
        local profile = VC_CompanionProfiles and VC_CompanionProfiles[key]
        if profile and profile.models and #profile.models > 0 then
            local btn = VC.UI:CreateTabButton(f, profile.name, function()
                self:SelectCompanion(key)
            end)
            local fs = btn:GetFontString()
            local textW = fs and fs:GetStringWidth() or 50
            local btnW = math.max(50, math.floor(textW + 20))
            btn:SetWidth(btnW)
            btn:SetPoint("TOPLEFT", tabX, TAB_Y)
            self._companionTabs[key] = btn
            tabX = tabX + btnW + 2
        end
    end

    -- Tab separator
    local tabSep = f:CreateTexture(nil, "ARTWORK")
    tabSep:SetHeight(1)
    tabSep:SetPoint("TOPLEFT", 0, TAB_Y - 28)
    tabSep:SetPoint("TOPRIGHT", 0, TAB_Y - 28)
    tabSep:SetTexture(WHITE)
    tabSep:SetVertexColor(c.border.r, c.border.g, c.border.b, 0.6)

    -- =====================================================================
    -- Animation panel (dynamic, built on model click)
    -- =====================================================================
    local animTop = TAB_Y - 34
    local animHeader = f:CreateFontString(nil, "OVERLAY")
    animHeader:SetPoint("TOPLEFT", 10, animTop)
    animHeader:SetFont("Fonts\\ARIALN.TTF", 10, "")
    animHeader:SetTextColor(c.text_dim.r, c.text_dim.g, c.text_dim.b, 1)
    animHeader:SetText("Click a model to probe animations")
    self._animHeader = animHeader

    -- Container for dynamic anim buttons (height set dynamically in BuildAnimButtons)
    local animContainer = CreateFrame("Frame", nil, f)
    animContainer:SetPoint("TOPLEFT", 10, animTop - 14)
    animContainer:SetPoint("TOPRIGHT", -10, animTop - 14)
    animContainer:SetHeight(22) -- minimum 1 row, grows dynamically
    self._animContainer = animContainer
    self._animButtons = {}

    -- Custom displayID input (anchored below anim container)
    local customLabel = f:CreateFontString(nil, "OVERLAY")
    customLabel:SetPoint("TOPLEFT", animContainer, "BOTTOMLEFT", 0, -4)
    customLabel:SetFont("Fonts\\ARIALN.TTF", 10, "")
    customLabel:SetText("Add ID:")
    customLabel:SetTextColor(c.text_dim.r, c.text_dim.g, c.text_dim.b, 1)

    local customEB = CreateFrame("EditBox", nil, f, "InputBoxTemplate")
    customEB:SetSize(80, 20)
    customEB:SetPoint("LEFT", customLabel, "RIGHT", 4, 0)
    customEB:SetAutoFocus(false)
    customEB:SetNumeric(true)
    customEB:SetScript("OnEnterPressed", function(eb)
        local id = tonumber(eb:GetText())
        if id and id > 0 and self._activeCompanion then
            local profile = VC_CompanionProfiles[self._activeCompanion]
            if profile and profile.models then
                local exists = false
                for _, did in ipairs(profile.models) do
                    if did == id then exists = true; break end
                end
                if not exists then
                    profile.models[#profile.models + 1] = id
                end
                self:BuildGrid()
                if VC.Store:GetState().config.debug then print("|cFFA0522D[VC]|r Added displayID " .. id .. " to " .. profile.name) end
            end
        end
        eb:SetText("")
        eb:ClearFocus()
    end)

    -- Model count label
    local countLabel = f:CreateFontString(nil, "OVERLAY")
    countLabel:SetPoint("LEFT", customEB, "RIGHT", 12, 0)
    countLabel:SetFont("Fonts\\ARIALN.TTF", 10, "")
    countLabel:SetTextColor(c.text_dim.r, c.text_dim.g, c.text_dim.b, 1)
    self._countLabel = countLabel

    -- Probe All button
    local probeAllBtn = VC.UI:CreateButton(f, "Probe All", 70, 20, function()
        self:ProbeAllAndExport()
    end)
    probeAllBtn:SetPoint("LEFT", countLabel, "RIGHT", 12, 0)

    -- =====================================================================
    -- Scroll frame for model grid
    -- =====================================================================
    local scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", customLabel, "BOTTOMLEFT", -10, -6)
    scroll:SetPoint("BOTTOMRIGHT", -24, 4)

    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(W - 30, 1)
    scroll:SetScrollChild(content)
    self._scrollContent = content

    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(sf, delta)
        local cur = sf:GetVerticalScroll()
        local maxS = math.max(0, content:GetHeight() - sf:GetHeight())
        sf:SetVerticalScroll(math.max(0, math.min(maxS, cur - delta * 50)))
    end)
    self._scroll = scroll

    VC.UI.StyleScrollBar(scroll)

    -- Select first tab
    self:SelectCompanion(TAB_ORDER[1])
    f:Show()
end

-- ============================================================================
-- TAB SELECTION
-- ============================================================================

function VC.ModelViewer:SelectCompanion(key)
    self._activeCompanion = key
    self._selectedModel = nil
    self._selectedDisplayID = nil
    for k, btn in pairs(self._companionTabs) do
        VC.UI:SetTabActive(btn, k == key)
    end
    self:ClearAnimButtons()
    if self._animHeader then
        self._animHeader:SetText("Click a model to probe animations")
    end
    self:BuildGrid()
end

-- ============================================================================
-- ANIMATION PROBE + BUTTONS
-- ============================================================================

function VC.ModelViewer:ProbeAnimations(model)
    local results = {}
    local seen = {}

    -- Scan 0-300: only include IDs that HasAnimation confirms
    for id = 0, 300 do
        local ok, has = pcall(model.HasAnimation, model, id)
        if ok and has then
            local name = ANIM_NAMES[id] or ("#" .. id)
            results[#results + 1] = { id = id, name = name, confirmed = true }
            seen[id] = true
        end
    end

    -- Also include all named animations (dim, clickable) even if not confirmed
    for id, name in pairs(ANIM_NAMES) do
        if not seen[id] then
            results[#results + 1] = { id = id, name = name, confirmed = false }
        end
    end

    table.sort(results, function(a, b) return a.id < b.id end)
    return results
end

function VC.ModelViewer:ClearAnimButtons()
    for _, btn in ipairs(self._animButtons or {}) do
        btn:Hide()
    end
    self._animButtons = {}
    self._animBtnPool = self._animBtnPool or {}
end

function VC.ModelViewer:BuildAnimButtons(model, displayID)
    self:ClearAnimButtons()
    local c = VC.Theme:GetScheme()

    local results = self:ProbeAnimations(model)
    local confirmedCount = 0
    for _, r in ipairs(results) do
        if r.confirmed then confirmedCount = confirmedCount + 1 end
    end

    self._animHeader:SetText("ID " .. displayID .. " -- " .. confirmedCount .. " confirmed, " .. #results .. " total")

    local container = self._animContainer
    local BTN_H = 20
    local BTN_PAD = 2
    local containerW = container:GetWidth() or 980
    local rowX = 0
    local rowY = 0

    for i, entry in ipairs(results) do
        local btn = self._animBtnPool[i]
        if not btn then
            btn = VC.UI:CreateButton(container, "", 1, BTN_H, nil)
            self._animBtnPool[i] = btn
        end

        local btnW = math.max(#entry.name * 6 + 14, 36)
        btn:SetWidth(btnW)
        btn:SetText(entry.name)
        -- Confirmed = readable, unconfirmed = dimmer but still readable
        local textR, textG, textB, textA
        if entry.confirmed then
            textR, textG, textB, textA = c.text_dim.r, c.text_dim.g, c.text_dim.b, 1
        else
            textR, textG, textB, textA = c.text_dim.r * 0.6, c.text_dim.g * 0.6, c.text_dim.b * 0.6, 0.8
        end
        btn:GetFontString():SetTextColor(textR, textG, textB, textA)
        -- Store rest color so OnLeave restores correctly
        btn._restColor = { r = textR, g = textG, b = textB, a = textA }

        -- Flow layout: wrap to next row when button exceeds container width
        if rowX + btnW > containerW and rowX > 0 then
            rowX = 0
            rowY = rowY + BTN_H + BTN_PAD
        end
        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", rowX, -rowY)
        rowX = rowX + btnW + BTN_PAD

        -- Override hover to restore custom rest color
        btn:SetScript("OnEnter", function(self)
            self._bg:SetVertexColor(c.button_hover.r, c.button_hover.g, c.button_hover.b, c.button_hover.a)
            self:GetFontString():SetTextColor(c.text_header.r, c.text_header.g, c.text_header.b, 1)
        end)
        btn:SetScript("OnLeave", function(self)
            self._bg:SetVertexColor(c.button_normal.r, c.button_normal.g, c.button_normal.b, c.button_normal.a)
            local rc = self._restColor
            if rc then self:GetFontString():SetTextColor(rc.r, rc.g, rc.b, rc.a) end
        end)

        local aid = entry.id
        local m = model
        btn:SetScript("OnClick", function()
            -- Reset all buttons to their rest colors
            for _, ab in ipairs(self._animButtons) do
                local rc = ab._restColor
                if rc then ab:GetFontString():SetTextColor(rc.r, rc.g, rc.b, rc.a) end
            end
            -- Highlight selected
            btn:GetFontString():SetTextColor(c.accent.r, c.accent.g, c.accent.b, 1)
            btn._restColor = { r = c.accent.r, g = c.accent.g, b = c.accent.b, a = 1 }
            m:SetAnimation(aid)
        end)
        btn._entry = entry

        btn:Show()
        self._animButtons[#self._animButtons + 1] = btn
    end

    -- Resize container to fit all rows
    container:SetHeight(math.max(22, rowY + BTN_H + BTN_PAD))
end

-- ============================================================================
-- MODEL GRID
-- ============================================================================

function VC.ModelViewer:BuildGrid()
    local content = self._scrollContent
    if not content then return end
    local c = VC.Theme:GetScheme()

    -- Hide all pooled cards (reuse below)
    if self._modelCards then
        for _, mf in ipairs(self._modelCards) do
            if mf.card then mf.card:Hide() end
        end
    else
        self._modelCards = {}
    end
    self._modelFrames = {}

    local key = self._activeCompanion
    local profile = VC_CompanionProfiles and VC_CompanionProfiles[key]
    local models = profile and profile.models
    if not models then
        content:SetHeight(1)
        if self._countLabel then self._countLabel:SetText("No models") end
        return
    end

    if self._countLabel then
        self._countLabel:SetText(#models .. " models")
    end

    -- Check which displayID is currently selected for this companion
    local state = VC.Store:GetState()
    local activeDisplayID = state.config.modelOverrides[key] or (profile and profile.displayID)

    local CARD_W, CARD_H = 170, 190
    local COLS = 4
    local PAD = 8

    for i, displayID in ipairs(models) do
        local col = (i - 1) % COLS
        local row = math.floor((i - 1) / COLS)
        local x = col * (CARD_W + PAD) + PAD
        local y = -(row * (CARD_H + PAD) + PAD)

        -- Reuse pooled card+model, create only when needed
        local pooled = self._modelCards[i]
        local card, model, idText
        if pooled then
            card  = pooled.card
            model = pooled.model
            idText = pooled.idText
            card:ClearAllPoints()
            card:SetPoint("TOPLEFT", x, y)
            card:Show()
        else
            card = CreateFrame("Frame", nil, content, "BackdropTemplate")
            card:SetSize(CARD_W, CARD_H)
            card:SetPoint("TOPLEFT", x, y)
            card:SetBackdrop(VC.Theme.BACKDROP_INSET)

            model = CreateFrame("PlayerModel", nil, card)
            model:SetSize(CARD_W - 10, CARD_H - 30)
            model:SetPoint("TOP", 0, -5)
            model:SetPosition(0, 0, 0)
            model:SetFacing(0)

            idText = card:CreateFontString(nil, "OVERLAY")
            idText:SetPoint("BOTTOM", card, "BOTTOM", 0, 8)
            idText:SetFont("Fonts\\ARIALN.TTF", 9, "")

            -- Mousewheel propagation (captured once at creation)
            local sv = self
            local function PropagateWheel(_, delta)
                local cur = sv._scroll:GetVerticalScroll()
                local maxS = math.max(0, content:GetHeight() - sv._scroll:GetHeight())
                sv._scroll:SetVerticalScroll(math.max(0, math.min(maxS, cur - delta * 50)))
            end
            card:EnableMouse(true)
            card:EnableMouseWheel(true)
            card:SetScript("OnMouseWheel", PropagateWheel)
            model:EnableMouse(true)
            model:EnableMouseWheel(true)
            model:SetScript("OnMouseWheel", PropagateWheel)
            model:SetScript("OnMouseDown", function(m, btn)
                if btn == "RightButton" then m._rotating = true
                else m:GetParent():GetScript("OnMouseDown")(m:GetParent(), btn) end
            end)
            model:SetScript("OnMouseUp", function(m, btn)
                if btn == "RightButton" then m._rotating = false end
            end)
            model:SetScript("OnUpdate", function(m)
                if not m:IsShown() then return end
                if m._rotating then
                    m:SetFacing(m:GetFacing() + 0.03)
                end
            end)

            self._modelCards[i] = { card = card, model = model, idText = idText }
        end

        -- Update per-iteration data (displayID, colors, click handler)
        idText:SetText("|cFF888888" .. displayID .. "|r")

        local isSelected = (displayID == self._selectedDisplayID)
        local isActive = (displayID == activeDisplayID)

        if isSelected then
            card:SetBackdropColor(c.accent.r, c.accent.g, c.accent.b, 0.25)
            card:SetBackdropBorderColor(1, 1, 1, 1)
        elseif isActive then
            card:SetBackdropColor(c.accent.r, c.accent.g, c.accent.b, 0.15)
            card:SetBackdropBorderColor(c.accent.r, c.accent.g, c.accent.b, 1)
        else
            card:SetBackdropColor(c.panel.r, c.panel.g, c.panel.b, 1)
            card:SetBackdropBorderColor(c.border.r, c.border.g, c.border.b, 1)
        end

        pcall(model.SetDisplayInfo, model, displayID)
        model:SetAnimation(0)
        model._rotating = false

        local did = displayID
        local cKey = key
        local mdl = model
        card:SetScript("OnMouseDown", function(_, btn)
            if btn == "LeftButton" then
                -- Select this model for animation probing
                self._selectedModel = mdl
                self._selectedDisplayID = did
                self:BuildAnimButtons(mdl, did)
                self:BuildGrid() -- refresh highlight
            elseif btn == "MiddleButton" then
                -- Middle-click: set as active model override
                VC.Store:Dispatch("SET_MODEL_OVERRIDE", { companionKey = cKey, displayID = did })
                if VC.Portrait and VC.Portrait.UpdateModel then
                    VC.Portrait:UpdateModel()
                end
                self:BuildGrid()
                if VC.Store:GetState().config.debug then print("|cFFA0522D[VC]|r Set " .. (profile and profile.name or cKey) .. " model to " .. did) end
            end
        end)

        self._modelFrames[#self._modelFrames + 1] = { card = card, model = model, displayID = displayID }
    end

    local totalRows = math.ceil(#models / COLS)
    content:SetHeight(totalRows * (CARD_H + PAD) + PAD)
end

-- ============================================================================
-- PROBE ALL + EXPORT
-- ============================================================================

function VC.ModelViewer:ProbeAllAndExport()
    -- Hidden scratch model for probing (must be visible for model to load)
    if not self._probeModel then
        self._probeModel = CreateFrame("PlayerModel", nil, self.frame)
        self._probeModel:SetSize(1, 1)
        self._probeModel:SetPoint("BOTTOMRIGHT", -9999, -9999)
        self._probeModel:Show()
    end
    local probe = self._probeModel

    -- Build queue of all models to probe
    local queue = {}
    for _, companionKey in ipairs(TAB_ORDER) do
        local profile = VC_CompanionProfiles and VC_CompanionProfiles[companionKey]
        if profile and profile.models and #profile.models > 0 then
            queue[#queue + 1] = { type = "header", key = companionKey, name = profile.name }
            for _, displayID in ipairs(profile.models) do
                queue[#queue + 1] = { type = "model", key = companionKey, displayID = displayID }
            end
        end
    end

    local lines = {}
    lines[#lines + 1] = "-- Animation probe: " .. date("%Y-%m-%d %H:%M")
    lines[#lines + 1] = ""

    local idx = 1
    local total = #queue
    self._animHeader:SetText("Probing... 0/" .. total)

    -- Process one model per frame via OnModelLoaded callback
    local function ProbeNext()
        -- Skip headers
        while idx <= total and queue[idx].type == "header" do
            lines[#lines + 1] = "-- " .. queue[idx].name .. " (" .. queue[idx].key .. ")"
            idx = idx + 1
        end

        if idx > total then
            -- Done
            probe:SetScript("OnModelLoaded", nil)
            probe:Hide()
            lines[#lines + 1] = ""
            local text = table.concat(lines, "\n")
            VC.ModelViewer:ShowExportDialog(text)
            VC.ModelViewer._animHeader:SetText("Probe complete -- " .. total .. " entries")
            if VC.Store:GetState().config.debug then print("|cFFA0522D[VC]|r Probed all models. Copy from export window.") end
            return
        end

        local entry = queue[idx]
        self._animHeader:SetText("Probing " .. entry.displayID .. "... " .. idx .. "/" .. total)

        probe:ClearModel()
        probe:SetScript("OnModelLoaded", function()
            -- Model is now loaded, probe animations
            local supported = {}
            for id = 0, MAX_PROBE_ID do
                local ok, has = pcall(probe.HasAnimation, probe, id)
                if ok and has then
                    local name = ANIM_NAMES[id]
                    if name then
                        supported[#supported + 1] = id .. "=" .. name
                    else
                        supported[#supported + 1] = tostring(id)
                    end
                end
            end

            if #supported > 0 then
                lines[#lines + 1] = "[" .. entry.displayID .. "] = {" .. table.concat(supported, ", ") .. "}"
            else
                lines[#lines + 1] = "[" .. entry.displayID .. "] = {} -- no animations"
            end

            idx = idx + 1
            -- Process next on next frame to avoid stacking
            C_Timer.After(0, ProbeNext)
        end)
        pcall(probe.SetDisplayInfo, probe, entry.displayID)

        -- Fallback: if OnModelLoaded never fires (bad displayID), skip after timeout
        C_Timer.After(0.5, function()
            if idx <= total and queue[idx] == entry then
                probe:SetScript("OnModelLoaded", nil)
                lines[#lines + 1] = "[" .. entry.displayID .. "] = {} -- load timeout"
                idx = idx + 1
                C_Timer.After(0, ProbeNext)
            end
        end)
    end

    ProbeNext()
end

function VC.ModelViewer:ShowExportDialog(text)
    if self._exportDialog then
        self._exportDialog.editBox:SetText(text)
        self._exportDialog.editBox:HighlightText()
        self._exportDialog.editBox:SetFocus()
        self._exportDialog:Show()
        return
    end

    local d = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    d:SetSize(700, 500)
    d:SetPoint("CENTER")
    d:SetFrameStrata("FULLSCREEN_DIALOG")
    d:SetMovable(true)
    d:EnableMouse(true)
    d:RegisterForDrag("LeftButton")
    d:SetScript("OnDragStart", d.StartMoving)
    d:SetScript("OnDragStop", d.StopMovingOrSizing)
    d:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    d:SetBackdropColor(0.05, 0.05, 0.08, 0.95)
    d:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)

    local title = d:CreateFontString(nil, "OVERLAY")
    title:SetPoint("TOPLEFT", 8, -6)
    title:SetFont("Fonts\\ARIALN.TTF", 11, "")
    title:SetText("Animation Probe Export")
    title:SetTextColor(0.8, 0.8, 0.8, 1)

    local closeBtn = CreateFrame("Button", nil, d)
    closeBtn:SetSize(16, 16)
    closeBtn:SetPoint("TOPRIGHT", -4, -4)
    local closeX = closeBtn:CreateTexture(nil, "OVERLAY")
    closeX:SetAllPoints()
    closeX:SetAtlas("XMarksTheSpot")
    closeX:SetVertexColor(0.6, 0.6, 0.6, 1)
    closeBtn:SetScript("OnClick", function() d:Hide() end)

    local sf = CreateFrame("ScrollFrame", nil, d, "UIPanelScrollFrameTemplate")
    sf:SetPoint("TOPLEFT", 6, -24)
    sf:SetPoint("BOTTOMRIGHT", -28, 6)

    local eb = CreateFrame("EditBox", nil, sf)
    eb:SetMultiLine(true)
    eb:SetAutoFocus(false)
    eb:SetFont("Fonts\\ARIALN.TTF", 11, "")
    eb:SetTextColor(0.9, 0.9, 0.9, 1)
    eb:SetWidth(sf:GetWidth() or 660)
    eb:SetScript("OnEscapePressed", function(e) e:ClearFocus() end)
    sf:SetScrollChild(eb)

    d.editBox = eb
    self._exportDialog = d

    eb:SetText(text)
    eb:HighlightText()
    eb:SetFocus()
    d:Show()
end
