-- HousingDecorGuide/tests/test_mogul.lua
---@diagnostic disable: undefined-global
package.path = "./?.lua;" .. package.path
local t = require("test_helpers")

-- Stub the addon namespace + load engine in standalone Lua.
-- HDG_Mogul.lua must be runnable outside WoW for tests to work.
_G.HDG = _G.HDG or {}
local Mogul = dofile("../modules/HDG_Mogul.lua")

-- ----------------------------------------------------------------------------
-- Fixture builders
-- ----------------------------------------------------------------------------

local function makeRecipe(spec)
    return {
        itemID = spec.id,                              -- output decor itemID (specs still pass `id` for brevity)
        name = spec.name,
        spellID = spec.spell or (1000 + spec.id),
        profession = spec.profession or "Carpentry",
        expansion = spec.expansion or "Midnight",
        reagents = spec.reagents,                      -- list of {id, qty}
        salePrice = spec.salePrice,                    -- copper
        uncollected = spec.uncollected or 0,           -- 0 or 1
        knownByActive = spec.knownByActive ~= false,   -- default true
        bestAlt = spec.bestAlt,                        -- nil if active char knows it
    }
end

local function makeCtx(spec)
    -- Backward-compat: if `lumberBudget` (single number) is provided, distribute
    -- it onto the default lumber id 190316 so existing single-type tests keep
    -- working without per-test rewrites. New multi-type tests should pass
    -- `lumberBudgets = {[id] = count, ...}` directly.
    local lumberBudgets = spec.lumberBudgets
    if not lumberBudgets and spec.lumberBudget then
        lumberBudgets = { [190316] = spec.lumberBudget }
    end
    return {
        lumberBudgets = lumberBudgets or {},
        ownedMats = spec.ownedMats or {},              -- map of itemID -> count
        matPrices = spec.matPrices or {},              -- map of itemID -> copper
        lumberIDs = spec.lumberIDs or { [190316] = true }, -- single lumber ID by default
        candidates = spec.candidates,
        mode = spec.mode or "profit",
        optimizeBy = spec.optimizeBy or "lumberOnly",
        viewMode = spec.viewMode or "char",
    }
end

-- ----------------------------------------------------------------------------
-- Score model
-- ----------------------------------------------------------------------------

t.describe("HDG_Mogul.ScoreRecipe - lumber & non-lumber split", function()
    t.it("separates lumber cost from non-lumber mat cost", function()
        local r = makeRecipe({
            id = 9001, name = "Bookcase",
            reagents = { { id = 190316, qty = 24 }, { id = 200001, qty = 4 } },
            salePrice = 50000,
        })
        local ctx = makeCtx({ lumberIDs = { [190316] = true }, matPrices = { [200001] = 1500 } })
        local s = Mogul.ScoreRecipe(r, ctx)
        t.assert_eq(s.lumberCost, 24)
        t.assert_eq(s.nonLumberMatCost, 6000)            -- 4 * 1500
        t.assert_eq(s.salePrice, 50000)
    end)

    t.it("returns lumberCost 0 if no lumber reagent", function()
        local r = makeRecipe({
            id = 9002, name = "Lamp",
            reagents = { { id = 200001, qty = 1 } },
            salePrice = 10000,
        })
        local ctx = makeCtx({ matPrices = { [200001] = 800 } })
        local s = Mogul.ScoreRecipe(r, ctx)
        t.assert_eq(s.lumberCost, 0)
    end)

    t.it("lumber-only profit ignores non-lumber mat cost (non-lumber treated as free)", function()
        local r = makeRecipe({
            id = 9003, name = "Sconce",
            reagents = { { id = 190316, qty = 8 }, { id = 200002, qty = 2 } },
            salePrice = 20000,
        })
        local ctx = makeCtx({ matPrices = { [200002] = 1000 } })
        local s = Mogul.ScoreRecipe(r, ctx)
        t.assert_eq(s.revenuePerCraft, 20000)
    end)

    t.it("computes lumber+mats profit per craft (salePrice - matCost)", function()
        local r = makeRecipe({
            id = 9004, name = "Chest",
            reagents = { { id = 190316, qty = 12 }, { id = 200001, qty = 2 } },
            salePrice = 15000,
        })
        local ctx = makeCtx({ matPrices = { [200001] = 1000 } })
        local s = Mogul.ScoreRecipe(r, ctx)
        t.assert_eq(s.netProfitPerCraft, 13000)       -- 15000 - 2000
    end)

    t.it("treats missing price as 0 cost (caller decides UI)", function()
        local r = makeRecipe({
            id = 9005, name = "Mystery",
            reagents = { { id = 190316, qty = 6 }, { id = 999999, qty = 1 } },
            salePrice = 5000,
        })
        local ctx = makeCtx({ matPrices = {} })          -- no price for 999999
        local s = Mogul.ScoreRecipe(r, ctx)
        t.assert_eq(s.nonLumberMatCost, 0)
        t.assert_eq(s.netProfitPerCraft, 5000)
    end)

end)

