-- ModelProbe.lua — diagnostic for the planned 3D model-view feature
-- (armor-on-body + floating weapons). Shipped alongside APIProbe so it's
-- always available; touches no addon behavior.
--
-- Confirmed:
--   * Native wardrobe cells are DressUpModel frames.
--   * SetItemAppearance(appearanceID) renders BOTH modes: on a player-body
--     model the armor sits on the body; on a bodiless model the weapon floats.
--   * SetCameraID is ABSENT; framing is manual SetPosition/SetFacing/
--     SetCameraDistance. Head slot confirmed: pos 3.4520,0.0790,-1.7630
--     facing -0.5450 dist 2.9420.
--   * Native cells are POOLED and settle a frame after layout; half-configured
--     cells read junk (e.g. pos 0,0,0). So harvest waits a tick, scans the pool,
--     and takes the MOST COMMON non-zero camera (all cells in a category share one).
--
-- Usage (open Collections > Appearances, switch to the target category, and
-- make sure THAT grid is the one showing before each command — weapons included):
--   /etbmodel                  -> introspection
--   /etbmodel harvest <label>  -> paste-ready camera line for the current category
--                                 (head, chest, legs, ... / axe1h, bow, staff, ...)
--   /etbmodel armor  <itemID>  -> spawn a player-body test model + apply + frame
--   /etbmodel weapon <itemID>  -> spawn a bodiless (floating) test model + apply + frame
--   /etbmodel clear            -> remove spawned test models
--
-- Copy the chat output back to Claude.

local _, NS = ...
local function p(m) DEFAULT_CHAT_FRAME:AddMessage("|cff33ddff[ETB Model]|r " .. tostring(m)) end
local function yn(b) return b and "|cff44ff44YES|r" or "|cffff5555no|r" end
local function fn(t, k) return t and type(t[k]) == "function" end

local DEFAULT_DIST = 2.9420

local MODEL_METHODS = {
    "SetItemAppearance", "SetItem", "SetItemTransmogInfo", "TryOn",
    "Undress", "UndressSlot", "SetUnit", "SetSheathed",
    "SetCameraID", "SetCamera", "SetPosition", "SetFacing",
    "SetCameraDistance", "GetCameraDistance", "GetPosition", "GetFacing",
    "RefreshCamera",
}

local spawned = {}

local function getModels()
    local wf = _G.WardrobeCollectionFrame
    if not wf then return nil, nil end
    local icf = wf.ItemsCollectionFrame or wf.itemsCollectionFrame
    return wf, icf
end

-- Scan the pooled cells and return the MOST COMMON non-zero camera.
-- Returns: x, y, z, facing, dist, agree, scanned, distinct  (or nil + diagnostics).
local function readNativeCamera()
    local _, icf = getModels()
    local models = icf and icf.Models
    if not models then return nil, 0, 0 end

    local buckets, scanned = {}, 0
    for i = 1, 30 do
        local cell = models[i]
        if cell and fn(cell, "GetPosition") then
            scanned = scanned + 1
            local okP, x, y, z = pcall(cell.GetPosition, cell)
            local okF, facing  = pcall(cell.GetFacing, cell)
            local okD, dist    = pcall(cell.GetCameraDistance, cell)
            -- A configured cell has a non-zero position (armor) OR a non-zero
            -- distance (covers weapons that may frame near origin).
            local configured = okP and ((x ~= 0 or y ~= 0 or z ~= 0) or (okD and dist and dist ~= 0))
            if configured then
                facing = okF and facing or 0
                dist = (okD and dist) or DEFAULT_DIST
                local key = string.format("%.3f|%.3f|%.3f|%.3f|%.3f", x, y, z, facing, dist)
                local b = buckets[key]
                if b then
                    b.n = b.n + 1
                else
                    buckets[key] = { n = 1, x = x, y = y, z = z, facing = facing, dist = dist }
                end
            end
        end
    end

    local best, distinct = nil, 0
    for _, b in pairs(buckets) do
        distinct = distinct + 1
        if not best or b.n > best.n then best = b end
    end
    if not best then return nil, scanned, 0 end
    return best.x, best.y, best.z, best.facing, best.dist, best.n, scanned, distinct
