-- HousingDecorGuide/modules/HDG_Mogul.lua
-- Pure-function craft optimiser engine. Stateless. Unit-testable.

local HDG_Mogul = {}

-- ScoreRecipe: split a recipe's reagents into lumber vs non-lumber, compute mat
-- cost from price oracle in ctx, and produce per-craft profit fields. Lumber is
-- tracked per type (Sturdy / Polished / etc.) because each housing recipe binds
-- to one specific lumber tier; treating "lumber" as a single fungible bucket
-- would let the planner pick recipes the player can't actually craft.
--
-- Recipes with no lumber reagent are filtered out by callers (Mogul is the
-- LUMBER optimizer; lumberless decor recipes belong to other tabs).
--
-- recipe: { id, name, reagents = { { id, qty } ... }, salePrice, uncollected, ... }
-- ctx:    { lumberIDs = {[id]=true}, matPrices = {[id]=copper}, ... }
--
-- returns: {
--     lumberCost           = N (sum across all lumber reagents in this recipe; for display "X lumber/ea"),
--     bindingLumberQty     = N (max qty across tiers; bottleneck used as ranking divisor),
--     lumberReagents       = list of { id, qty } (one entry per lumber tier this recipe needs),
--     nonLumberMatCost     = copper (sum at oracle prices; missing price treated as 0),
--     salePrice            = copper (gross revenue per craft),
--     revenuePerCraft      = copper (= salePrice; mats assumed free/owned),
--     netProfitPerCraft    = copper (= salePrice - nonLumberMatCost; assumes all mats bought at AH),
--     nonLumberMats        = list of { id, qty } (kept for plan-stage owned-mat math),
-- }
function HDG_Mogul.ScoreRecipe(recipe, ctx)
    local lumberIDs = ctx.lumberIDs or {}
    local matPrices = ctx.matPrices or {}
    local lumberCost = 0
    local bindingLumberQty = 0
    local lumberReagents = {}
    local nonLumberMatCost = 0
    local nonLumberMats = {}
    for _, reagent in ipairs(recipe.reagents or {}) do
        local n = reagent.qty
        if lumberIDs[reagent.id] then
            lumberCost = lumberCost + n
            if n > bindingLumberQty then bindingLumberQty = n end
            lumberReagents[#lumberReagents + 1] = { id = reagent.id, qty = n }
        else
            nonLumberMats[#nonLumberMats + 1] = { id = reagent.id, qty = n }
            local price = matPrices[reagent.id] or 0
            nonLumberMatCost = nonLumberMatCost + price * n
        end
    end
    local salePrice = recipe.salePrice or 0
    return {
        lumberCost        = lumberCost,
        bindingLumberQty  = bindingLumberQty,
        lumberReagents    = lumberReagents,
        nonLumberMatCost  = nonLumberMatCost,
        salePrice         = salePrice,
        revenuePerCraft   = salePrice,
        netProfitPerCraft = salePrice - nonLumberMatCost,
        nonLumberMats     = nonLumberMats,
    }
end

local function copyTable(t)
    local out = {}
    for k, v in pairs(t) do out[k] = v end
    return out
end

local function craftableWithOwned(score, remainingMats)
    if #score.nonLumberMats == 0 then return math.huge end
    local cap = math.huge
    for _, m in ipairs(score.nonLumberMats) do
        local owned = remainingMats[m.id] or 0
        if m.qty > 0 then
            local k = math.floor(owned / m.qty)
            if k < cap then cap = k end
        end
    end
    return cap
end

local function marginalMatCost(score, remainingMats, matPrices)
    local cost = 0
    for _, m in ipairs(score.nonLumberMats) do
        local owned = remainingMats[m.id] or 0
        local needToBuy = m.qty - owned
        if needToBuy > 0 then
            cost = cost + needToBuy * (matPrices[m.id] or 0)
        end
    end
    return cost
end

-- Max crafts of `score` that the per-type lumber budget supports. Each lumber
-- reagent must individually fit; the cap is the strictest type. Returns 0 when
-- any required type is exhausted.
local function lumberCap(score, remainingLumber)
    if #score.lumberReagents == 0 then return math.huge end
    local cap = math.huge
    for _, lr in ipairs(score.lumberReagents) do
        if lr.qty > 0 then
            local have = remainingLumber[lr.id] or 0
            local k = math.floor(have / lr.qty)
            if k < cap then cap = k end
        end
    end
    return cap
end

local function deductLumber(score, K, remainingLumber)
    for _, lr in ipairs(score.lumberReagents) do
        local have = remainingLumber[lr.id] or 0
        remainingLumber[lr.id] = have - K * lr.qty
    end
end

local function totalLumberRemaining(remainingLumber)
    local sum = 0
    for _, n in pairs(remainingLumber) do sum = sum + n end
    return sum
end

-- BuildProfitPlan is a per-lumber profit greedy with owned-mat re-evaluation.
-- It is a heuristic, not provably optimal: greedy can underperform LP/MILP on
-- contrived shared-mat / multi-tier instances. In practice on real housing
-- recipe data the gap is small (~2-5%); see code review notes for upgrade
-- options if optimality concerns surface in playtest.
local function BuildProfitPlan(opts)
    local lumberBudgets = opts.lumberBudgets or {}
    local candidates = opts.candidates or {}
    local ownedMats = opts.ownedMats or {}
    local matPrices = opts.matPrices or {}
    local lumberIDs = opts.lumberIDs or {}
    local optimizeBy = opts.optimizeBy or "lumberOnly"

    -- Supply-impact: market-pressure model. mode = off (no change), smooth (per-unit
    -- decay so the Nth craft of the same recipe is worth less in ranking), or cap
    -- (hard ceiling per recipe). Cap and smooth are mutually exclusive modes; only
    -- the active one's number is consulted.
    local siMode  = (opts.supplyImpact and opts.supplyImpact.mode) or "off"
    local siCap   = (opts.supplyImpact and opts.supplyImpact.cap) or 10
    local siDecay = (opts.supplyImpact and opts.supplyImpact.decay) or 0.07

    -- Pre-score every candidate once. Recipes with zero lumber are skipped:
    -- Mogul is the lumber optimizer, lumberless recipes don't belong here.
    local scored = {}
    for _, recipe in ipairs(candidates) do
        local s = HDG_Mogul.ScoreRecipe(recipe, { lumberIDs = lumberIDs, matPrices = matPrices })
        s.recipe = recipe
        if s.lumberCost > 0 then scored[#scored + 1] = s end
    end

    local remainingLumber = copyTable(lumberBudgets)        -- map of {[lumberItemID] = qty}
    local initialTotal = totalLumberRemaining(remainingLumber)
    local remainingMats = copyTable(ownedMats)
    local matsBoughtPerID = {}      -- itemID -> qty bought
    local actualMatSpend = 0        -- copper actually spent buying mats (excludes owned mats consumed for free)
    local rows = {}
    local merged = {}               -- recipe.id -> rows index, to merge sequential picks of same recipe
    local craftedSoFar = {}         -- recipe.id -> count of crafts already chosen this plan (for supply-impact)

    while true do
        local bestScore, best = -math.huge, nil
        for _, s in ipairs(scored) do
            local already = craftedSoFar[s.recipe.id] or 0
            -- Cap mode: refuse to consider recipes that have hit their per-recipe ceiling.
            local capOK = (siMode ~= "cap") or (already < siCap)
            if capOK and lumberCap(s, remainingLumber) >= 1 then
                local marginal = marginalMatCost(s, remainingMats, matPrices)
                local effectiveProfitPerCraft
                if optimizeBy == "lumberOnly" then
                    effectiveProfitPerCraft = s.salePrice
                else
                    effectiveProfitPerCraft = s.salePrice - marginal
                end
                if effectiveProfitPerCraft > 0 then
                    -- Rank by profit per BOTTLENECK lumber (not sum across tiers). For single-tier
                    -- recipes bindingLumberQty == lumberCost, no behavior change. For multi-tier
                    -- it ranks by the binding constraint, matching what lumberCap actually enforces.
                    local perLumber = effectiveProfitPerCraft / s.bindingLumberQty
                    -- Smooth mode: discount the marginal value of this unit by decay^already.
                    -- The plan composition diversifies; reported revenue stays literal (this
                    -- decay is the market-pressure model used only for ranking decisions).
                    if siMode == "smooth" and siDecay > 0 then
                        perLumber = perLumber * ((1 - siDecay) ^ already)
                    end
                    if perLumber > bestScore then
                        bestScore = perLumber
                        best = s
                    end
                end
            end
        end
        if not best then break end

        -- K = min(max(craftWithOwned, 1), lumberCap). The max(_,1) guarantees at least
        -- one craft when no owned non-lumber mats remain, so the loop re-evaluates between
        -- crafts and shared-mat depletion can shift which recipe wins next iteration.
        local craftWithOwned = craftableWithOwned(best, remainingMats)
        local lc = lumberCap(best, remainingLumber)
        local K = math.min(math.max(craftWithOwned, 1), lc)
        -- Cap mode: clip K so we don't overshoot the per-recipe ceiling.
        if siMode == "cap" then
            local already = craftedSoFar[best.recipe.id] or 0
            local headroom = siCap - already
            if headroom < K then K = headroom end
        end
        -- Smooth mode: force K=1 so each successive unit re-evaluates its decayed
        -- marginal value against other recipes. Without this the first pick would
        -- consume the full lumber budget for the top recipe before decay engages.
        if siMode == "smooth" and K > 1 then K = 1 end
        if K < 1 then break end

        -- Deduct lumber per-type, deplete owned mats, accumulate buys.
        deductLumber(best, K, remainingLumber)
        for _, m in ipairs(best.nonLumberMats) do
            local need = K * m.qty
            local owned = remainingMats[m.id] or 0
            local fromOwned = math.min(owned, need)
            remainingMats[m.id] = owned - fromOwned
            local buy = need - fromOwned
            if buy > 0 then
                matsBoughtPerID[m.id] = (matsBoughtPerID[m.id] or 0) + buy
                actualMatSpend = actualMatSpend + buy * (matPrices[m.id] or 0)
            end
        end

        -- Merge if the same recipe was picked again (rare but possible across loop iterations).
        if merged[best.recipe.id] then
            local idx = merged[best.recipe.id]
            rows[idx].crafts = rows[idx].crafts + K
        else
            rows[#rows + 1] = { recipe = best.recipe, crafts = K, score = best }
            merged[best.recipe.id] = #rows
        end
        craftedSoFar[best.recipe.id] = (craftedSoFar[best.recipe.id] or 0) + K
    end

    -- Aggregate totals. revenue sums per-craft salePrice; netProfit subtracts ACTUAL mat spend
    -- (not pre-scored full mat cost), so owned-mat consumption is correctly reflected.
    local totalRevenue = 0
    local totalCrafts = 0
    for _, row in ipairs(rows) do
        totalRevenue = totalRevenue + row.crafts * row.score.salePrice
        totalCrafts = totalCrafts + row.crafts
    end
    -- cap-limited: in cap mode, true when lumber went unspent because every
    -- candidate hit its per-recipe ceiling. The UI surfaces this as "(cap-limited)"
    -- on the Lumber-used line so the player can tell their cap was the binding
    -- constraint, not a bug in the planner.
    local lumberRemaining = totalLumberRemaining(remainingLumber)
    local capLimited = (siMode == "cap") and (lumberRemaining > 0) and (#rows > 0)
    local totals = {
        revenue       = totalRevenue,
        netProfit     = totalRevenue - actualMatSpend,
        lumberUsed    = initialTotal - lumberRemaining,
        lumberBudget  = initialTotal,
        crafts        = totalCrafts,
        matSpend      = actualMatSpend,
        capLimited    = capLimited,
    }

    -- Sort plan rows by total revenue (crafts * salePrice) descending so the
    -- biggest contributions surface first. This matches LP's row order so a
    -- visual side-by-side comparison reflects actual plan diffs, not display noise.
    table.sort(rows, function(a, b)
        return a.crafts * a.score.salePrice > b.crafts * b.score.salePrice
    end)

    local shoppingList = {}
    for matID, qty in pairs(matsBoughtPerID) do
        local price = matPrices[matID] or 0
        shoppingList[#shoppingList + 1] = {
            id        = matID,
            qty       = qty,
            unitPrice = price,
            totalCost = qty * price,
        }
    end
    table.sort(shoppingList, function(a, b) return a.totalCost > b.totalCost end)

    return {
        rows         = rows,
        totals       = totals,
        shoppingList = shoppingList,
        candidates   = scored,
    }
end

local function BuildCollectionPlan(opts)
    local lumberBudgets = opts.lumberBudgets or {}
    local candidates = opts.candidates or {}
    local ownedMats = opts.ownedMats or {}
    local matPrices = opts.matPrices or {}
    local lumberIDs = opts.lumberIDs or {}

    -- Collection mode candidates are uncollected-only by design: an already-collected
    -- recipe contributes zero new appearances, so it can never be a "runner-up" in the
    -- collection sense. The Show all candidates panel inherits this filter.
    local scored = {}
    for _, recipe in ipairs(candidates) do
        if (recipe.uncollected or 0) == 1 then
            local s = HDG_Mogul.ScoreRecipe(recipe, { lumberIDs = lumberIDs, matPrices = matPrices })
            s.recipe = recipe
            if s.lumberCost > 0 then scored[#scored + 1] = s end
        end
    end
    table.sort(scored, function(a, b) return a.lumberCost < b.lumberCost end)

    local remainingLumber = copyTable(lumberBudgets)
    local initialTotal = totalLumberRemaining(remainingLumber)
    local remainingMats = copyTable(ownedMats)
    local matsBoughtPerID = {}
    local actualMatSpend = 0
    local rows = {}
    for _, s in ipairs(scored) do
        if lumberCap(s, remainingLumber) >= 1 then
            deductLumber(s, 1, remainingLumber)
            for _, m in ipairs(s.nonLumberMats) do
                local owned = remainingMats[m.id] or 0
                local fromOwned = math.min(owned, m.qty)
                remainingMats[m.id] = owned - fromOwned
                local buy = m.qty - fromOwned
                if buy > 0 then
                    matsBoughtPerID[m.id] = (matsBoughtPerID[m.id] or 0) + buy
                    actualMatSpend = actualMatSpend + buy * (matPrices[m.id] or 0)
                end
            end
            rows[#rows + 1] = { recipe = s.recipe, crafts = 1, score = s }
        end
    end

    local totals = {
        newAppearances = #rows,
        lumberUsed     = initialTotal - totalLumberRemaining(remainingLumber),
        revenue        = 0,
        netProfit      = 0,
        crafts         = #rows,
        matSpend       = actualMatSpend,    -- copper spent on non-owned mats
    }
    local shoppingList = {}
    for matID, qty in pairs(matsBoughtPerID) do
        local price = matPrices[matID] or 0
        shoppingList[#shoppingList + 1] = {
            id        = matID,
            qty       = qty,
            unitPrice = price,
            totalCost = qty * price,
        }
    end
    table.sort(shoppingList, function(a, b) return a.totalCost > b.totalCost end)

    return {
        rows         = rows,
        totals       = totals,
        shoppingList = shoppingList,
        candidates   = scored,
    }
end

-- BuildPlan: produce a planned recipe mix from the per-type lumber budget.
-- opts: {
--   algorithm     = "greedy" | "lp" (default "greedy"),
--   mode          = "profit" | "collection",
--   optimizeBy    = "lumberOnly" | "lumberPlusMats" (profit mode only),
--   lumberBudgets = map { lumberItemID -> qty } -- per-type budgets; recipes
--                   bind to a specific lumber tier and check that tier's bucket,
--   candidates    = list of recipe tables (already filtered by view + decor + known),
--   ownedMats     = map { itemID -> qty } of non-lumber owned reagents,
--   matPrices     = map { itemID -> copper } of non-lumber mat AH/vendor prices,
--   lumberIDs     = map { itemID -> true } of housing lumber reagent IDs,
-- }
-- returns: { rows = { {recipe, crafts, score} ... }, totals = {...}, shoppingList = {...}, candidates = {...} }
function HDG_Mogul.BuildPlan(opts)
    -- LP path: defer to HDG_MogulLP when present and requested. The LP module
    -- handles its own collection-mode delegation back to greedy.
    if opts.algorithm == "lp" and _G.HDG_MogulLP and _G.HDG_MogulLP.BuildPlan then
        return _G.HDG_MogulLP.BuildPlan(opts)
    end
    if opts.mode == "collection" then
        return BuildCollectionPlan(opts)
    end
    return BuildProfitPlan(opts)
end

if _G then _G.HDG_Mogul = HDG_Mogul end
return HDG_Mogul
