--[[ UI/CardGrid.lua - virtualized card grid (spec 5.1: recycle frames, zero
     per-entry frames for the whole dataset; M2 target 60fps @ 500 entries). ]]

local _, ns = ...
local S = ns.Style
local W = ns.Widgets
ns.UI = ns.UI or {}
local UI = ns.UI

local pool = {}          -- reusable card frames
local view = {}          -- filtered+sorted entries currently shown
local scroll, inner
local selectedId

------------------------------------------------------------------------
-- Score resolution (lazy, session-cached; live payload wins over baked)
------------------------------------------------------------------------
local scoreCache = {}
local liveRequested = {}   -- one live request per entry per session
local function scoreFor(entry)
  local hit = scoreCache[entry.id]
  if hit ~= nil then return hit end
  local live = ns.API.Cached and ns.API.Cached(entry.code)
  local result
  if live then
    result = ns.Score.FromLive(live, ns.Sources and ns.Sources.Get)
  elseif entry.mockPct then
    result = { pct = entry.mockPct, missing = {} }
  else
    result = ns.Score.Compute(entry.manifest, function(rid)
      return ns.Catalog.OwnedByRecord(rid)
    end, ns.Sources and ns.Sources.Get)
    -- unknown ownership -> ask the server for the authoritative score once.
    -- QUEUED, not fired all at once: a library of sixteen builds used to send
    -- sixteen simultaneous requests the moment the window opened, and the ones
    -- the server dropped left their cards stuck on "?" forever.
    if result.pct == nil and entry.code and not liveRequested[entry.id]
      and ns.Guard.Has("contents") then
      liveRequested[entry.id] = true
      UI.QueueScoreRequest(entry)
    end
  end
  scoreCache[entry.id] = result
  return result
