-- VDS_Selectors_Warehouse
-- ============================================================================
-- Stock warehouse selectors: target/have/need from account.warehouse.targets
-- + session.dyes.catalog + session.stock. Returns structured data; widgets
-- compose strings (ADR-024).
--
-- Invalidation seam: `session.stock.tick` -- perItem is replaced wholesale
-- per scan, so binding to .perItem directly would miss updates.

local Selectors = VDS.Selectors

local function resolveTarget(state, dyeID, group)
    local override = state.account.warehouse.targetDyes[dyeID]
    if override ~= nil then return override end
    return state.account.warehouse.targetGroups[group] or 0  -- exception(nullable): no group target set = 0
end

-- ----------------------------------------------------------------------------
-- stock.fillMode -- "buy" or "craft"
-- ----------------------------------------------------------------------------
Selectors:Register("stock.fillMode", {
    reads = { "account.warehouse.fillMode" },
    fn    = function(state) return state.account.warehouse.fillMode end,
})

Selectors:Register("stock.craftMode", {
    reads = { "account.warehouse.fillMode" },
    fn    = function(state) return state.account.warehouse.fillMode == "craft" end,
})

-- Dynamic columns: Buy = single (warehouse body), Craft = two (warehouse + herb
-- side panel). View's `width = "auto"` sums these so the window resizes per mode.
Selectors:Register("view.stock.columns", {
    reads = { "account.warehouse.fillMode" },
    fn    = function(state)
        if state.account.warehouse.fillMode == "craft" then
            return { 420, 240 }
        end
        return { 420 }
    end,
})