-- ----------------------------------------------------------------------------
-- Profit plan: greedy with state, mix emerges from owned-mat depletion
-- ----------------------------------------------------------------------------

t.describe("HDG_Mogul.BuildPlan - profit mode", function()
    t.it("returns empty plan when no candidates", function()
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 1000,
            candidates = {},
            mode = "profit",
        }))
        t.assert_eq(#out.rows, 0)
        t.assert_eq(out.totals.revenue, 0)
        t.assert_eq(out.totals.netProfit, 0)
        t.assert_eq(out.totals.lumberUsed, 0)
    end)

    t.it("returns empty plan when budget < cheapest lumberCost", function()
        local r = makeRecipe({
            id = 1, name = "Big",
            reagents = { { id = 190316, qty = 100 } },
            salePrice = 99999,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 50, candidates = { r }, mode = "profit",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 0)
    end)

    t.it("stamps N copies of single positive-profit recipe (lumber-only sort)", function()
        local r = makeRecipe({
            id = 1, name = "Bookcase",
            reagents = { { id = 190316, qty = 24 } },
            salePrice = 10000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 100, candidates = { r }, mode = "profit",
            optimizeBy = "lumberOnly",
            lumberIDs = { [190316] = true },
        }))
        -- 100 / 24 = 4 crafts, 96 lumber used
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].crafts, 4)
        t.assert_eq(out.rows[1].recipe.itemID, 1)
        t.assert_eq(out.totals.lumberUsed, 96)
        t.assert_eq(out.totals.revenue, 40000)
    end)

    t.it("mix emerges when owned non-lumber mats deplete (Bookcase x 3, Lamp x N)", function()
        -- Bookcase: needs 4 stone (price 1000), salePrice 10000
        -- Lamp: needs 1 brass (price 200), salePrice 5000
        -- per-craft Lumber+mats profit when buying:
        --   Bookcase = 10000 - 4000 = 6000   (per 24 lumber: 250/lumber)
        --   Lamp     = 5000 - 200  = 4800    (per 12 lumber: 400/lumber)
        -- per-craft Lumber+mats profit when stone is owned:
        --   Bookcase = 10000 - 0    = 10000  (per 24 lumber: 416/lumber) -> wins until stone runs out
        local bookcase = makeRecipe({
            id = 1, name = "Bookcase",
            reagents = { { id = 190316, qty = 24 }, { id = 200001, qty = 4 } },
            salePrice = 10000,
        })
        local lamp = makeRecipe({
            id = 2, name = "Lamp",
            reagents = { { id = 190316, qty = 12 }, { id = 200002, qty = 1 } },
            salePrice = 5000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 200,
            candidates = { bookcase, lamp },
            ownedMats = { [200001] = 12 },           -- enough stone for 3 bookcases
            matPrices = { [200001] = 1000, [200002] = 200 },
            optimizeBy = "lumberPlusMats",
            lumberIDs = { [190316] = true },
        }))
        -- 3 Bookcases use 72 lumber + 12 stone. Stone gone.
        -- After: 128 lumber left. Lamp now wins (Bookcase per-lumber drops to 250 vs Lamp 400).
        -- 128 / 12 = 10 lamps, using 120 lumber.
        -- Total: 8 lumber unused, 3 Bookcase + 10 Lamp.
        t.assert_eq(#out.rows, 2)
        local crafts = {}
        for _, row in ipairs(out.rows) do crafts[row.recipe.itemID] = row.crafts end
        t.assert_eq(crafts[1], 3)    -- Bookcase
        t.assert_eq(crafts[2], 10)   -- Lamp
        t.assert_eq(out.totals.lumberUsed, 192)
    end)

    t.it("aggregates plan totals (lumber-only and lumber+mats) correctly", function()
        local r = makeRecipe({
            id = 1, name = "Item",
            reagents = { { id = 190316, qty = 10 }, { id = 200001, qty = 2 } },
            salePrice = 5000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 30,
            candidates = { r }, mode = "profit",
            optimizeBy = "lumberPlusMats",
            ownedMats = {},
            matPrices = { [200001] = 500 },
            lumberIDs = { [190316] = true },
        }))
        -- 30 / 10 = 3 crafts. Lumber-only profit: 3*5000 = 15000.
        -- Lumber+mats profit: 3*(5000 - 1000) = 12000.
        t.assert_eq(out.rows[1].crafts, 3)
        t.assert_eq(out.totals.revenue, 15000)
        t.assert_eq(out.totals.netProfit, 12000)
    end)

    t.it("emits aggregated shopping list of mats actually purchased", function()
        local r = makeRecipe({
            id = 1, name = "Item",
            reagents = { { id = 190316, qty = 10 }, { id = 200001, qty = 4 } },
            salePrice = 5000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 30,
            candidates = { r }, mode = "profit",
            optimizeBy = "lumberPlusMats",
            ownedMats = { [200001] = 5 },           -- enough for 1 craft, 1 left over
            matPrices = { [200001] = 100 },
            lumberIDs = { [190316] = true },
        }))
        -- 3 crafts need 12 stone. Owned 5. Buy 7.
        local stone = nil
        for _, m in ipairs(out.shoppingList) do
            if m.id == 200001 then stone = m end
        end
        t.assert_truthy(stone, "stone in shopping list")
        t.assert_eq(stone.qty, 7)
        t.assert_eq(stone.totalCost, 700)
    end)

    t.it("skips recipes with non-positive lumber+mats profit when sort=lumberPlusMats", function()
        local good = makeRecipe({
            id = 1, name = "Good",
            reagents = { { id = 190316, qty = 10 } },
            salePrice = 5000,
        })
        local bad = makeRecipe({
            id = 2, name = "Bad",
            reagents = { { id = 190316, qty = 10 }, { id = 200001, qty = 1 } },
            salePrice = 1000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 100,
            candidates = { good, bad },
            optimizeBy = "lumberPlusMats",
            matPrices = { [200001] = 5000 },         -- bad recipe nets -4000 / craft
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].recipe.itemID, 1)        -- only Good was stamped
    end)

    t.it("all-AH-buy: shared-mat consumption causes mid-plan recipe switch", function()
        -- A: 24 lumber + 4 stone, salePrice 10000.  per-craft profit (buying stone @ 100): 9600 -> 400/lumber
        -- B: 12 lumber + 1 brass, salePrice 5000.   per-craft profit (buying brass @ 50): 4950 -> 412/lumber
        -- Owned 3 stone + 0 brass. canCraftWithOwned(A) = floor(3/4) = 0.
        -- Iteration 1: B wins at 412/lumber. K = min(max(0,1), floor(200/12)) = 1. Stamp 1 B; buy 1 brass.
        -- Iteration 2: same scores; B wins again. K = 1. Stamp 1 B; buy 1 brass.
        -- ... B keeps winning because A's marginal cost (400) > B's (50). 200/12 = 16 B's max.
        -- But on the *first* iteration, A's marginalCost uses 3 owned stone -> only 1 stone bought ->
        -- A score = 9900/24 = 412.5. So actually A WINS iteration 1 (412.5 > 412.0).
        -- A K=1: consumes 3 owned stone, buys 1. remainingMats[stone] = 0. remainingLumber = 176.
        -- Iteration 2: A's marginalCost now = 4*100 = 400, score = 9600/24 = 400. B wins (412).
        -- B keeps winning until budget exhausted. floor(176/12) = 14 B's, using 168 lumber.
        -- Final plan: 1 A + 14 B; lumberUsed = 24 + 168 = 192.
        local recipeA = makeRecipe({
            id = 10, name = "A",
            reagents = { { id = 190316, qty = 24 }, { id = 200001, qty = 4 } },
            salePrice = 10000,
        })
        local recipeB = makeRecipe({
            id = 11, name = "B",
            reagents = { { id = 190316, qty = 12 }, { id = 200002, qty = 1 } },
            salePrice = 5000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 200,
            candidates = { recipeA, recipeB },
            ownedMats = { [200001] = 3 },
            matPrices = { [200001] = 100, [200002] = 50 },
            optimizeBy = "lumberPlusMats",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 2)
        local crafts = {}
        for _, row in ipairs(out.rows) do crafts[row.recipe.itemID] = row.crafts end
        t.assert_eq(crafts[10], 1)    -- A: one craft using owned stone
        t.assert_eq(crafts[11], 14)   -- B: rest of budget
        t.assert_eq(out.totals.lumberUsed, 192)
        -- Revenue: 1*10000 + 14*5000 = 80000.
        -- Mat spend: 1 stone bought (100) + 14 brass bought (700) = 800.
        -- lumberPlusMatsProfit total reflects ACTUAL spend, not pre-scored base.
        t.assert_eq(out.totals.revenue, 80000)
        t.assert_eq(out.totals.netProfit, 79200)
    end)

    t.it("respects per-type lumber budgets (Sturdy vs Polished)", function()
        -- Two lumber tiers: Sturdy (id 190316) and Polished (id 190317).
        -- Player has 200 Sturdy and 0 Polished.
        -- Recipe S needs 24 Sturdy + 0 Polished, salePrice 10000.
        -- Recipe P needs 0 Sturdy + 24 Polished, salePrice 10000.
        -- Even though Recipe P has the same per-craft profit, the planner must
        -- skip it entirely because the Polished bucket is empty -- a single
        -- fungible-lumber budget would wrongly include it.
        local recipeS = makeRecipe({
            id = 30, name = "Sturdy item",
            reagents = { { id = 190316, qty = 24 } },
            salePrice = 10000,
        })
        local recipeP = makeRecipe({
            id = 31, name = "Polished item",
            reagents = { { id = 190317, qty = 24 } },
            salePrice = 10000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudgets = { [190316] = 200, [190317] = 0 },
            candidates    = { recipeS, recipeP },
            optimizeBy    = "lumberOnly",
            lumberIDs     = { [190316] = true, [190317] = true },
        }))
        -- 200 Sturdy / 24 = 8 crafts of S. P fails the per-type check entirely.
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].recipe.itemID, 30)
        t.assert_eq(out.rows[1].crafts, 8)
        t.assert_eq(out.totals.lumberUsed, 192)
    end)

    t.it("skips a recipe whose specific lumber type is exhausted", function()
        -- 200 Sturdy, 0 Polished. Recipe needs Polished only.
        local r = makeRecipe({
            id = 32, name = "Polished only",
            reagents = { { id = 190317, qty = 10 } },
            salePrice = 5000,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudgets = { [190316] = 200, [190317] = 0 },
            candidates    = { r },
            lumberIDs     = { [190316] = true, [190317] = true },
        }))
        t.assert_eq(#out.rows, 0)
        t.assert_eq(out.totals.lumberUsed, 0)
    end)

    t.it("ranks multi-tier recipes by binding (max-qty) tier, not sum across tiers", function()
        -- Recipe X uses 5 Sturdy + 5 Polished, salePrice 1500. lumberCost (sum) = 10,
        -- bindingLumberQty (max) = 5. Per-bottleneck profit = 1500/5 = 300.
        -- Recipe Y uses 6 Sturdy only, salePrice 800. bindingLumberQty = 6.
        -- Per-bottleneck profit = 800/6 ~= 133. With sum-divisor (old behavior),
        -- Y would tie or beat X. With binding-divisor, X wins -- correctly, since
        -- X uses both tiers in parallel and its bottleneck is smaller.
        local x = makeRecipe({
            id = 40, name = "Multi-tier",
            reagents = { { id = 190316, qty = 5 }, { id = 190317, qty = 5 } },
            salePrice = 1500,
        })
        local y = makeRecipe({
            id = 41, name = "Sturdy-only",
            reagents = { { id = 190316, qty = 6 } },
            salePrice = 800,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudgets = { [190316] = 10, [190317] = 10 },
            candidates    = { x, y },
            lumberIDs     = { [190316] = true, [190317] = true },
        }))
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].recipe.itemID, 40)        -- X wins on per-bottleneck profit
        t.assert_eq(out.rows[1].crafts, 2)            -- both tiers run out simultaneously after 2 crafts
    end)
