-- VDS_DecorInfoResolver
-- ============================================================================
-- Module facade for C_HousingCatalog. Lazily resolves decor-info on demand --
-- selectors stay pure; the widget's push fn calls Resolve() at paint time
-- (per ADR-001 amended 2026-05-20).
--
-- The catalog is populated by Blizzard's CreateCatalogSearcher. A
-- GetCatalogEntryInfoByRecordID call before priming returns nil, which the
-- modelPreview widget renders as a placeholder. The resolver primes the
-- catalog at module-OnEnable (eager prime, not lazy first-Resolve) so the
-- first Canvas open after login flips from placeholder to model the moment
-- CATALOG_READY fires.
--
-- OWNS:        C_HousingCatalog.*  (per invariant §13, ADR-001 amended)
-- FORBIDDEN:   any selector calling C_HousingCatalog.* directly. Selectors
--              that depend on the catalog read session.catalog.tick via the
--              catalog.ready selector and re-resolve on tick change.

VDS = VDS or {}

local A = VDS.Constants.ACTIONS

VDS.Modules:Declare({
    name = "DecorInfoResolver",
    dependencies = {},

    ownsBlizzardNamespaces = { "C_HousingCatalog" },

    -- Cold-session retry: housing catalog populates async after login. Init()
    -- can complete with zero results before the catalog is ready. These events
    -- fire as the catalog hydrates server-side; OnCatalogPopulating retries
    -- Init when dyeableDecor is still empty. Without this, Canvas paints
    -- empty until /reload.
    blizzardEvents = {
        HOUSING_CATALOG_CATEGORY_UPDATED    = { handler = "OnCatalogPopulating", debounce = 0.5 },
        HOUSING_CATALOG_SUBCATEGORY_UPDATED = { handler = "OnCatalogPopulating", debounce = 0.5 },
    },

    onEnable = function(self)
        -- All catalog hydration -- snapshot rehydrate AND live searcher Init --
        -- is deferred to the first "window shown + canvas tab active" moment.
        -- Mirrors Blizzard_HousingDashboardCatalog.lua:OneTimeInit (delayed to
        -- OnShow) and HousingDashboard's LoadOnDemand TOC -- the catalog
        -- backend isn't reliably ready at PLAYER_LOGIN on fresh chars, but it
        -- is by the time a user clicks into a housing-related UI.
        --
        -- onEnable subscribes only; no state mutation here.
        VDS.Log:Debug("catalog", "[C0] onEnable -> subscribe (hydrate deferred until Canvas open)")

        -- Wake conditions, all gated on "window is shown AND canvas tab is active":
        --   1. User clicks Canvas chip (SET_FILTER_MODE)
        --   2. User opens the main window with Canvas as saved view (MAIN_WINDOW_SHOW / TOGGLE)
        --   3. User re-enters Canvas after a failed cold-start (status=failed -> reset + hydrate)
        local A_SET_FILTER_MODE    = VDS.Constants.ACTIONS.SET_FILTER_MODE
        local A_MAIN_WINDOW_SHOW   = VDS.Constants.ACTIONS.MAIN_WINDOW_SHOW
        local A_MAIN_WINDOW_TOGGLE = VDS.Constants.ACTIONS.MAIN_WINDOW_TOGGLE
        self._storeToken = VDS.Store:Subscribe(function(actionType)
            if actionType ~= A_SET_FILTER_MODE
                and actionType ~= A_MAIN_WINDOW_SHOW
                and actionType ~= A_MAIN_WINDOW_TOGGLE then return end
            local s = VDS.Store:GetState()  -- top-level only: hydrate-deferral subscriber
            if not s.session.ui.windowShown then return end
            if s.session.ui.filterMode ~= "canvas" then return end
            if not self._initialized then
                VDS.Log:Debug("catalog", string.format("[C0] %s + canvas + shown -> Hydrate", actionType))
                self:Hydrate()
            elseif s.session.canvas.catalog.status == "failed" then
                -- Full reset: clear restart budget, ready flag, init flag,
                -- so the next Hydrate starts as if from a fresh /reload.
                VDS.Log:Debug("catalog", string.format("[C0] %s + canvas + status=failed -> reset + Hydrate", actionType))
                self._initialized    = false
                self._restartCount   = 0
                self._readyFired     = false
                self._phase          = nil
                self._storedByRecord = nil
                self:Hydrate()
            end
        end)
    end,

    -- Single entry point for catalog hydration. Two phases:
    --   1. Snapshot rehydrate -- instant paint from SavedVariables snapshot
    --   2. Live searcher Init -- kicks Blizzard's C_HousingCatalog searcher;
    --      results overwrite the snapshot when they arrive.
    --
    -- Called once per "user enters Canvas while window is shown" event (gated
    -- by self._initialized, which Init sets on first run). Tab-switching back
    -- into Canvas after the first run is a no-op unless the catalog failed.
    Hydrate = function(self)
        local cfg = VDS.Store:GetState().account.config
        local snap = VDS.Store:GetState().account.canvas.catalogSnapshot

        -- forceCatalogEmpty (debug toggle via /vds forcefail): skip rehydrate
        -- so the retry/failed UI is actually visible. Otherwise the snapshot
        -- would mask the "Loading.../Retrying.../Failed" path.
        if cfg.forceCatalogEmpty then
            VDS.Log:Debug("catalog", "[C0] forceCatalogEmpty=true -> skip snapshot rehydrate")
        elseif snap and snap.items and #snap.items > 0 then  -- exception(nullable): no snapshot saved yet on fresh accounts
            -- Dispatches the same action a live searcher would, with
            -- fromSnapshot=true so the reducer skips the re-save (we'd
            -- overwrite the snapshot with itself). User sees last-session
            -- catalog instantly; live searcher below overwrites when it returns.
            VDS.Log:Debug("catalog", string.format(
                "[C0] rehydrate from snapshot: %d items, ts=%d",
                #snap.items, snap.ts or 0))  -- exception(nullable): pre-ts snapshots have no timestamp
            VDS.Store:Dispatch({
                type = A.CANVAS_CATALOG_UPDATE,
                payload = {
                    items         = snap.items,
                    categoryNames = snap.categoryNames or {},
                    fromSnapshot  = true,
                },
            })
        end

        self:Init()
    end,

    OnCatalogPopulating = function(self, ...)
        local arg = (select("#", ...) > 0) and tostring((select(1, ...))) or "-"
        -- [C3] visible behind /vds debug. MCP gotcha on CreateCatalogSearcher
        -- recommends these events as a recovery mitigation; user observation
        -- 2026-05-25 suggests they may not fire in normal sessions. Turn on
        -- /vds debug, open Canvas, watch whether [C3] prints to verify.
        VDS.Log:Debug("catalog", string.format(
            "[C3] HOUSING_CATALOG event arg=%s readyFired=%s searcherInFlight=%s",
            arg, tostring(self._readyFired), tostring(self._searcher ~= nil)))
        -- Canonical one-shot pattern per WoW API MCP gotcha on
        -- C_HousingCatalog.CreateCatalogSearcher: "with a _readyFired guard
        -- so you don't loop." Catalog is static-for-session by design;
        -- /reload is the recovery path for "I bought new decor mid-session."
        if self._readyFired then
            VDS.Log:Debug("catalog", "[C4] OnCatalogPopulating: skip (readyFired)")
            return
        end
        -- Searcher exists -> wake it (re-poll). Replaces the prior "skip
        -- if searcher in flight" guard, which left users stuck when the
        -- searcher had been created but callback never fired (Reddit-
        -- reported "Loading dyeable decor..." forever).
        if self._searcher then
            VDS.Log:Debug("catalog", "[C4] OnCatalogPopulating: re-poll existing searcher")
            self:OnSearchResults()
            return
        end
        -- No searcher: Init was never called (user hasn't opened Canvas) or
        -- Init was abandoned (poll exhausted). Re-arm + Hydrate (snapshot
        -- rehydrate + Init in one call).
        VDS.Log:Debug("catalog", "[C4] OnCatalogPopulating: re-arm + Hydrate")
        self._initialized = false
        self._phase = nil
        self._storedByRecord = nil
        self:Hydrate()
    end,

    -- LESSON (HDGR DecorInfoResolver): hold the searcher in a field or GC drops the weak-ref userdata mid-flight.
    -- Config mirrors Blizzard's OWN HousingCatalogFrameMixin:OneTimeInit
    -- (Blizzard_HousingDashboardCatalog.lua) verbatim. The KEY param is
    -- SetEditorModeContext(BasicDecor) -- without it, the searcher returns
    -- empty even though the catalog is loaded server-side. Verified 2026-05-24
    -- by inspecting Gethe source: Blizzard sets context on every searcher,
    -- ATT and HDGR don't and rely on first-searcher-per-session timing luck.
    Init = function(self)
        if self._initialized then
            VDS.Log:Debug("catalog", "[C1] Init: skip (already initialized)")
            return
        end
        self._initialized = true

        local searcher = _G.C_HousingCatalog.CreateCatalogSearcher()
        self._searcher = searcher                   -- pin against GC

        searcher:SetResultsUpdatedCallback(function() self:OnSearchResults() end)

        -- Full Blizzard config from HousingCatalogFrameMixin:OneTimeInit +
        -- HousingCatalogFiltersMixin defaults. WITHOUT all of these, the
        -- searcher returns empty -- the indoor/outdoor location filters and
        -- the filter tag groups default to "nothing selected" = "no results".
        -- This is a layered AND of every filter; any one being empty kills
        -- the result set. Verified 2026-05-24 by reading
        -- Blizzard_HousingDashboardCatalog.lua + Blizzard_HousingCatalogFilters.lua.
        -- Boundary block: the searcher is Blizzard userdata whose method
        -- surface varies by patch; each setter is probed before use so a
        -- renamed/removed setter degrades to Blizzard's default filter
        -- instead of hard-erroring the catalog.
        if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(false) end  -- exception(boundary): patch-variant searcher API
        if searcher.SetStoredOnly                then searcher:SetStoredOnly(false)               end  -- exception(boundary): patch-variant searcher API
        if searcher.SetBaseVariantOnly           then searcher:SetBaseVariantOnly(true)           end  -- exception(boundary): patch-variant searcher API
        if searcher.SetEditorModeContext and _G.Enum and _G.Enum.HouseEditorMode then  -- exception(boundary): patch-variant searcher API + Enum
            searcher:SetEditorModeContext(_G.Enum.HouseEditorMode.BasicDecor)
        end
        -- VDS-specific: SetCustomizableOnly(true). "Customizable" in catalog
        -- terms = has dye slots = dyeable. This is exactly the set VDS cares
        -- about, so let the searcher pre-filter (faster) instead of returning
        -- all 7000+ decor and us filtering in _buildFromResults. Blizzard's
        -- catalog UI uses (false) because they show all decor.
        if searcher.SetCustomizableOnly          then searcher:SetCustomizableOnly(true)          end  -- exception(boundary): patch-variant searcher API
        if searcher.SetAllowedIndoors            then searcher:SetAllowedIndoors(true)            end  -- exception(boundary): patch-variant searcher API
        if searcher.SetAllowedOutdoors           then searcher:SetAllowedOutdoors(true)           end  -- exception(boundary): patch-variant searcher API
        if searcher.SetCollected                 then searcher:SetCollected(true)                 end  -- exception(boundary): patch-variant searcher API
        if searcher.SetUncollected               then searcher:SetUncollected(true)               end  -- exception(boundary): patch-variant searcher API
        if searcher.SetFirstAcquisitionBonusOnly then searcher:SetFirstAcquisitionBonusOnly(false) end  -- exception(boundary): patch-variant searcher API

        -- Enable every filter tag in every tag group. Without this, items
        -- match by location + collected state but fail the tag-group filter
        -- (which defaults to nothing selected = nothing matches).
        if searcher.SetAllInFilterTagGroup and _G.C_HousingCatalog.GetAllFilterTagGroups then
            local groups = _G.C_HousingCatalog.GetAllFilterTagGroups() or {}
            for _, group in ipairs(groups) do
                if group.groupID then
                    searcher:SetAllInFilterTagGroup(group.groupID, true)
                end
            end
            VDS.Log:Debug("catalog", string.format("[C1] enabled %d filter tag groups", #groups))
        end

        if searcher.SetAutoUpdateOnParamChanges then searcher:SetAutoUpdateOnParamChanges(true) end  -- exception(boundary): patch-variant searcher API

        VDS.Log:Debug("catalog", "[C1] Init: searcher created (full Blizzard config), RunSearch")
        searcher:RunSearch()

        -- Status dispatch -- first attempt or a retry restart.
        local statusAttempt = self._restartCount or 0  -- exception(nullable): nil until first retry restart
        VDS.Store:Dispatch({
            type = A.CATALOG_STATUS_SET,
            payload = {
                status  = (statusAttempt > 0) and "retrying" or "loading",
                attempt = statusAttempt,
            },
        })

        -- Secondary-searcher fallback + active retry: SetResultsUpdatedCallback
        -- only fires on UPDATES. If another addon (e.g. HDGR) already primed
        -- the catalog before our onEnable, there's no further "update" event
        -- and our callback never fires -- even though GetCatalogSearchResults
        -- would return populated data. Poll once a second; on 5 consecutive
        -- empty polls, recreate the searcher (3 restart attempts max). After
        -- that, dispatch CATALOG_STATUS_SET status="failed" so the emptyHint
        -- selector tells the user to /reload.
        self._pollCount    = 0
        self._emptyTicks   = 0
        local MAX_POLL     = 10  -- per-searcher poll budget
        local EMPTY_BEFORE_RESTART = 5
        local MAX_RESTARTS = 3
        self._pollTimer = _G.C_Timer.NewTicker(1, function()
            self._pollCount = self._pollCount + 1
            self:OnSearchResults()

            -- Success: OnSearchResults nils self._searcher after dispatch.
            if self._searcher == nil then
                if self._pollTimer then self._pollTimer:Cancel(); self._pollTimer = nil end
                return
            end

            -- Searcher still in flight after the call = no dispatch happened
            -- (either zero raw results, or all rows filtered out by
            -- _buildFromResults' canCustomize/asset gate, or forceCatalogEmpty
            -- debug flag). Count it as an empty tick regardless of WHY; the
            -- retry logic only cares whether useful data reached the Store.
            self._emptyTicks = self._emptyTicks + 1
            if self._emptyTicks >= EMPTY_BEFORE_RESTART then
                -- 5s of nothing. Recreate the searcher; same Init path runs.
                self._restartCount = (self._restartCount or 0) + 1
                if self._restartCount > MAX_RESTARTS then
                    VDS.Log:Debug("catalog", string.format(
                        "[C6] poll: %d restarts exhausted, giving up (user /reload to retry)",
                        MAX_RESTARTS))
                    VDS.Store:Dispatch({
                        type = A.CATALOG_STATUS_SET,
                        payload = { status = "failed", attempt = self._restartCount - 1 },
                    })
                    if self._pollTimer then self._pollTimer:Cancel(); self._pollTimer = nil end
                    return
                end
                VDS.Log:Debug("catalog", string.format(
                    "[C6] poll: %d empty ticks -> restart searcher (attempt %d/%d)",
                    self._emptyTicks, self._restartCount, MAX_RESTARTS))
                -- Tear down current searcher; OnCatalogPopulating-equivalent
                -- re-arm. Init creates a fresh searcher + restarts poll.
                self._searcher = nil
                self._emptyTicks = 0
                self._initialized = false
                self._phase = nil
                self._storedByRecord = nil
                if self._pollTimer then self._pollTimer:Cancel(); self._pollTimer = nil end
                self:Init()
                return
            end

            -- Per-searcher poll cap reached. If we never dispatched and
            -- haven't restarted, give up loudly.
            if self._pollCount >= MAX_POLL then
                if self._pollTimer then self._pollTimer:Cancel(); self._pollTimer = nil end
                if self._searcher then
                    VDS.Log:Debug("catalog", string.format(
                        "[C6] poll: gave up after %d attempts (no restart triggered)", MAX_POLL))
                end
                return
            end
        end)
    end,

    -- Dispatched once per session; no clean "done" signal on the searcher so we fire on first non-empty result.
    OnCatalogReady = function(self)
        if self._readyFired then return end
        self._readyFired = true
        VDS.Store:Dispatch({ type = A.CATALOG_READY })
        -- Status -> loaded. Reset restart counter so any future stuck-state
        -- (e.g. catalog regression after a hotfix) starts fresh.
        VDS.Store:Dispatch({
            type = A.CATALOG_STATUS_SET,
            payload = { status = "loaded", attempt = 0 },
        })
        self._restartCount = 0
        local s = VDS.Store:GetState().session.canvas
        local catCount = 0
        for _ in pairs(s.decorCategoryNames or {}) do catCount = catCount + 1 end
        VDS.Log:Info("catalog",
            ("housing catalog ready: %d decor / %d categories")
                :format(#s.dyeableDecor, catCount))
    end,

    -- Build `byRecord, order` from the searcher's current results. Ports donor
    -- VDS_Canvas.lua:62-126: collapse N variant rows per base record, pick
    -- first variant with dyeSlots for channel count, accumulate isOwned.
    _buildFromResults = function(self, results)
        local byRecord, order = {}, {}
        for _, entryID in ipairs(results) do
            local info = _G.C_HousingCatalog.GetCatalogEntryInfo(entryID)
            if info and info.canCustomize and info.asset then  -- exception(boundary): GetCatalogEntryInfo can return nil/partial
                local rid = entryID.recordID
                local item = byRecord[rid]
                if not item then
                    local catID = info.categoryIDs and info.categoryIDs[1] or nil
                    item = {
                        decorID    = rid,
                        name       = info.name or ("decor " .. rid),
                        asset      = info.asset,
                        isOwned    = false,
                        icon       = info.iconTexture,
                        channels   = nil,
                        categoryID = catID,
                    }
                    byRecord[rid] = item
                    order[#order + 1] = item
                end
                local owned = ((info.quantity or 0)
                             + (info.remainingRedeemable or 0)
                             + (info.numPlaced or 0)) > 0
                if owned then item.isOwned = true end

                -- Base record (variantIdentifier == 0) has no dyeSlots; WOWAPIMCP gotcha "channel-count.uncollected-decor".
                if not item.channels
                   and entryID.variantIdentifier and entryID.variantIdentifier ~= 0 then
                    local vi = _G.C_HousingCatalog.GetCatalogEntryVariantInfo(entryID)
                    if vi and vi.dyeSlots and #vi.dyeSlots > 0 then  -- exception(boundary): GetCatalogEntryVariantInfo can return nil/partial
                        local maxCh = -1
                        for _, slot in ipairs(vi.dyeSlots) do
                            if slot.channel and slot.channel > maxCh then maxCh = slot.channel end
                        end
                        if maxCh >= 0 then item.channels = maxCh + 1 end
                    end
                end
            end
        end
        -- Surface DB-only decor (in the channel DB but not this character's
        -- catalog). The ALWAYS_APPEND_DECOR set (Lunar Celebrant's) resolves +
        -- preview-dyes cleanly, so it's shown for everyone (no tag). The
        -- /vds alldecor debug flag additionally surfaces ALL DB-only decor,
        -- tagged "[DB]". Name + model resolved by recordID; channels from the DB.
        local ALWAYS   = VDS.Constants.ALWAYS_APPEND_DECOR
        local debugAll = VDS.Store:GetState().account.config.debugAllDecor  -- top-level only: debug append
        local db       = _G.VDS_DecorDyeSlotsDB
        for decorID in pairs(db) do
            if not byRecord[decorID] and (debugAll or ALWAYS[decorID]) then
                local info = self:Resolve(decorID)
                if info and info.canCustomize and info.asset then  -- exception(boundary): Resolve nil/partial for a recordID not in this catalog
                    local item = {
                        decorID    = decorID,
                        name       = (info.name or ("decor " .. decorID)) .. (ALWAYS[decorID] and "" or " [DB]"),
                        asset      = info.asset,
                        isOwned    = false,
                        icon       = info.iconTexture,
                        channels   = #db[decorID],
                        categoryID = info.categoryIDs and info.categoryIDs[1] or nil,
                    }
                    byRecord[decorID] = item
                    order[#order + 1] = item
                end
            end
        end
        return byRecord, order
    end,

    -- Single-pass search: SetCollected(true) + SetUncollected(true) returns
    -- both owned and unowned decor in one shot. _buildFromResults sets isOwned
    -- per-entry from info.quantity + remainingRedeemable + numPlaced.
    -- Callback may fire multiple times as results stream; we wait for the
    -- searcher to report not-in-progress before reading.
    OnSearchResults = function(self)
        local searcher = self._searcher
        if not searcher then
            VDS.Log:Debug("catalog", "[C2] OnSearchResults: skip (searcher nil; already dispatched)")
            return
        end

        local results = searcher:GetCatalogSearchResults() or {}
        -- Debug-only: /vds forcefail forces results to empty so the retry
        -- and "Catalog unavailable" failure-mode UX can be exercised
        -- without an actual broken searcher.
        if VDS.Store:GetState().account.config.forceCatalogEmpty then
            VDS.Log:Debug("catalog", string.format(
                "[C2] OnSearchResults: forceCatalogEmpty=true -> faking %d -> 0 results",
                #results))
            results = {}
        end
        if #results == 0 then
            -- Diagnostic: GetAllSearchItems ignores filters, returns everything
            -- in catalog scope. If it has data but GetCatalogSearchResults
            -- doesn't, our filters are the problem (not cold cache).
            local allItems = (searcher.GetAllSearchItems and searcher:GetAllSearchItems()) or {}
            local numItems = (searcher.GetNumSearchItems and searcher:GetNumSearchItems()) or -1
            VDS.Log:Debug("catalog", string.format(
                "[C2] OnSearchResults: filtered=0 allSearchItems=%d numSearchItems=%d (waiting)",
                #allItems, numItems))
            return
        end

        local _byRecord, order = self:_buildFromResults(results)
        VDS.Log:Debug("catalog", string.format(
            "[C2] OnSearchResults: rawResults=%d builtItems=%d",
            #results, #order))

        table.sort(order, function(a, b) return (a.name or "") < (b.name or "") end)

        local categoryNames = {}
        for _, item in ipairs(order) do
            local cid = item.categoryID
            if cid and not categoryNames[cid] then
                local ci = _G.C_HousingCatalog.GetCatalogCategoryInfo(cid)
                categoryNames[cid] = ci and ci.name or ("Category " .. cid)  -- exception(boundary): GetCatalogCategoryInfo can miss
            end
        end

        VDS.Log:Debug("catalog", string.format(
            "[C2] OnSearchResults: dispatching CANVAS_CATALOG_UPDATE items=%d categories=%d",
            #order, (function() local n=0; for _ in pairs(categoryNames) do n=n+1 end; return n end)()))
        VDS.Store:Dispatch({
            type = A.CANVAS_CATALOG_UPDATE,
            payload = { items = order, categoryNames = categoryNames },
        })
        self:OnCatalogReady()

        -- Canonical one-shot per WoW API MCP gotcha on CreateCatalogSearcher:
        -- "swaps the callback to a no-op stub (NOT nil) so the closure can
        -- exit and GC can reclaim both." Catalog is static-for-session;
        -- /reload is the recovery path for mid-session changes. Recovery
        -- from a stuck cold-start lives in the poll timer + the
        -- HOUSING_CATALOG_* events' _readyFired-gated re-Init in
        -- OnCatalogPopulating.
        searcher:SetResultsUpdatedCallback(function() end)
        self._searcher = nil
    end,

    -- ADR-001: WoW API IS the cache; never memoize. Returns nil -> widget paints placeholder.
    Resolve = function(self, decorID)
        -- 12.0.5: (entryType, recordID); WOWAPIMCP confirmed no tryGetOwnedInfo arg.
        local entryType = _G.Enum.HousingCatalogEntryType.Decor
        return _G.C_HousingCatalog.GetCatalogEntryInfoByRecordID(entryType, decorID)
    end,

    ResolveByItem = function(self, itemID)
        return _G.C_HousingCatalog.GetCatalogEntryInfoByItem(itemID)
    end,

    -- Debug re-search: clears the one-shot Init guard and re-runs the catalog
    -- search so a toggle (/vds alldecor) rebuilds the dyeable-decor list now,
    -- rather than waiting for /reload.
    Refresh = function(self)
        self._initialized = false
        self:Init()
    end,
})