-- stock.row.array -- warehouse rows in COLOR_GROUP_ORDER. Honors ownedOnly +
-- searchText filters. cartOnly is skipped: every warehouse row is implicitly
-- a cart target by design.
--
-- Row shapes:
--   { kind = "groupHead", group, have, target, need, targetValue, atTarget }
--   { kind = "warehouseRow", dyeID, itemID, name, group, color, have, target,
--     need, hasOverride, overstock, buyCost, craftCost }
Selectors:Register("stock.row.array", {
    reads = {
        "session.dyes.catalog",
        "session.stock.tick",
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
        "account.warehouse.fillMode",
        "account.config.ownedOnly",
        "session.ui.searchText",
        "session.items.tick",   -- async name resolution invalidation
        "account.warehouse.collapsedGroups",
    },
    fn    = function(state)
        local catalog = state.session.dyes.catalog
        if not catalog or not catalog.dyes or #catalog.dyes == 0 then return {} end

        local perItem    = state.session.stock.perItem
        local infos      = catalog.infos
        local SD         = VDS.StaticData
        local order      = SD.COLOR_GROUP_ORDER
        local ownedOnly  = state.account.config.ownedOnly
        local searchRaw  = state.session.ui.searchText
        local search     = (searchRaw and searchRaw ~= "") and searchRaw:lower() or nil
        local fillMode   = state.account.warehouse.fillMode

        -- Bucket by colorGroup, applying filters
        local buckets = {}
        for _, dyeID in ipairs(catalog.dyes) do
            local info = infos[dyeID]
            if info and info.itemID then  -- exception(nullable): catalog info/itemID can miss for unmapped dyes
                local have = perItem[info.itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
                local nameLow = info.nameLower
                local passes = true
                if ownedOnly and have == 0 then passes = false end
                if search and (not nameLow or not nameLow:find(search, 1, true)) then
                    passes = false
                end
                if passes then
                    local g = info.colorGroup or "Other"
                    buckets[g] = buckets[g] or {}
                    buckets[g][#buckets[g] + 1] = dyeID
                end
            end
        end

        -- Sort within bucket by name for stable display
        for _, b in pairs(buckets) do
            table.sort(b, function(a, c)
                return (infos[a].name or "") < (infos[c].name or "")
            end)
        end

        local rows = {}
        for _, group in ipairs(order) do
            local bucket = buckets[group]
            if bucket and #bucket > 0 then
                local groupHave, groupTarget, groupNeed = 0, 0, 0
                local groupDyeCount, groupAtTargetCount = 0, 0
                local groupCost = 0
                local dyeRows = {}
                for _, dyeID in ipairs(bucket) do
                    local info      = infos[dyeID]
                    local target    = resolveTarget(state, dyeID, group)
                    local have      = perItem[info.itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
                    local need      = math.max(0, target - have)
                    local overstock = target > 0 and have >= target
                    local hasOverride = state.account.warehouse.targetDyes[dyeID] ~= nil

                    -- Cost composition (pure-cache Auctionator reads; ADR-VDS-001).
                    local buyPrice = SD.GetAuctionPrice(info.itemID) or 0  -- exception(boundary): Auctionator optional; no price source = 0
                    local buyCost  = need * buyPrice

                    local recipe = SD.GetDyeRecipe(info.itemID)
                    local craftCost = 0
                    if recipe and recipe.qtyPerCraft > 0 then  -- exception(nullable): not every dye has a craft recipe
                        local pick = SD.PickCheapestHerb(recipe.herbItemIDs)
                        if pick then
                            craftCost = need * recipe.qtyPerCraft * pick.unitPrice
                        end
                    end

                    local rowCost = (fillMode == "buy") and buyCost or craftCost

                    groupHave   = groupHave + math.min(have, target)
                    groupTarget = groupTarget + target
                    groupNeed   = groupNeed + need
                    groupCost   = groupCost + rowCost
                    groupDyeCount = groupDyeCount + 1
                    if target > 0 and have >= target then
                        groupAtTargetCount = groupAtTargetCount + 1
                    end

                    dyeRows[#dyeRows + 1] = {
                        kind         = "warehouseRow",
                        dyeID        = dyeID,
                        itemID       = info.itemID,
                        name         = info.name,
                        group        = group,
                        color        = info.swatchRGB,
                        have         = have,
                        target       = target,
                        need         = need,
                        hasOverride  = hasOverride,
                        -- ADR-041 ed-projection: groupDefault on every dye row so
                        -- the override-line tooltip doesn't dive into Store mid-hover.
                        groupDefault = state.account.warehouse.targetGroups[group] or 0,  -- exception(nullable): no group target set = 0
                        overstock    = overstock,
                        buyCost      = buyCost,
                        craftCost    = craftCost,
                        rowCost      = rowCost,
                    }
                end

                local collapsed = state.account.warehouse.collapsedGroups[group] and true or false
                rows[#rows + 1] = {
                    kind        = "groupHead",
                    group       = group,
                    have        = groupHave,
                    target      = groupTarget,
                    need        = groupNeed,
                    cost        = groupCost,         -- summary aggregates this
                    dyeCount    = groupDyeCount,     -- summary aggregates this
                    atTargetCount = groupAtTargetCount,  -- summary aggregates this
                    -- ADR-041 ed-projection: group-header dispatcher reads
                    -- row._targetValue instead of diving into Store on click.
                    targetValue = state.account.warehouse.targetGroups[group] or 0,  -- exception(nullable): no group target set = 0
                    atTarget    = groupNeed == 0,
                    collapsed   = collapsed,
                }
                if not collapsed then
                    for _, r in ipairs(dyeRows) do rows[#rows + 1] = r end
                end
            end
        end
        return rows
    end,
})

-- ----------------------------------------------------------------------------
-- stock.summary -- top stat strip values
-- ----------------------------------------------------------------------------
Selectors:Register("stock.summary", {
    reads = {
        "session.dyes.catalog",
        "session.stock.tick",
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
        "account.warehouse.fillMode",
        "account.config.ownedOnly",
        "session.ui.searchText",
    },
    calls = { "stock.row.array" },
    fn    = function(state)
        -- Aggregate from group heads (always emitted, carry pre-summed totals).
        -- Per-dye rows are filtered out when collapsed, so aggregating those
        -- would zero out in the default all-collapsed state.
        local rows = Selectors:Call("stock.row.array", state, {})
        local atTarget, totalDyes, deficit, totalCost = 0, 0, 0, 0
        for _, r in ipairs(rows) do
            if r.kind == "groupHead" then
                totalDyes = totalDyes + (r.dyeCount or 0)
                atTarget  = atTarget  + (r.atTargetCount or 0)
                deficit   = deficit   + (r.need or 0)
                totalCost = totalCost + (r.cost or 0)
            end
        end
        local overrides = 0
        for _ in pairs(state.account.warehouse.targetDyes) do
            overrides = overrides + 1
        end
        return {
            atTarget = atTarget, totalDyes = totalDyes,
            deficit  = deficit,  totalCost = totalCost,
            overrides = overrides,
            fillMode = state.account.warehouse.fillMode,
        }
    end,
})

-- stock.buy.cart -- Buy-mode shopping list. ALL dyes with need > 0, irrespective
-- of view filters (ownedOnly / searchText / collapsed groups). The Send-to-AH
-- intent ships everything you're short on, not just what's visible in the list.
-- Entries: { dyeID, itemID, name, qty }, sorted by name for stable AH ordering.
Selectors:Register("stock.buy.cart", {
    reads = {
        "session.dyes.catalog",
        "session.stock",
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
        "session.items.names",
        "session.items.tick",
    },
    fn    = function(state)
        local catalog = state.session.dyes.catalog
        if not catalog or not catalog.dyes then return {} end
        local infos   = catalog.infos
        local perItem = state.session.stock.perItem
        local names   = state.session.items.names

        local out = {}
        for _, dyeID in ipairs(catalog.dyes) do
            local info = infos[dyeID]
            if info and info.itemID then  -- exception(nullable): catalog info/itemID can miss for unmapped dyes
                local group  = info.colorGroup or "Other"
                local target = resolveTarget(state, dyeID, group)
                local have   = perItem[info.itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
                local need   = math.max(0, target - have)
                if need > 0 then
                    out[#out + 1] = {
                        dyeID  = dyeID,
                        itemID = info.itemID,
                        name   = names[info.itemID] or info.name,  -- fallback to C_DyeColor name
                        qty    = need,
                    }
                end
            end
        end
        table.sort(out, function(a, b) return (a.name or "") < (b.name or "") end)
        return out
    end,
})

-- stock.herbs.list -- consolidated Craft-mode side panel. Entries:
-- { itemID, name, qty, unitPrice, lineCost }, sorted by lineCost desc.
--
-- When account.warehouse.buyGap is true (default), totals are reduced by what
-- the player already has in bag + bank + reagent bank + warband (BagObserver
-- writes those counts into session.stock.perItem). The popout + Send-to-AH
-- then reflect "what to BUY", not gross craft requirements. Flip the checkbox
-- off to see gross requirements.
Selectors:Register("stock.herbs.list", {
    reads = {
        "session.dyes.catalog",
        "session.stock",    -- tick is the invalidation seam; perItem is also read
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
        "account.warehouse.buyGap",
        "session.items.names",
        "session.items.tick",
    },
    fn    = function(state)
        local catalog = state.session.dyes.catalog
        if not catalog or not catalog.dyes then return {} end

        local infos   = catalog.infos
        local perItem = state.session.stock.perItem
        local SD      = VDS.StaticData
        local names   = state.session.items.names
        local buyGap  = state.account.warehouse.buyGap
        local totals  = {}

        for _, dyeID in ipairs(catalog.dyes) do
            local info = infos[dyeID]
            if info and info.itemID then  -- exception(nullable): catalog info/itemID can miss for unmapped dyes
                local group  = info.colorGroup or "Other"
                local target = resolveTarget(state, dyeID, group)
                local have   = perItem[info.itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
                local need   = math.max(0, target - have)
                if need > 0 then
                    local recipe = SD.GetDyeRecipe(info.itemID)
                    if recipe then
                        local pick = SD.PickCheapestHerb(recipe.herbItemIDs)
                        if pick then
                            totals[pick.itemID] = (totals[pick.itemID] or 0)
                                                  + need * recipe.qtyPerCraft
                        end
                    end
                end
            end
        end

        local rows = {}
        for itemID, grossQty in pairs(totals) do
            -- ownedQty is always painted in the row subline so the player
            -- can see their stock per-herb without toggling. qty (the main
            -- column) follows the "Use owned herbs" setting: net when on,
            -- gross when off. Off-rows still show ownership for context.
            local ownedQty = perItem[itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
            local qty = buyGap and math.max(0, grossQty - ownedQty) or grossQty
            if qty > 0 then
                local price = SD.GetAuctionPrice(itemID) or 0  -- exception(boundary): Auctionator optional; no price source = 0
                -- Leave name as nil on cache miss; row factory paints RETRIEVING_DATA
                -- placeholder AND calls ItemNameResolver:Request. If selector
                -- injected a placeholder string here the row factory's "if not name"
                -- gate would never fire and the async load would never kick.
                local name  = names[itemID]
                rows[#rows + 1] = {
                    kind      = "herbListItem",  -- scrollBoxList rowKinds dispatch key
                    itemID    = itemID,
                    qty       = qty,
                    grossQty  = grossQty,
                    ownedQty  = ownedQty,
                    unitPrice = price,
                    lineCost  = qty * price,
                    name      = name,
                }
            end
        end
        table.sort(rows, function(a, b) return a.lineCost > b.lineCost end)
        return rows
    end,
})

Selectors:Register("stock.herbs.summary", {
    reads = {
        "session.dyes.catalog",
        "session.stock.tick",
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
        "session.items.tick",
    },
    calls = { "stock.herbs.list" },
    fn    = function(state)
        local list = Selectors:Call("stock.herbs.list", state, {})
        local kinds, qty, total = 0, 0, 0
        for _, r in ipairs(list) do
            kinds = kinds + 1
            qty   = qty + r.qty
            total = total + r.lineCost
        end
        return { kinds = kinds, qty = qty, total = total }
    end,
})

Selectors:Register("stock.modeToggle.chips", {
    reads = { "account.warehouse.fillMode" },
    fn    = function(state)
        local m = state.account.warehouse.fillMode
        -- chip.action overrides the tabBar's default SET_FILTER_MODE so Buy/Craft
        -- writes warehouse.fillMode (not session.ui.filterMode with a bad bucket).
        local action = VDS.Constants.ACTIONS.STOCK_SET_FILL_MODE
        return {
            { id = "buy",   label = "Buy (Dyes)",    active = (m == "buy"),   action = action },
            { id = "craft", label = "Craft (Herbs)", active = (m == "craft"), action = action },
        }
    end,
})

-- Display-string selectors (ADR-024: selectors compose, widgets SetText).

-- Compact "Ng" / "Ns" formatter. Input is COPPER (Auctionator's unit);
-- 1g = 10,000 copper. Big totals (>= 10k g) abbreviate to "Nk g".
local function formatGold(copper)
    if not copper or copper <= 0 then return "--" end
    local gold = math.floor(copper / 10000)
    if gold >= 10000 then return string.format("%dk g", math.floor(gold / 1000)) end
    if gold >= 1 then return gold .. "g" end
    local silver = math.floor((copper % 10000) / 100)
    if silver >= 1 then return silver .. "s" end
    return "--"
end

Selectors:Register("stock.summary.label", {
    reads = {}, calls = { "stock.summary" },
    fn = function(state)
        local s = Selectors:Call("stock.summary", state, {})
        return string.format("%d/%d at target - %d short",
            s.atTarget, s.totalDyes, s.deficit)
    end,
})

Selectors:Register("stock.restock.label", {
    reads = { "account.warehouse.buyGap" },
    calls = { "stock.summary", "stock.herbs.totals" },
    fn = function(state)
        local s = Selectors:Call("stock.summary", state, {})
        if s.deficit == 0 then return "All at target" end

        -- Cost source mirrors what Send-to-AH will actually ship:
        --   buy mode: dye buy cost (stock.summary.totalCost = sum of buyCost rows)
        --   craft + buyGap on: net herb cost (after on-hand subtraction)
        --   craft + buyGap off: gross herb cost (every herb the crafts consume)
        local cost
        if s.fillMode == "craft" then
            local t = Selectors:Call("stock.herbs.totals", state, {})
            cost = state.account.warehouse.buyGap and t.netCost or t.grossCost
            if cost == 0 then return "Craft from on-hand" end
            return "Buy Herbs: " .. formatGold(cost)
        end
        return "Buy Dyes: " .. formatGold(s.totalCost)
    end,
})

Selectors:Register("stock.herbs.summary.label", {
    reads = {}, calls = { "stock.herbs.summary" },
    fn = function(state)
        local s = Selectors:Call("stock.herbs.summary", state, {})
        if s.kinds == 0 then return "--" end
        return string.format("%d kinds", s.kinds)
    end,
})

-- Bool selector for the Buy-gap checkbox above the Send-to-AH button.
Selectors:Register("stock.buyGap", {
    reads = { "account.warehouse.buyGap" },
    fn    = function(state) return state.account.warehouse.buyGap end,
})

-- Visibility gate: Buy-gap checkbox only matters in Craft mode. In Buy mode
-- the dye list is always net (target - owned) -- there's no "gross vs net"
-- distinction to toggle.
Selectors:Register("stock.mode.isCraft", {
    reads = { "account.warehouse.fillMode" },
    fn    = function(state) return state.account.warehouse.fillMode == "craft" end,
})

-- stock.herbs.totals -- always-on dual totals (gross + net) for the popout's
-- two footer rows. Walks the catalog independently of stock.herbs.list so
-- the values don't flip with the buyGap toggle. Gross = what the crafts
-- consume; Net = what's left after subtracting on-hand bag/bank/reagent/warband.
Selectors:Register("stock.herbs.totals", {
    reads = {
        "session.dyes.catalog",
        "session.stock",
        "account.warehouse.targetGroups",
        "account.warehouse.targetDyes",
    },
    fn = function(state)
        local catalog = state.session.dyes.catalog
        if not catalog or not catalog.dyes then
            return { kinds = 0, grossQty = 0, grossCost = 0, netQty = 0, netCost = 0 }
        end

        local infos   = catalog.infos
        local perItem = state.session.stock.perItem
        local SD      = VDS.StaticData
        local totals  = {}  -- itemID -> gross qty

        for _, dyeID in ipairs(catalog.dyes) do
            local info = infos[dyeID]
            if info and info.itemID then  -- exception(nullable): catalog info/itemID can miss for unmapped dyes
                local group  = info.colorGroup or "Other"
                local target = resolveTarget(state, dyeID, group)
                local have   = perItem[info.itemID] or 0  -- exception(nullable): sparse ownership map; miss = own 0
                local need   = math.max(0, target - have)
                if need > 0 then
                    local recipe = SD.GetDyeRecipe(info.itemID)
                    if recipe then
                        local pick = SD.PickCheapestHerb(recipe.herbItemIDs)
                        if pick then
                            totals[pick.itemID] = (totals[pick.itemID] or 0)
                                                  + need * recipe.qtyPerCraft
                        end
                    end
                end
            end
        end

        local kinds, grossQty, grossCost, netQty, netCost = 0, 0, 0, 0, 0
        for itemID, qty in pairs(totals) do
            kinds = kinds + 1
            local price   = SD.GetAuctionPrice(itemID) or 0  -- exception(boundary): Auctionator optional; no price source = 0
            local netRow  = math.max(0, qty - (perItem[itemID] or 0))
            grossQty  = grossQty  + qty
            grossCost = grossCost + qty * price
            netQty    = netQty    + netRow
            netCost   = netCost   + netRow * price
        end
        return { kinds = kinds, grossQty = grossQty, grossCost = grossCost,
                 netQty = netQty, netCost = netCost }
    end,
})

-- Footer-cell selectors: column-aligned dual totals (Required + To buy) at
-- the bottom of the herbs popout. Send-to-AH always ships the "To buy" total
-- when buyGap is on (default); the chat print should match those numbers.
Selectors:Register("stock.herbs.footer.required.qty.label", {
    reads = {}, calls = { "stock.herbs.totals" },
    fn = function(state)
        local t = Selectors:Call("stock.herbs.totals", state, {})
        if t.kinds == 0 then return "" end
        return "x" .. t.grossQty
    end,
})

Selectors:Register("stock.herbs.footer.required.cost.label", {
    reads = {}, calls = { "stock.herbs.totals" },
    fn = function(state)
        local t = Selectors:Call("stock.herbs.totals", state, {})
        if t.kinds == 0 then return "" end
        return formatGold(t.grossCost)
    end,
})

Selectors:Register("stock.herbs.footer.buy.qty.label", {
    reads = {}, calls = { "stock.herbs.totals" },
    fn = function(state)
        local t = Selectors:Call("stock.herbs.totals", state, {})
        if t.kinds == 0 then return "" end
        if t.netQty == 0 then return "--" end
        return "x" .. t.netQty
    end,
})

Selectors:Register("stock.herbs.footer.buy.cost.label", {
    reads = {}, calls = { "stock.herbs.totals" },
    fn = function(state)
        local t = Selectors:Call("stock.herbs.totals", state, {})
        if t.kinds == 0 then return "" end
        if t.netQty == 0 then return "--" end
        return formatGold(t.netCost)
    end,
})