end

local function frameFromNative(m, label)
    local x, y, z, facing, dist, agree, scanned, distinct = readNativeCamera()
    if not x then
        if fn(m, "SetCameraDistance") then pcall(m.SetCameraDistance, m, DEFAULT_DIST) end
        p("  [" .. label .. "] no live native camera (scanned " .. tostring(scanned)
            .. ") — using default distance; open the matching grid + scroll to top")
        return
    end
    if fn(m, "SetPosition") then pcall(m.SetPosition, m, x, y, z) end
    if fn(m, "SetFacing") then pcall(m.SetFacing, m, facing) end
    if fn(m, "SetCameraDistance") then pcall(m.SetCameraDistance, m, dist) end
    p(string.format("  [%s] camera pos=%.3f,%.3f,%.3f facing=%.3f dist=%.3f (%d/%d cells, %d distinct)",
        label, x, y, z, facing, dist, agree, scanned, distinct))
end

-- ============================================================================
local function introspect()
    p("---- native wardrobe introspection ----")
    local wf, icf = getModels()
    p("WardrobeCollectionFrame: " .. yn(wf ~= nil))
    if not wf then
        for k in pairs(_G) do
            if type(k) == "string" and k:find("Wardrobe") then p("  candidate global: " .. k) end
        end
        p("Open Collections > Appearances (Items tab), then rerun.")
    else
        local cell = icf and icf.Models and icf.Models[1]
        p("ItemsCollectionFrame: " .. yn(icf ~= nil) .. " | Models[]: " .. yn(icf and icf.Models ~= nil))
        if cell then
            p("native cell type: " .. tostring(cell:GetObjectType()))
            for _, m in ipairs(MODEL_METHODS) do
                p(string.format("  %-20s %s", m, fn(cell, m) and "|cff44ff44YES|r" or "--"))
            end
        end
        local x, y, z, facing, dist, agree, scanned, distinct = readNativeCamera()
        if x then
            p(string.format("  live camera: pos=%.4f,%.4f,%.4f facing=%.4f dist=%.4f (%d/%d, %d distinct)",
                x, y, z, facing, dist, agree, scanned, distinct))
        else
            p("  no live camera in pool (scanned " .. tostring(scanned) .. ") — open the grid, scroll to top")
        end
    end
    local TC = _G.C_TransmogCollection
    p("C_TransmogCollection: " .. yn(TC ~= nil)
        .. " | GetItemInfo " .. yn(fn(TC, "GetItemInfo"))
        .. " | GetAppearanceCameraID " .. yn(fn(TC, "GetAppearanceCameraID")))
    p("---- end introspection ----")
end

-- ============================================================================
local function harvest(label)
    label = (label and label ~= "") and label or "UNLABELED"
    -- Wait a tick so freshly-switched cells have settled before reading.
    C_Timer.After(0.1, function()
        local x, y, z, facing, dist, agree, scanned, distinct = readNativeCamera()
        if not x then
            p("harvest['" .. label .. "']: no live cell (scanned " .. tostring(scanned)
                .. "). Open that category's grid, scroll to top, retry.")
            return
        end
        p(string.format("ETB_CAM[%q] = { pos = {%.4f, %.4f, %.4f}, facing = %.4f, dist = %.4f },  -- %d/%d cells, %d distinct",
            label, x, y, z, facing, dist, agree, scanned, distinct))
        if distinct > 1 then
            p("  |cffffcc00note:|r " .. distinct .. " distinct cameras seen — pool may still hold the previous category; reswitch + retry to be safe")
        end
    end)
end