end
-- One score request in flight at a time, the next fired when the previous
-- answers (or times out via the API's own 10s timer). Retries once: a dropped
-- request gets a second chance before its card settles on "?".
local scoreQueue, queueBusy = {}, false
local function pumpScoreQueue()
  if queueBusy then return end
  local job = table.remove(scoreQueue, 1)
  if not job then return end
  queueBusy = true
  ns.API.Request(job.entry.code, function(ok)
    queueBusy = false
    if ok then
      ns.bus:Fire("SCORE_INVALIDATE", job.entry.id)
    elseif not job.retried then
      job.retried = true
      table.insert(scoreQueue, job)   -- back of the line, one more try
    end
    pumpScoreQueue()
  end)
end

function UI.QueueScoreRequest(entry)
  table.insert(scoreQueue, { entry = entry })
  pumpScoreQueue()
end

ns.bus:On("OWNERSHIP_CHANGED", function() wipe(scoreCache); if UI.RefreshGrid then UI.RefreshGrid() end end)
ns.bus:On("SCORE_INVALIDATE", function(id)
  if id then scoreCache[id] = nil else wipe(scoreCache) end
  if UI.RefreshGrid then UI.RefreshGrid() end
end)
UI.ScoreFor = scoreFor

--- The number a build costs on the side that matters, for sorting.
function UI.CostOf(entry)
  local b = entry.budget
  if not b then return 0 end
  return math.max(b.interior or 0, b.exterior or 0)
end

-- Will it import? Distinct from the score, which is "do you own it". The
-- server's own blocking flags win when a live reply has been seen for this
-- code; before that, the baked cost against the house-level table is the best
-- available answer. Everything that shows a build - card, detail pane, Import
-- button - asks this one function, so they cannot disagree.
--   -> { ok = true|false|nil, head, detail, text, short }
--   head   "Needs house level 3"        (the verdict, coloured by the pane)
--   detail "235 exterior budget, ..."   (the numbers, dimmed)
--   text   head .. " - " .. detail      (one string, for the Import refusal)
--   short  "Lvl 3" / "Room"              (the card chip)
--   ok == nil: no reason to expect a failure, but the house is not yet known
function UI.ImportVerdict(entry)
  local live = ns.API.Cached and entry.code and ns.API.Cached(entry.code)
  local hl = ns.HouseLevel and ns.HouseLevel.Verdict(entry)
  -- hard = nothing the player does will change it (red); soft = a goal (amber)
  local function pack(ok, head, detail, short, hard)
    local text = head and (detail and (head .. " - " .. detail .. ".") or (head .. ".")) or nil
    return { ok = ok, head = head, detail = detail, text = text, short = short, hard = hard }
  end
  if live and live.flags and (live.flags.blocking or 0) ~= 0 then
    -- The server knows THAT it will not fit; the table knows HOW FAR off it
    -- is. "Needs house level 3 - 235 exterior, your house holds 200" is a
    -- goal; "costs more than your house has" is only a wall. So when budget
    -- is among the reasons the level sentence carries it, and any other
    -- reasons follow.
    local names = ns.Result.FlagNames(live.flags.blocking)
    local budgetBlocked = false
    for _, n in ipairs(names) do if n == "InsufficientBudget" then budgetBlocked = true end end
    local fhead, fdetail, hard, chip = ns.Result.FlagVerdict(live.flags.blocking)
    if budgetBlocked and not hard and hl and hl.needLevel and hl.fits ~= true and hl.head then
      local detail = hl.detail
      if #names > 1 then
        local why = ns.Result.ExplainFlags(live.flags.blocking - 1)   -- drop the budget bit
        if why then detail = detail .. "; also " .. why:gsub("^Can't import this: ", ""):gsub("%.$", "") end
      end
      return pack(false, hl.head, detail, hl.short or ("Lvl " .. hl.needLevel), false)
    end
    return pack(false, fhead, fdetail, chip, hard)
  end
  if live then
    if hl and hl.fits then return pack(true, hl.head, hl.detail, nil, false) end
    return pack(true, nil, nil, nil, false)
  end
  if hl and hl.head then
    local hard = (hl.needLevel == nil)
    if hl.fits == false or hard then
      return pack(false, hl.head, hl.detail, hl.short, hard)
    end
    return pack(hl.fits, hl.head, hl.detail, hl.short, false)
  end
  return pack(nil)
end

------------------------------------------------------------------------
-- Filtering + sorting (spec F2)
------------------------------------------------------------------------
local function passes(entry, flt)
  if flt.search and flt.search ~= "" then
    local hay = (entry.title or "") .. " " .. (entry.author and entry.author.name or "")
      .. " " .. table.concat(entry.tags or {}, " ")
    -- searchable manifests (judge/provenance use case): find builds by the
    -- decor items they contain, e.g. "tusked fireplace"
    for _, m in ipairs(entry.manifest or {}) do
      if m.name then hay = hay .. " " .. m.name end
    end
    if not hay:lower():find(flt.search, 1, true) then return false end
  end
  if flt.type and flt.type ~= "all" and entry.type ~= flt.type then return false end
  if flt.buildable then
    local sc = scoreFor(entry)
    if not (sc.pct and sc.pct >= 0.8) then return false end
  end
  if flt.fits and UI.ImportVerdict(entry).ok == false then return false end
  if UI.activeTab == "favs" and not ns.db.favorites[entry.id] then return false end
  if ns.db.hidden[entry.id] then return false end
  return true
end

local sorters = {
  featured = nil,   -- curated file order
  newest = function(a, b) return (a.addedIn or "") > (b.addedIn or "") end,
  -- A build's cost is on whichever side it decorates; exteriors sorted by
  -- their interior figure (always 0) all sank to the bottom together.
  budgetAsc = function(a, b) return UI.CostOf(a) < UI.CostOf(b) end,
  budgetDesc = function(a, b) return UI.CostOf(a) > UI.CostOf(b) end,
  buildability = function(a, b)
    local pa = scoreFor(a).pct or -1
    local pb = scoreFor(b).pct or -1
    return pa > pb
  end,
}

local function rebuildView()
  wipe(view)
  local flt = ns.db.settings.lastFilters or {}
  local entries = ns.Library and ns.Library.entries or {}
  for _, e in ipairs(entries) do
    if passes(e, flt) then view[#view + 1] = e end
  end
  local sorter = sorters[flt.sort or "featured"]
  if sorter then table.sort(view, sorter) end
end

------------------------------------------------------------------------
-- Card frame factory + layout
------------------------------------------------------------------------
local function makeCard(parent)
  local c = W.Panel(parent, true)
  c:SetSize(S.card.w, S.card.h)
  c:EnableMouse(true)

  -- preview image across the top of the card
  c.img = c:CreateTexture(nil, "ARTWORK")
  c.img:SetPoint("TOPLEFT", 1, -1)
  c.img:SetPoint("TOPRIGHT", -1, -1)
  c.img:SetHeight(S.card.imgH)

  -- shown instead of the image when a build has no screenshot yet
  c.imgEmpty = W.Text(c, S.size.tiny, S.textDim)
  c.imgEmpty:SetPoint("TOP", c.img, "TOP", 0, -S.card.imgH / 2 + 6)
  c.imgEmpty:SetText("no preview yet")

  -- Page through a build's pictures without opening it. Arrows only appear on
  -- hover, and only when there is more than one picture.
  local function showThumb(card, index)
    local thumbs = card.entry and card.entry.thumbs
    if not (thumbs and #thumbs > 0) then return end
    card.imgIndex = ((index - 1) % #thumbs) + 1
    card.img:SetTexture("Interface\\AddOns\\BlueprintBazaar\\Media\\"
      .. thumbs[card.imgIndex]:gsub("%.%a+$", ""))
    card.imgCount:SetText(card.imgIndex .. "/" .. #thumbs)
  end
  c.ShowThumb = showThumb

  c.imgPrev = W.Button(c, "<", nil)
  c.imgPrev:SetPoint("LEFT", c.img, "LEFT", 2, 0)
  c.imgPrev:SetSize(18, 20)
  c.imgPrev:SetFrameLevel(c:GetFrameLevel() + 3)
  c.imgPrev:SetScript("OnClick", function(self)
    local card = self:GetParent()
    showThumb(card, (card.imgIndex or 1) - 1)
  end)
  c.imgPrev:Hide()

  c.imgNext = W.Button(c, ">", nil)
  c.imgNext:SetPoint("RIGHT", c.img, "RIGHT", -2, 0)
  c.imgNext:SetSize(18, 20)
  c.imgNext:SetFrameLevel(c:GetFrameLevel() + 3)
  c.imgNext:SetScript("OnClick", function(self)
    local card = self:GetParent()
    showThumb(card, (card.imgIndex or 1) + 1)
  end)
  c.imgNext:Hide()

  c.imgCount = W.Text(c, S.size.tiny, S.text)
  c.imgCount:SetPoint("BOTTOMRIGHT", c.img, "BOTTOMRIGHT", -4, 4)

  c.title = W.Text(c, S.size.body, S.gold)
  c.title:SetPoint("TOPLEFT", 8, -S.card.imgH - 8)
  c.title:SetPoint("RIGHT", -52, 0)
  c.title:SetWordWrap(false)

  -- Anchored on the right too, and no wrapping: a long credit ("B-Net
  -- Aysen#1684 - Open to all visitors on NA") used to run straight out of the
  -- card and across its neighbour. Now it ends in an ellipsis at the edge,
  -- and the full name is on the build page.
  c.author = W.Text(c, S.size.tiny, S.textDim)
  c.author:SetPoint("TOPLEFT", 8, -S.card.imgH - 26)
  c.author:SetPoint("RIGHT", -8, 0)
  c.author:SetWordWrap(false)

  c.info = W.Text(c, S.size.small, S.textDim)
  c.info:SetPoint("BOTTOMLEFT", 8, 8)
  c.info:SetPoint("RIGHT", -8, 0)
  c.info:SetWordWrap(false)

  c.chip = W.ScoreChip(c)
  c.chip:SetPoint("TOPRIGHT", -6, -6)

  -- Star on the picture itself, top-left, opposite the score chip. Clicking it
  -- toggles the favourite and nothing else - it must not also select the card,
  -- or every star click drags the detail pane along with it.
  c.fav = W.Star(c, 18, function(star)
    local e = c.entry
    if not e then return end
    ns.db.favorites[e.id] = not ns.db.favorites[e.id] or nil
    star:SetOn(ns.db.favorites[e.id])
    if UI.RefreshFavCount then UI.RefreshFavCount() end
    if UI.SyncDetailFav then UI.SyncDetailFav(e.id) end
    -- on the Favorites tab an un-starred card should leave the shelf
    if UI.activeTab == "favs" then UI.RefreshGrid(true) end
  end)
  c.fav:SetPoint("TOPLEFT", 6, -6)
  c.fav:SetFrameLevel(c:GetFrameLevel() + 3)

  c:SetScript("OnMouseDown", function(self)
    if self.entry then
      selectedId = self.entry.id
      if UI.ShowDetail then UI.ShowDetail(self.entry) end
      UI.RefreshGrid(true)
    end
  end)
  c:SetScript("OnEnter", function(self)
    self:SetBackdropBorderColor(S.gold[1], S.gold[2], S.gold[3], 0.8)
    local thumbs = self.entry and self.entry.thumbs
    if thumbs and #thumbs > 1 then
      self.imgPrev:Show()
      self.imgNext:Show()
    end
  end)
  c:SetScript("OnLeave", function(self)
    -- the arrows sit inside the card, so leaving onto one is not leaving
    if self:IsMouseOver() then return end
    self.imgPrev:Hide()
    self.imgNext:Hide()
    if self.entry and self.entry.id == selectedId then return end
    self:SetBackdropBorderColor(unpack(S.border))
  end)
  return c
end

local function layout()
  if not scroll then return end
  local width = scroll:GetWidth()
  if width < 10 then return end
  local cols = math.max(1, math.floor((width + S.card.gap) / (S.card.w + S.card.gap)))
  local rows = math.ceil(#view / cols)
  local rowH = S.card.h + S.card.gap
  inner:SetHeight(math.max(1, rows * rowH))

  local offset = scroll:GetVerticalScroll()
  local viewH = scroll:GetHeight()
  -- Never let the viewport point below the content: filtering while scrolled
  -- deep used to leave the window staring at empty space under a now-short
  -- list, rendering nothing at all.
  local maxOffset = math.max(0, rows * rowH - viewH)
  if offset > maxOffset then
    scroll:SetVerticalScroll(maxOffset)
    offset = maxOffset
  end
  local firstRow = math.max(0, math.floor(offset / rowH))
  local lastRow = math.min(rows - 1, math.ceil((offset + viewH) / rowH))

  local used = 0
  for row = firstRow, lastRow do
    for col = 0, cols - 1 do
      local idx = row * cols + col + 1
      local entry = view[idx]
      if entry then
        used = used + 1
        local card = pool[used]
        if not card then
          card = makeCard(inner)
          pool[used] = card
        end
        card.entry = entry
        card:ClearAllPoints()
        card:SetPoint("TOPLEFT", inner, "TOPLEFT",
          col * (S.card.w + S.card.gap), -(row * rowH))
        local thumbs = entry.thumbs
        if thumbs and #thumbs > 0 then
          card.img:SetTexCoord(0, 1, 0, 1)
          card.img:Show()
          card.imgEmpty:Hide()
          card.ShowThumb(card, 1)       -- pooled cards must reset to page one
          card.imgCount:SetShown(#thumbs > 1)
        else
          card.img:Hide()
          card.imgEmpty:Show()
          card.imgCount:Hide()
          card.imgPrev:Hide()
          card.imgNext:Hide()
        end
        card.title:SetText(entry.title or "?")
        card.author:SetText("by " .. ((entry.author and entry.author.name) or "?"))
        local sc = scoreFor(entry)
        local iv = UI.ImportVerdict(entry)
        -- The chip is the first thing read on a card. "100%" on a build the
        -- house cannot hold is the lie that got the addon called broken, so
        -- when the import is known to fail the chip says why - "Lvl 7" - and
        -- the ownership score waits for the detail pane.
        if iv.ok == false and iv.short then
          card.chip:SetLocked(iv.short, iv.hard)
        else
          card.chip:SetScore(sc.pct)
        end
        -- Cost on the side that matters: an exterior's cost is its exterior
        -- budget. This used to read the interior figure for everything, so no
        -- exterior card ever showed a budget at all. "0" means unbaked - say
        -- nothing rather than "0 budget".
        local b = entry.budget
        local cost = b and ((entry.type == "exterior") and b.exterior or b.interior)
        if b and (not cost or cost == 0) then cost = b.exterior or b.interior end
        local info = W.TypeLabel(entry.type)
        if cost and cost > 0 then info = info .. "  -  " .. cost .. " budget" end
        if iv.ok ~= false and iv.short then
          -- ceiling not yet known: advisory level, neutral colour
          info = info .. "  -  " .. iv.short .. "+"
        end
        card.info:SetText(info)
        card.fav:SetOn(ns.db.favorites[entry.id])
        if entry.id == selectedId then
          card:SetBackdropBorderColor(S.gold[1], S.gold[2], S.gold[3], 1)
        else
          card:SetBackdropBorderColor(unpack(S.border))
        end
        card:Show()
      end
    end
  end
  for i = used + 1, #pool do pool[i]:Hide() end

  if UI.emptyText then
    if #view == 0 then
      -- An EMPTY LIBRARY is not a filter problem: telling a day-one user to
      -- "clear filters" when there is nothing to clear reads as broken.
      local libCount = #((ns.Library and ns.Library.entries) or {})
      if UI.activeTab == "favs" then
        UI.emptyText:SetText("No favorites yet.\n\nClick the star on any card to keep it here.")
      elseif libCount == 0 then
        UI.emptyText:SetText("The library fills up during launch week.\n\n"
          .. "Meanwhile: paste any blueprint code into |cffffd100/bb code <code>|r\n"
          .. "to inspect it, see what it would cost you, and import it.")
      else
        UI.emptyText:SetText("No results - try clearing filters.")
      end
      UI.emptyText:Show()
    else
      UI.emptyText:Hide()
    end
  end
end

function UI.BuildGrid(host)
  scroll = CreateFrame("ScrollFrame", nil, host)
  scroll:SetAllPoints()
  UI.gridScroll = scroll
  inner = CreateFrame("Frame", nil, scroll)
  inner:SetPoint("TOPLEFT")
  inner:SetWidth(1)
  scroll:SetScrollChild(inner)
  scroll:EnableMouseWheel(true)
  scroll:SetScript("OnMouseWheel", function(self, delta)
    local max = math.max(0, inner:GetHeight() - self:GetHeight())
    local target = math.min(max, math.max(0, self:GetVerticalScroll() - delta * 60))
    self:SetVerticalScroll(target)
    layout()
  end)
  scroll:SetScript("OnSizeChanged", function() layout() end)

  UI.emptyText = W.Text(host, S.size.body, S.textDim)
  UI.emptyText:SetPoint("CENTER")
  UI.emptyText:SetWidth(380)          -- the empty-library message is multi-line
  UI.emptyText:SetJustifyH("CENTER")
  UI.emptyText:SetSpacing(3)
  UI.emptyText:Hide()

  ns.bus:On("WINDOW_RESIZED", layout)
end

--- Jump to the top of the grid (deliberate filter/tab changes)
function UI.ResetGridScroll()
  if scroll then scroll:SetVerticalScroll(0) end
end

--- softRefresh = keep the current view list (selection change only)
-- How many cards fit across, by the same maths layout() uses.
local function columnCount()
  local width = scroll and scroll:GetWidth() or 0
  if width < 10 then return nil end
  return math.max(1, math.floor((width + S.card.gap) / (S.card.w + S.card.gap)))
end

-- Which row of the current view holds the selected card?
local function rowOfSelected(cols)
  if not (selectedId and cols) then return nil end
  for i, e in ipairs(view) do
    if e.id == selectedId then return math.floor((i - 1) / cols) end
  end
  return nil
end

function UI.RefreshGrid(softRefresh)
  if not scroll then return end
  if UI.activeTab == "mine" then return end   -- grid + empty text stay hidden there
  if softRefresh then layout(); return end

  -- A rebuild can reorder the shelf, and pressing Verify usually does exactly
  -- that: the score improves and the card climbs. Without help it climbs out
  -- from under the player, who was reading it. So note where the open card
  -- sits, rebuild, then scroll by however many rows it moved - the shelf
  -- reorders around it while it stays put on screen.
  local cols = columnCount()
  local before = rowOfSelected(cols)
  rebuildView()
  local after = rowOfSelected(cols)
  if before and after and after ~= before then
    local rowH = S.card.h + S.card.gap
    scroll:SetVerticalScroll(math.max(0, scroll:GetVerticalScroll() + (after - before) * rowH))
  end
  layout()
end
