--[[ Core/Score.lua — Can-I-Build-It (pure Lua, unit-testable outside WoW).

     TWO inputs, one output shape:
     1) Score.FromLive(normalized)  — a BlueprintAPI normalized payload where the
        server already computed per-entry `missing` (spike run 2). AUTHORITATIVE.
     2) Score.Compute(manifest, ownership, sources) — baked manifest + injected
        ownership fn, used before any live request has run.

     Output: { pct, missing = { {recordID|entryID, need, have, name?, source, estGold,
               obtainable} }, estGoldTotal, unobtainableCount, craftableCount, ahPricedCount } ]]--

local _, ns = ...
local Score = {}
if ns then ns.Score = Score end

local function newResult()
  return {
    pct = 1, missing = {},
    estGoldTotal = 0, unobtainableCount = 0, craftableCount = 0, ahPricedCount = 0,
    dyeMissing = 0, dyeUnknown = 0,
  }
end

-- Dyes are the server's contentType 4 (decor is 3). They are consumables
-- bought by the stack, not pieces you collect, and a build can be half dye
-- by unit count. Scored per unit they buried the real answer: a player who
-- owns every single piece of decor in Panda Garden but no dye read as 40%,
-- in red. So dyes count once per colour, like a shopping-list line, rather
-- than once per bottle - and are labelled so the list can say "dye" instead
-- of "missing" and mean it.
local DYE_CONTENT_TYPE = 4

local function isDye(e)
  if e.contentType == DYE_CONTENT_TYPE then return true end
  -- Baked manifests predate contentType; the name is the fallback.
  local n = e.name
  return type(n) == "string" and n:find("Housing Dye", 1, true) ~= nil
end
Score.IsDye = isDye

local function applySource(r, m, src, deficit)
  if not src then return end
  m.source = src
  if src.obtainable == false or src.type == "promo" then
    m.obtainable = false
    r.unobtainableCount = r.unobtainableCount + 1
  elseif src.type == "vendor" and src.cost and (src.currency == "gold" or src.currency == nil) then
    m.estGold = src.cost * deficit
    r.estGoldTotal = r.estGoldTotal + m.estGold
  elseif src.type == "profession" then
    r.craftableCount = r.craftableCount + 1
  elseif src.type == "ah" then
    r.ahPricedCount = r.ahPricedCount + 1
  end
end

--- Authoritative: from a live normalized payload (server-computed missing)
function Score.FromLive(normalized, sources)
  local r = newResult()
  local owned, total = 0, 0
  for _, e in ipairs(normalized.entries or {}) do
    local qty = e.qty or 1
    local missing = math.min(e.missing or 0, qty)
    local dye = isDye(e)
    -- A colour of dye is one line on the shopping list, whatever the count.
    local weight = dye and 1 or qty
    total = total + weight
    owned = owned + (missing > 0 and (dye and 0 or qty - missing) or weight)
    if missing > 0 then
      local m = {
        recordID = e.recordID, name = e.name, dye = dye,
        need = missing, have = qty - missing, obtainable = true,
      }
      applySource(r, m, sources and sources(e.recordID) or nil, missing)
      r.missing[#r.missing + 1] = m
      if dye then r.dyeMissing = r.dyeMissing + 1 end
    end
  end
  r.pct = (total > 0) and (owned / total) or 1
  return r
end

--- Fallback: from a baked manifest + ownership fn (nil ownership -> nil pct = "unknown")
function Score.Compute(manifest, ownership, sources)
  local r = newResult()
  local owned, total = 0, 0
  local anyKnown = false
  for _, item in ipairs(manifest or {}) do
    local need = item.qty or 1
    local dye = isDye(item)
    local weight = dye and 1 or need
    local have = ownership and ownership(item.recordID or item.entryID)
    if dye and have == nil then
      -- The catalog counts collected decor; dye is a consumable it cannot
      -- see at all. Guessing zero would report "5 dyes to buy" to someone
      -- with a bank full of them, and drag the score down on a guess. An
      -- unknown dye is left out of the sum entirely and reported as
      -- something Verify can answer.
      r.dyeUnknown = r.dyeUnknown + 1
    else
      total = total + weight
      if have ~= nil then anyKnown = true else have = 0 end
      if have >= need then
        owned = owned + weight
      else
        owned = owned + (dye and 0 or have)
        local m = {
          recordID = item.recordID, entryID = item.entryID, name = item.name, dye = dye,
          need = need - have, have = have, obtainable = true,
        }
        applySource(r, m, sources and sources(item.recordID or item.entryID) or nil, need - have)
        r.missing[#r.missing + 1] = m
        if dye then r.dyeMissing = r.dyeMissing + 1 end
      end
    end
  end
  -- An empty manifest means we have not baked this build's contents yet, NOT
  -- that it contains nothing. Claiming 100% there would tell every player they
  -- can build anything. Unknown must stay unknown - the grid then asks the
  -- server for the real score.
  if total == 0 or not anyKnown then
    r.pct = nil
  else
    r.pct = owned / total
  end
  return r
end

--- Ring colour (spec §7): red <40, amber 40-79, green >=80, star @100, grey unknown
function Score.RingColor(pct)
  if pct == nil then return 0.5, 0.5, 0.5, "unknown"
  elseif pct >= 1 then return 1.0, 0.85, 0.2, "star"
  elseif pct >= 0.8 then return 0.25, 0.8, 0.25, "green"
  elseif pct >= 0.4 then return 0.9, 0.65, 0.15, "amber"
  else return 0.85, 0.25, 0.25, "red" end
end

if not ns then return Score end
