-- Transmoggin - Copyright (c) 2026 Rooster_Roost. All Rights Reserved.
-- Proprietary software. Personal non-commercial use only; redistribution/repackaging/forks/reuse require permission.
-- Full license: Core.lua and LICENSE.txt

local T=Transmoggin

function T:InitCompat()
    local version,build,date,interface=GetBuildInfo()
    self.interface=tonumber(interface) or 0
    self.clientVersion=version or "?"
    self.clientBuild=tonumber(build) or 0

    -- Supported Transmoggin branches are intentionally explicit.  Do not infer
    -- support merely because a transmog-named API happens to exist on another
    -- Classic ruleset.  MoP Classic is the reference client; Retail is the
    -- modern adapter target.
    local projectID=_G.WOW_PROJECT_ID
    if (_G.WOW_PROJECT_MAINLINE and projectID==_G.WOW_PROJECT_MAINLINE) or self.interface>=100000 then
        self.clientMode="retail"
    elseif self.interface>=50000 and self.interface<60000 then
        self.clientMode="mop-classic"
    else
        self.clientMode="unsupported"
    end

    local hasCollection=C_TransmogCollection and type(C_TransmogCollection.GetCategoryAppearances)=="function"
    local hasNewOutfit=C_TransmogOutfitInfo and type(C_TransmogOutfitInfo.SetPendingTransmog)=="function"
    local hasNewApply=C_TransmogOutfitInfo and type(C_TransmogOutfitInfo.CommitAndApplyAllPending)=="function"
    self.cap={
        supported=self.clientMode~="unsupported",
        retail=self.clientMode=="retail",
        mopClassic=self.clientMode=="mop-classic",
        collection=hasCollection and true or false,
        sets=C_TransmogSets and (type(C_TransmogSets.GetAllSets)=="function" or type(C_TransmogSets.GetAvailableSets)=="function"),
        setSlotSources=C_TransmogSets and type(C_TransmogSets.GetSourcesForSlot)=="function",
        setAllSources=C_TransmogSets and type(C_TransmogSets.GetAllSourceIDs)=="function",
        sourceDrops=C_TransmogCollection and type(C_TransmogCollection.GetAppearanceSourceDrops)=="function",
        newOutfit=hasNewOutfit and true or false,
        newApply=hasNewApply and true or false,
        legacyPending=C_Transmog and type(C_Transmog.SetPending)=="function",
        legacyApply=C_Transmog and type(C_Transmog.ApplyAllPending)=="function",
        interaction=C_PlayerInteractionManager~=nil,
        nativeTransmog=hasCollection and (hasNewOutfit or (C_Transmog and type(C_Transmog.SetPending)=="function")) and true or false,
        generatedMoPSourceDB=self.clientMode=="mop-classic",
    }
end

function T:IsRetail() return self.clientMode=="retail" end
function T:IsMoPClassic() return self.clientMode=="mop-classic" end
function T:IsSupportedClient() return self.clientMode=="retail" or self.clientMode=="mop-classic" end

function T:IsTransmogInteractionType(typ)
    local target=(Enum and Enum.PlayerInteractionType and Enum.PlayerInteractionType.Transmogrifier) or 24
    return tonumber(typ)==tonumber(target)
end

function T:IsAtTransmogVendor()
    local typ=(Enum and Enum.PlayerInteractionType and Enum.PlayerInteractionType.Transmogrifier) or 24
    if C_PlayerInteractionManager and C_PlayerInteractionManager.IsInteractingWithNpcOfType then
        local ok,v=pcall(C_PlayerInteractionManager.IsInteractingWithNpcOfType,typ)
        if ok then return v and true or false end
    end
    return self.state.vendorOpen and true or false
end

function T:MakeLocation(slotDef)
    if not slotDef then return nil end
    local t=(Enum and Enum.TransmogType and Enum.TransmogType.Appearance) or 0
    local m=(Enum and Enum.TransmogModification and Enum.TransmogModification.Main) or 0
    if TransmogUtil and type(TransmogUtil.CreateTransmogLocation)=="function" then
        local ok,loc=pcall(TransmogUtil.CreateTransmogLocation,slotDef.key,t,m)
        if ok and loc then return loc end
        ok,loc=pcall(TransmogUtil.CreateTransmogLocation,slotDef.slotID,t,m)
        if ok and loc then return loc end
    end
    return nil