end)

-- ----------------------------------------------------------------------------
-- Collection plan: greedy by ascending lumberCost over uncollected recipes
-- ----------------------------------------------------------------------------

t.describe("HDG_Mogul.BuildPlan - collection mode", function()
    t.it("filters out already-collected recipes", function()
        local got = makeRecipe({
            id = 1, reagents = { { id = 190316, qty = 10 } },
            salePrice = 1000, uncollected = 0,
        })
        local need = makeRecipe({
            id = 2, reagents = { { id = 190316, qty = 10 } },
            salePrice = 1000, uncollected = 1,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 100, candidates = { got, need }, mode = "collection",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].recipe.itemID, 2)
        t.assert_eq(out.rows[1].crafts, 1)
    end)

    t.it("orders cheapest-lumber-first", function()
        local cheap = makeRecipe({
            id = 1, reagents = { { id = 190316, qty = 5 } },
            salePrice = 1000, uncollected = 1,
        })
        local mid = makeRecipe({
            id = 2, reagents = { { id = 190316, qty = 12 } },
            salePrice = 1000, uncollected = 1,
        })
        local expensive = makeRecipe({
            id = 3, reagents = { { id = 190316, qty = 30 } },
            salePrice = 1000, uncollected = 1,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 50, candidates = { expensive, cheap, mid }, mode = "collection",
            lumberIDs = { [190316] = true },
        }))
        -- 5 + 12 + 30 = 47, all three fit. Sorted ascending by lumberCost.
        t.assert_eq(#out.rows, 3)
        t.assert_eq(out.rows[1].recipe.itemID, 1)
        t.assert_eq(out.rows[2].recipe.itemID, 2)
        t.assert_eq(out.rows[3].recipe.itemID, 3)
    end)

    t.it("each row has count = 1 (one craft unlocks the appearance)", function()
        -- Three distinct uncollected recipes, all affordable. Each row's count
        -- must be 1 -- collection mode never stamps a recipe multiple times,
        -- because subsequent crafts of the same recipe contribute zero new
        -- appearances.
        local r1 = makeRecipe({ id = 1, reagents = { { id = 190316, qty = 5 } }, uncollected = 1, salePrice = 1000 })
        local r2 = makeRecipe({ id = 2, reagents = { { id = 190316, qty = 7 } }, uncollected = 1, salePrice = 1000 })
        local r3 = makeRecipe({ id = 3, reagents = { { id = 190316, qty = 9 } }, uncollected = 1, salePrice = 1000 })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 100, candidates = { r1, r2, r3 }, mode = "collection",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 3)
        for _, row in ipairs(out.rows) do
            t.assert_eq(row.crafts, 1)
        end
    end)

    t.it("totals.newAppearances = number of rows", function()
        local r1 = makeRecipe({ id = 1, reagents = { { id = 190316, qty = 5 } }, uncollected = 1, salePrice = 1000 })
        local r2 = makeRecipe({ id = 2, reagents = { { id = 190316, qty = 5 } }, uncollected = 1, salePrice = 1000 })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 100, candidates = { r1, r2 }, mode = "collection",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(out.totals.newAppearances, 2)
        t.assert_eq(out.totals.lumberUsed, 10)
    end)

    t.it("emits shopping list of non-lumber mats needed for the plan", function()
        local r = makeRecipe({
            id = 1, name = "Item",
            reagents = { { id = 190316, qty = 5 }, { id = 200001, qty = 2 } },
            salePrice = 1000, uncollected = 1,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 50, candidates = { r }, mode = "collection",
            ownedMats = {},                           -- nothing owned
            matPrices = { [200001] = 100 },
            lumberIDs = { [190316] = true },
        }))
        local stone = nil
        for _, m in ipairs(out.shoppingList) do
            if m.id == 200001 then stone = m end
        end
        t.assert_truthy(stone)
        t.assert_eq(stone.qty, 2)
    end)

    t.it("stops when next recipe lumberCost > remaining budget", function()
        local cheap = makeRecipe({
            id = 1, reagents = { { id = 190316, qty = 30 } },
            salePrice = 1000, uncollected = 1,
        })
        local big = makeRecipe({
            id = 2, reagents = { { id = 190316, qty = 100 } },
            salePrice = 1000, uncollected = 1,
        })
        local out = Mogul.BuildPlan(makeCtx({
            lumberBudget = 50, candidates = { cheap, big }, mode = "collection",
            lumberIDs = { [190316] = true },
        }))
        t.assert_eq(#out.rows, 1)
        t.assert_eq(out.rows[1].recipe.itemID, 1)
    end)
end)

t.run_all()