-- ============================================================================
local function spawnTest(itemID, label, bodiless)
    local TC = _G.C_TransmogCollection
    local appID, srcID
    if fn(TC, "GetItemInfo") then appID, srcID = TC.GetItemInfo(itemID) end
    p(string.format("[%s] item=%s appID=%s srcID=%s", label, tostring(itemID), tostring(appID), tostring(srcID)))
    if not appID then
        p("  no appearanceID for that item — pick one visible in the wardrobe grid")
        return
    end

    local m = CreateFrame("DressUpModel", nil, UIParent)
    m:SetSize(240, 340)
    m:SetPoint("CENTER", UIParent, "CENTER", bodiless and 290 or -290, 30)
    m:SetFrameStrata("FULLSCREEN_DIALOG")
    local fs = m:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    fs:SetPoint("BOTTOM", m, "TOP", 0, 4)
    fs:SetText(label .. (bodiless and " (no body)" or " (player body)"))
    m:Show()
    spawned[#spawned + 1] = m

    C_Timer.After(0.1, function()
        if not bodiless then
            if fn(m, "SetUnit") then m:SetUnit("player") end
            if fn(m, "Undress") then m:Undress() end
        end
        if fn(m, "SetItemAppearance") then
            p("  [" .. label .. "] SetItemAppearance ok=" .. tostring(select(1, pcall(m.SetItemAppearance, m, appID))))
        elseif srcID and fn(m, "TryOn") then
            p("  [" .. label .. "] (fallback) TryOn(srcID) ok=" .. tostring(select(1, pcall(m.TryOn, m, srcID))))
        end
        if bodiless and fn(m, "SetSheathed") then pcall(m.SetSheathed, m, false) end
        C_Timer.After(0.05, function() frameFromNative(m, label) end)
    end)
end

local function clearSpawned()
    for _, m in ipairs(spawned) do m:Hide(); m:SetParent(nil) end
    wipe(spawned)
    p("cleared spawned test models.")
end

-- ============================================================================
-- Auto-sweep: flip through every native category and harvest each camera.
-- ============================================================================
local function sanitize(name)
    return (tostring(name):lower():gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", ""))
end

-- Toggle mouse on the grid cells. The cells' OnEnter rebuilds a tooltip from
-- cell state; during a programmatic walk that state is mid-update and Blizzard's
-- handler errors. Disabling mouse for the duration prevents OnEnter entirely.
local function setCellsMouse(icf, enabled)
    local models = icf and icf.Models
    if not models then return end
    for i = 1, 30 do
        local cell = models[i]
        if cell and cell.EnableMouse then pcall(cell.EnableMouse, cell, enabled) end
    end
end

-- Persistent, accumulating result store (survives /reload via SavedVariables).
local function getStore()
    local s = _G.ETBModelSweepDB
    if type(s) ~= "table" then s = {}; _G.ETBModelSweepDB = s; return s end
    -- Purge legacy array-shaped entries (numeric keys) from older versions.
    for k in pairs(s) do if type(k) ~= "string" then s[k] = nil end end
    return s
end

local function printStore(added)
    local store = getStore()
    local lines = {}
    for _, v in pairs(store) do if type(v) == "string" then lines[#lines + 1] = v end end
    table.sort(lines)
    p("---- sweep results (copy everything below) ----")
    for _, line in ipairs(lines) do p(line) end
    p(string.format("---- end (%d total%s) — /etbmodel dump to reprint ----",
        #lines, added and (", +" .. added .. " this run") or ""))
end

-- Shared per-run harvester. Returns harvest(label,isWpn) and finalize().
local function makeRun(icf)
    local store = getStore()
    local added, finalized = 0, false
    local function harvest(lab, isWpn)
        local key = sanitize(lab)
        local x, y, z, facing, dist, agree, scanned, distinct = readNativeCamera()
        local line
        if x then
            line = string.format(
                "ETB_CAM[%q] = { pos = {%.4f, %.4f, %.4f}, facing = %.4f, dist = %.4f },  -- %s [%s] (%d/%d,%dd)",
                key, x, y, z, facing, dist, lab, isWpn and "wpn" or "armor", agree, scanned, distinct)
        else
            line = string.format("-- %s [%s]: no camera", lab, isWpn and "wpn" or "armor")
        end
        if store[key] == nil then added = added + 1 end
        store[key] = line
    end
    local function finalize()
        if finalized then return end
        finalized = true
        setCellsMouse(icf, true)
        printStore(added)
    end
    return harvest, finalize
end

local function enumCats()
    local TC = _G.C_TransmogCollection
    local cats = {}
    for i = 1, 40 do
        local ok, name, isW = pcall(TC.GetCategoryInfo, i)
        if ok and name and name ~= "" then
            cats[#cats + 1] = { id = i, name = name, isW = isW and true or false }
        end
    end
    return cats
end

-- Find the slot buttons (head/chest/.../mainhand/offhand) in SlotsFrame.
local function getSlotButtons(icf)
    local sf = icf and icf.SlotsFrame
    if not sf or not sf.GetChildren then return nil end
    local btns = {}
    for _, c in ipairs({ sf:GetChildren() }) do
        local ok, ot = pcall(c.GetObjectType, c)
        if ok and (ot == "Button" or ot == "CheckButton") and c.Click then
            btns[#btns + 1] = c
        end
    end
    return btns
end

-- Best-effort label for the currently active (armor) slot, which has no
-- enumerable sub-category. Tries active-category name, then slot info.
local function activeLabel(icf)
    local TC = _G.C_TransmogCollection
    if fn(icf, "GetActiveCategory") then
        local ok, cat = pcall(icf.GetActiveCategory, icf)
        if ok and type(cat) == "number" then
            local ok2, name = pcall(TC.GetCategoryInfo, cat)
            if ok2 and name and name ~= "" then return name end
        end
    end
    if fn(icf, "GetActiveSlotInfo") then
        local r = { pcall(icf.GetActiveSlotInfo, icf) }
        for i = 2, #r do if type(r[i]) == "string" and r[i] ~= "" then return r[i] end end
        for i = 2, #r do if type(r[i]) == "number" then return "slot" .. r[i] end end
    end
    return nil
end

-- Sweep ONLY the categories of the currently-active slot. No slot switching, so
-- the per-category camera reads are correct (this is the v15.134 path that worked).
-- Select a slot in the wardrobe (an armor slot, or main/off-hand) before running.
local function sweepCurrent()
    local _, icf = getModels()
    if not icf then p("sweepcur: open Collections > Appearances first."); return end
    local TC = _G.C_TransmogCollection
    if not (fn(icf, "SetActiveCategory") and fn(TC, "GetCategoryInfo")) then
        p("sweepcur: SetActiveCategory/GetCategoryInfo missing."); return
    end
    local cats = enumCats()
    if #cats == 0 then p("sweepcur: no categories — pick a slot in the wardrobe first."); return end
    local harvest, finalize = makeRun(icf)
    setCellsMouse(icf, false)
    p(("sweepcur: %d categories in the active slot — leave the window open..."):format(#cats))
    C_Timer.After(3 + #cats * 1.0, finalize)
    local ci = 0
    local function nextCat()
        ci = ci + 1
        if ci > #cats then finalize(); return end
        local cat = cats[ci]
        pcall(icf.SetActiveCategory, icf, cat.id)
        C_Timer.After(0.35, function() harvest(cat.name, cat.isW); nextCat() end)
    end
    nextCat()
end

-- Click-driven full sweep: click each slot button (the real user path that
-- frames cells correctly), then walk that slot's categories via SetActiveCategory.
local function sweep()
    local _, icf = getModels()
    if not icf then p("sweep: open Collections > Appearances first."); return end
    local TC = _G.C_TransmogCollection
    local buttons = getSlotButtons(icf)
    p("sweep: " .. tostring(buttons and #buttons or 0) .. " slot buttons found")
    if not (buttons and #buttons > 0 and fn(icf, "SetActiveCategory") and fn(TC, "GetCategoryInfo")) then
        p("sweep: no slot buttons. Guaranteed path instead: in the wardrobe, click a slot")
        p("  (or main/off-hand), run |cffffd100/etbmodel sweepcur|r, repeat per slot.")
        return
    end
    local harvest, finalize = makeRun(icf)
    setCellsMouse(icf, false)
    p(("sweep: clicking %d slots — leave the window open (~%ds)..."):format(#buttons, #buttons * 3))
    C_Timer.After(8 + #buttons * 5, finalize)
    local bi = 0
    local function nextBtn()
        bi = bi + 1
        if bi > #buttons then finalize(); return end
        pcall(buttons[bi].Click, buttons[bi])
        C_Timer.After(0.45, function()
            local cats = enumCats()
            if #cats == 0 then
                -- Armor slot: no sub-categories, harvest the current view directly.
                local lab = activeLabel(icf)
                if lab then
                    harvest(lab, false)
                else
                    local k = "armorslot_" .. bi
                    harvest(k, false)
                    local gac = fn(icf, "GetActiveCategory") and select(2, pcall(icf.GetActiveCategory, icf))
                    p(("  [%s] couldn't auto-label (GetActiveCategory=%s) — rename via grab"):format(k, tostring(gac)))
                end
                nextBtn()
                return
            end
            local ci = 0
            local function nextCat()
                ci = ci + 1
                if ci > #cats then nextBtn(); return end
                local cat = cats[ci]
                pcall(icf.SetActiveCategory, icf, cat.id)
                C_Timer.After(0.35, function() harvest(cat.name, cat.isW); nextCat() end)
            end
            nextCat()
        end)
    end
    nextBtn()
end

-- Manual single capture: select any slot/category in the wardrobe, then
-- /etbmodel grab <label> reads the current grid's camera and stores it.
local function grab(label)
    if not label or label == "" then p("usage: /etbmodel grab <label>   e.g. grab head"); return end
    local _, icf = getModels()
    if not icf then p("grab: open Collections > Appearances first."); return end
    setCellsMouse(icf, false)
    C_Timer.After(0.1, function()
        setCellsMouse(icf, true)
        local store = getStore()
        local key = sanitize(label)
        local x, y, z, facing, dist, agree, scanned, distinct = readNativeCamera()
        if x then
            store[key] = string.format(
                "ETB_CAM[%q] = { pos = {%.4f, %.4f, %.4f}, facing = %.4f, dist = %.4f },  -- %s (grab) (%d/%d,%dd)",
                key, x, y, z, facing, dist, label, agree, scanned, distinct)
            p("grabbed: " .. store[key])
        else
            p("grab: no camera read — make sure that slot/category grid is showing, scrolled to top.")
        end
    end)
end

-- ============================================================================
SLASH_ETBMODEL1 = "/etbmodel"
SlashCmdList.ETBMODEL = function(msg)
    msg = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")
    local cmd, rest = msg:match("^(%S+)%s*(.-)$")
    cmd = (cmd or ""):lower()

    if cmd == "clear" then
        clearSpawned()
    elseif cmd == "dump" then
        local store = getStore()
        local n = 0
        for _, v in pairs(store) do if type(v) == "string" then n = n + 1 end end
        if n > 0 then printStore() else p("no stored sweep — run /etbmodel sweep [target] first.") end
    elseif cmd == "reset" then
        _G.ETBModelSweepDB = {}
        p("cleared stored sweep table.")
    elseif cmd == "sweep" then
        sweep()
    elseif cmd == "sweepcur" then
        sweepCurrent()
    elseif cmd == "grab" then
        grab(rest)
    elseif cmd == "harvest" then
        harvest(rest)
    elseif cmd == "armor" then
        local id = tonumber(rest)
        if id then spawnTest(id, "ARMOR", false) else p("usage: /etbmodel armor <itemID>") end
    elseif cmd == "weapon" then
        local id = tonumber(rest)
        if id then spawnTest(id, "WEAPON", true) else p("usage: /etbmodel weapon <itemID>") end
    else
        introspect()
        p("|cffffd100/etbmodel sweep|r (auto) or select a slot + |cffffd100sweepcur|r (manual), then |cffffd100dump|r / |cffffd100reset|r")
    end
end