end

function T:GetEffectiveCategory(slotDef)
    if not slotDef then return 1 end
    if slotDef.category then return slotDef.category end
    local loc=self:MakeLocation(slotDef)
    if C_Transmog and C_Transmog.GetSlotEffectiveCategory and loc then
        local ok,cat=pcall(C_Transmog.GetSlotEffectiveCategory,loc)
        if ok and tonumber(cat) and cat>0 then return cat end
    end
    local itemID=GetInventoryItemID and GetInventoryItemID("player",slotDef.slotID)
    if itemID then
        local _,_,_,equipLoc,_,classID,subclassID=GetItemInfoInstant(itemID)
        if classID==2 then
            local map={
                [0]=13,[1]=20,[2]=25,[3]=27,[4]=15,[5]=21,[6]=24,[7]=14,[8]=22,[10]=23,[13]=17,[15]=16,[18]=28,[19]=12,
            }
            return map[subclassID] or (slotDef.slotID==17 and 19 or 23)
        elseif classID==4 and subclassID==6 then return 18 end
    end
    return slotDef.slotID==17 and 19 or 23
end

function T:IsWeaponSlot(slotDef)
    return slotDef and (slotDef.slotID==16 or slotDef.slotID==17) or false
end

function T:GetWeaponCategoriesForSlot(slotDef)
    if not self:IsWeaponSlot(slotDef) then return {} end
    local out={}
    for _,categoryID in ipairs(self.WEAPON_CATEGORIES or {}) do
        local include=false
        if C_TransmogCollection and C_TransmogCollection.GetCategoryInfo then
            local ok,name,isWeapon,canHaveIllusions,canMainHand,canOffHand,canRanged=pcall(C_TransmogCollection.GetCategoryInfo,categoryID)
            if ok and name and isWeapon then
                if slotDef.slotID==16 then include=(canMainHand or canRanged) and true or false
                else include=canOffHand and true or false end
            end
        end
        -- Older Classic branches may not expose all capability booleans. In that
        -- case, let the location-filtered collection query decide whether the
        -- category has anything usable for this hand.
        if not include then
            local loc=self:MakeLocation(slotDef)
            if loc and C_TransmogCollection and C_TransmogCollection.GetCategoryAppearances then
                local ok,apps=pcall(C_TransmogCollection.GetCategoryAppearances,categoryID,loc)
                include=ok and type(apps)=="table" and #apps>0
            end
        end
        if include then out[#out+1]=categoryID end
    end
    return out
end

function T:GetSlotAppearanceCatalog(slotDef)
    if not slotDef then return {} end
    if not self:IsWeaponSlot(slotDef) then
        local categoryID=self:GetEffectiveCategory(slotDef)
        local apps=self:GetCategoryAppearances(categoryID,slotDef)
        local out={}
        for _,a in ipairs(apps or {}) do
            local c={}; for k,v in pairs(a) do c[k]=v end
            c.__categoryID=categoryID
            out[#out+1]=c
        end
        return out
    end

    -- Weapon slots are special: Blizzard exposes separate collection categories
    -- for 1H axes/swords/maces, daggers, fist weapons, shields, holdables, 2H
    -- weapons, ranged weapons, etc. The old Transmoggin code only browsed the
    -- category of the currently equipped weapon, making most valid weapon mogs
    -- impossible to choose. Merge every category Blizzard says this hand can use.
    local out,seen={},{}
    for _,categoryID in ipairs(self:GetWeaponCategoriesForSlot(slotDef)) do
        for _,a in ipairs(self:GetCategoryAppearances(categoryID,slotDef) or {}) do
            local key=tostring(categoryID)..":"..tostring(a.visualID)
            if a.visualID and not seen[key] then
                seen[key]=true
                local c={}; for k,v in pairs(a) do c[k]=v end
                c.__categoryID=categoryID
                out[#out+1]=c
            end
        end
    end
    table.sort(out,function(a,b)
        local ao=a.uiOrder or a.visualID or 0
        local bo=b.uiOrder or b.visualID or 0
        if ao==bo then return (a.__categoryID or 0)<(b.__categoryID or 0) end
        return ao<bo
    end)
    return out
end

function T:IsAppearanceUsable(a, categoryID, slotDef)
    if not a or not a.visualID then return false end
    -- On MoP Classic, uncollected appearances commonly report isUsable=false.
    -- That flag cannot be used as an ownership/browser filter or the Missing tab
    -- becomes empty. The category API is already class-proficiency filtered.
    if a.canDisplayOnPlayer==false then return false end
    if a.hasRequiredHoliday and a.hasActiveRequiredHoliday==false then return false end

    -- 5.5.4 exposes an explicit class-valid source query. Use it when available
    -- to keep planned/missing pieces limited to appearances this character can wear.
    if C_TransmogCollection and C_TransmogCollection.GetValidAppearanceSourcesForClass then
        self.state.validAppearanceCache=self.state.validAppearanceCache or {}
        local ctx=self.GetPlayerContext and self:GetPlayerContext() or nil
        local classID=ctx and ctx.classID or nil
        if classID then
            local key=tostring(a.visualID)..":"..tostring(classID)..":"..tostring(categoryID or 0)..":"..tostring(slotDef and slotDef.key or "")
            local cached=self.state.validAppearanceCache[key]
            if cached~=nil then return cached end
            local loc=slotDef and self:MakeLocation(slotDef) or nil
            local ok,res=pcall(C_TransmogCollection.GetValidAppearanceSourcesForClass,a.visualID,classID,categoryID,loc)
            if not ok or type(res)~="table" then ok,res=pcall(C_TransmogCollection.GetValidAppearanceSourcesForClass,a.visualID,classID,categoryID) end
            if not ok or type(res)~="table" then ok,res=pcall(C_TransmogCollection.GetValidAppearanceSourcesForClass,a.visualID,classID) end
            if ok and type(res)=="table" then
                local valid=#res>0
                self.state.validAppearanceCache[key]=valid
                return valid
            end
        end
    end
    return true
end

function T:PrepareCollectionCatalog(force)
    if not self.cap.collection then return end
    if self.state.collectionPreparing then return end
    if self.state.collectionPrepared and not force then return end
    self.state.collectionPreparing=true
    -- Transmoggin replaces the Wardrobe browser, so its catalog must never inherit
    -- Blizzard's last-used filters. Force the hidden collection query to expose the
    -- complete class-usable catalog, then apply Transmoggin's own All/Collected/Missing
    -- filters locally. This is especially important on MoP Classic where the stock
    -- wardrobe filter state otherwise makes the Missing tab appear empty.
    if C_TransmogCollection.SetCollectedShown then pcall(C_TransmogCollection.SetCollectedShown,true) end
    if C_TransmogCollection.SetUncollectedShown then pcall(C_TransmogCollection.SetUncollectedShown,true) end
    if C_TransmogCollection.SetAllSourceTypeFilters then pcall(C_TransmogCollection.SetAllSourceTypeFilters,true) end
    if C_TransmogCollection.SetAllCollectionTypeFilters then pcall(C_TransmogCollection.SetAllCollectionTypeFilters,true) end
    if C_TransmogCollection.UpdateUsableAppearances then pcall(C_TransmogCollection.UpdateUsableAppearances) end
    self.state.appearanceCache={}
    self.state.collectionPreparing=false
    self.state.collectionPrepared=true
end

function T:GetCategoryAppearances(categoryID,slotDef)
    if not self.cap.collection then return {} end
    self:PrepareCollectionCatalog(false)
    local cacheKey=tostring(categoryID)..":"..tostring(slotDef and slotDef.key or "")
    local cached=self.state.appearanceCache[cacheKey]
    if cached then return cached end
    local loc=self:MakeLocation(slotDef)
    -- Query both forms and merge. Some Classic branches return a narrower list
    -- when a TransmogLocation is supplied; the no-location query is the complete
    -- class-proficiency catalog used for planning uncollected appearances.
    local merged,seen={},{}
    local function ingest(apps)
        if type(apps)~="table" then return end
        for _,a in ipairs(apps) do
            if a and a.visualID and not seen[a.visualID] then seen[a.visualID]=true; merged[#merged+1]=a end
        end
    end
    local ok,apps=pcall(C_TransmogCollection.GetCategoryAppearances,categoryID)
    if ok then ingest(apps) end
    if loc then local ok2,apps2=pcall(C_TransmogCollection.GetCategoryAppearances,categoryID,loc); if ok2 then ingest(apps2) end end
    if #merged==0 then return {} end
    local out={}
    self.state.visualCollectedCache=self.state.visualCollectedCache or {}
    for _,a in ipairs(merged) do
        if self:IsAppearanceUsable(a,categoryID,slotDef) then
            out[#out+1]=a
            -- GetCategoryAppearances reports ownership at the visual level. Seed the
            -- ownership cache here so filtering hundreds of tiles does not query every
            -- individual source. Source-level fallback remains for older branches.
            if a.isCollected~=nil then
                self.state.visualCollectedCache[tostring(a.visualID)..":"..tostring(categoryID or 0)]=a.isCollected and true or false
            end
        end
    end
    table.sort(out,function(a,b) return (a.uiOrder or a.visualID or 0)<(b.uiOrder or b.visualID or 0) end)
    self.state.appearanceCache[cacheKey]=out
    return out
end

function T:GetOutfitWeaponOption(slotDef,rec)
    if not slotDef then return 0 end
    local E=Enum and Enum.TransmogOutfitSlotOption
    local none=(E and E.None) or 0
    if self:IsWeaponSlot(slotDef) and rec and tonumber(rec.categoryID) then
        local cat=tonumber(rec.categoryID)
        if cat==18 then return (E and E.Shield) or 5 end
        if cat==19 then return (E and E.OffHand) or 4 end
        if cat>=20 and cat<=24 then
            if slotDef.slotID==17 then return (E and E.FuryTwoHandedWeapon) or 7 end
            return (E and E.TwoHandedWeapon) or 2
        end
        if cat>=25 and cat<=27 then return (E and E.RangedWeapon) or 3 end
        if (cat>=12 and cat<=17) or cat==28 or cat==29 then return (E and E.OneHandedWeapon) or 1 end
    end
    if C_TransmogOutfitInfo and type(C_TransmogOutfitInfo.GetEquippedSlotOptionFromTransmogSlot)=="function" then
        local ok,opt=pcall(C_TransmogOutfitInfo.GetEquippedSlotOptionFromTransmogSlot,slotDef.outfitSlot)
        if ok and opt~=nil then return opt end
    end
    return none
end

function T:StageAppearance(slotDef,rec)
    if not self:IsAtTransmogVendor() then return false,"Visit a transmogrifier to purchase this look." end
    if not slotDef or not rec then return false,"No transmog slot or appearance was selected." end
    rec.collected=self:IsPieceCollected(rec)
    if not rec.collected then return false,"That appearance is not collected yet. Track it instead." end

    if self.cap.newOutfit then
        -- The 5.5.4/12.x outfit API is slightly misleadingly named: for an
        -- appearance slot Blizzard passes a usable *sourceID* as transmogID,
        -- not the visualID shown by GetCategoryAppearances(). Mirror the
        -- stock TransmogWardrobeItemsMixin:SelectVisual flow exactly.
        local sourceID=rec.sourceID or rec.itemModID
        -- Weapon visuals can have hand-specific sources. Re-resolve against the
        -- actual Main Hand/Off Hand TransmogLocation before staging so a visual
        -- selected from the combined weapon browser cannot accidentally use a
        -- source that Blizzard only permits in the other hand.
        if self:IsWeaponSlot(slotDef) and rec.visualID and rec.categoryID then
            local resolved=self:GetSourceRecord(rec.visualID,rec.categoryID,sourceID,slotDef)
            if resolved then
                sourceID=resolved.sourceID or resolved.itemModID
                rec.sourceID=resolved.sourceID; rec.itemModID=resolved.itemModID
            end
        elseif not sourceID or sourceID==0 then
            local resolved=self:GetSourceRecord(rec.visualID,rec.categoryID,nil,slotDef)
            sourceID=resolved and (resolved.sourceID or resolved.itemModID) or nil
            if resolved then rec.sourceID=resolved.sourceID; rec.itemModID=resolved.itemModID end
        end
        if not sourceID or sourceID==0 then return false,"Could not resolve a usable collected source for this appearance." end

        local typ=(Enum and Enum.TransmogType and Enum.TransmogType.Appearance) or 0
        local opt=self:GetOutfitWeaponOption(slotDef,rec)
        local assigned=(Enum and Enum.TransmogOutfitDisplayType and Enum.TransmogOutfitDisplayType.Assigned) or 1
        local hidden=(Enum and Enum.TransmogOutfitDisplayType and Enum.TransmogOutfitDisplayType.Hidden) or 3
        local display=assigned
        if C_TransmogCollection and C_TransmogCollection.IsAppearanceHiddenVisual then
            local okHidden,isHidden=pcall(C_TransmogCollection.IsAppearanceHiddenVisual,sourceID)
            if okHidden and isHidden then display=hidden end
        elseif rec.isHideVisual then
            display=hidden
        end

        local ok,err=pcall(C_TransmogOutfitInfo.SetPendingTransmog,slotDef.outfitSlot,typ,opt,sourceID,display)
        if ok then
            self.lastStageError=nil
            if (not self._bulkStaging) and self.ScheduleVendorCostRefresh then self:ScheduleVendorCostRefresh() end
            return true
        end
        self.lastStageError=tostring(err)
    end

    if self.cap.legacyPending then
        local loc=self:MakeLocation(slotDef)
        if loc and TransmogUtil and TransmogUtil.CreateTransmogPendingInfo then
            local pt=(Enum and Enum.TransmogPendingType and Enum.TransmogPendingType.Apply) or 0
            local ok,pending=pcall(TransmogUtil.CreateTransmogPendingInfo,pt,rec.sourceID or rec.itemModID,rec.categoryID)
            if ok and pending then
                local ok2,err=pcall(C_Transmog.SetPending,loc,pending)
                if ok2 then self.lastStageError=nil; if (not self._bulkStaging) and self.ScheduleVendorCostRefresh then self:ScheduleVendorCostRefresh() end; return true end
                self.lastStageError=tostring(err)
            end
        end
    end
    return false,"Transmoggin could not stage this appearance. Use /tmg debug and send the result."
end

function T:ClearPendingTransmogs()
    if not self:IsAtTransmogVendor() then return false end
    -- MoP Classic's outfit API keeps pending slot choices until explicitly cleared.
    -- Clearing before every full sync prevents removed/reverted pieces from lingering
    -- in Blizzard's pending basket and keeps GetPendingTransmogCost() exact.
    if C_TransmogOutfitInfo and C_TransmogOutfitInfo.ClearAllPendingTransmogs then
        local ok=pcall(C_TransmogOutfitInfo.ClearAllPendingTransmogs)
        if ok then return true end
    end
    -- Older clients do not expose the outfit clear call. Revert each known slot when
    -- the per-slot API is available. This is best-effort compatibility only.
    if C_TransmogOutfitInfo and C_TransmogOutfitInfo.RevertPendingTransmog then
        local typ=(Enum and Enum.TransmogType and Enum.TransmogType.Appearance) or 0
        for _,slotDef in ipairs(self.SLOTS or {}) do
            local opt=self:GetOutfitWeaponOption(slotDef)
            pcall(C_TransmogOutfitInfo.RevertPendingTransmog,slotDef.outfitSlot,typ,opt)
        end
        return true
    end
    return false
end

function T:StageWorkingLook(resetPending)
    if not self:IsAtTransmogVendor() then return false,"Visit a transmogrifier to purchase this look." end
    if resetPending~=false then self:ClearPendingTransmogs() end
    local staged,missing=0,0
    self._bulkStaging=true
    for _,s in ipairs(self.SLOTS) do
        local p=self.state.working.pieces[s.key]
        if p then
            p.collected=self:IsPieceCollected(p)
            if p.collected then local ok=self:StageAppearance(s,p); if ok then staged=staged+1 end else missing=missing+1 end
        end
    end
    self._bulkStaging=nil
    if self.ScheduleVendorCostRefresh then self:ScheduleVendorCostRefresh() end
    if staged==0 and missing>0 then return false,"This look only contains missing pieces. Track them first." end
    return true,("Staged %d pieces%s."):format(staged,missing>0 and ("; "..missing.." missing pieces skipped") or "")
end

function T:SyncPendingToWorkingLook(delay)
    if not self:IsAtTransmogVendor() then return end
    self._pendingSyncToken=(self._pendingSyncToken or 0)+1
    local token=self._pendingSyncToken
    local function run()
        if token~=T._pendingSyncToken or not T:IsAtTransmogVendor() then return end
        T:StageWorkingLook(true)
        if T.RefreshVendorStatus then T:RefreshVendorStatus() end
    end
    if C_Timer and C_Timer.After then C_Timer.After(delay or 0.04,run) else run() end
end

function T:ApplyPending()
    if not self:IsAtTransmogVendor() then return false,"Purchases are only available while visiting a transmogrifier." end
    if self.cap.newApply then
        if C_TransmogOutfitInfo.HasPendingOutfitTransmogs then
            local okPending,hasPending=pcall(C_TransmogOutfitInfo.HasPendingOutfitTransmogs)
            if okPending and not hasPending then
                return false,"Nothing is staged for purchase. Select a collected appearance first."
            end
        end
        -- Match Blizzard's normal Save/Apply button: spend gold by default.
        -- Discount handling can be exposed as an explicit salon option later.
        local useAvailableDiscount=false
        local ok,err=pcall(C_TransmogOutfitInfo.CommitAndApplyAllPending,useAvailableDiscount)
        if ok then self.lastApplyError=nil; return true end
        self.lastApplyError=tostring(err)
    end
    if self.cap.legacyApply then
        local ok,res=pcall(C_Transmog.ApplyAllPending,false)
        if ok then self.lastApplyError=nil; return res~=false end
        self.lastApplyError=tostring(res)
    end
    return false,"No supported purchase API is available on this client. /tmg debug"
end

local function normalizeCopper(v)
    if v==nil then return nil end
    if type(v)=="number" then return v end
    local n=tonumber(v); if n then return n end
    local ok,s=pcall(tostring,v); if ok and s then
        n=tonumber(s:match("%-?%d+")); if n then return n end
    end
    if type(v)=="table" then
        for _,k in ipairs({"value","amount","copper","quantity","low"}) do
            if v[k]~=nil then local z=normalizeCopper(v[k]); if z then return z end end
        end
    end
    return nil
end

function T:GetApplyCost()
    if C_TransmogOutfitInfo and C_TransmogOutfitInfo.GetPendingTransmogCost then
        local ok,c=pcall(C_TransmogOutfitInfo.GetPendingTransmogCost)
        if ok then local n=normalizeCopper(c); if n then return n end end
    end
    if C_Transmog and C_Transmog.GetApplyCost then
        local ok,c=pcall(C_Transmog.GetApplyCost)
        if ok then local n=normalizeCopper(c); if n then return n end end
    end
    return 0
end

function T:ScheduleVendorCostRefresh()
    if not (self.state and self.state.vendorOpen) then return end
    self._costRefreshToken=(self._costRefreshToken or 0)+1
    local token=self._costRefreshToken
    local function refresh()
        if token~=T._costRefreshToken then return end
        if T.state and T.state.vendorOpen and T.RefreshVendorStatus then T:RefreshVendorStatus() end
    end
    refresh()
    if C_Timer and C_Timer.After then
        for _,delay in ipairs({0.04,0.12,0.28,0.55,0.90}) do C_Timer.After(delay,refresh) end
    end
end

function T:GetDebugString()
    local c=self.cap or {}
    return ("v%s mode=%s client=%s interface=%s collection=%s sets=%s nativeTransmog=%s newOutfit=%s newApply=%s legacyPending=%s legacyApply=%s vendor=%s lastStage=%s lastApply=%s")
        :format(self.version,tostring(self.clientMode),tostring(self.clientVersion),tostring(self.interface),tostring(c.collection),tostring(c.sets),tostring(c.nativeTransmog),tostring(c.newOutfit),tostring(c.newApply),tostring(c.legacyPending),tostring(c.legacyApply),tostring(self:IsAtTransmogVendor()),tostring(self.lastStageError),tostring(self.lastApplyError))
end
