--[[ UI/DetailPane.lua - right pane (spec F3): title/author/desc, budgets, code
     copy box, ONE-CLICK IMPORT (spike-confirmed), chat link, verify, manifest
     with own/missing colors, score summary, favorite toggle. ]]

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

local D = {}      -- widget refs
local current     -- selected entry
-- Upper bound on row FRAMES, not on what is shown: the credit-floor clamp
-- decides how many actually fit, so a taller window simply shows more rows.
-- 40 covers the pane at any sane window height.
local MANIFEST_LINES = 40

function UI.BuildDetail(panel)
  local pad = S.pad

  -- Hero image: the first thing you should see about a build is the build.
  -- It lives in a holder whose height collapses to nothing when there is no
  -- screenshot, and everything below flows from the holder's bottom edge.
  D.heroHolder = CreateFrame("Frame", nil, panel)
  D.heroHolder:SetPoint("TOPLEFT", pad, -pad)
  D.heroHolder:SetPoint("TOPRIGHT", -pad, -pad)
  D.heroHolder:SetHeight(1)
  D.hero = D.heroHolder:CreateTexture(nil, "ARTWORK")
  D.hero:SetAllPoints()
  D.hero:Hide()

  -- Some builds are too big for one picture (the AT-AT started this): a build
  -- can carry several screenshots, and arrows page through them.
  local function updateHero()
    local thumbs = current and current.thumbs
    if not (thumbs and #thumbs > 0) then
      D.hero:Hide()
      D.heroHolder:SetHeight(1)
      D.heroPrev:Hide(); D.heroNext:Hide(); D.heroCount:Hide()
      return
    end
    D.heroIndex = math.max(1, math.min(D.heroIndex or 1, #thumbs))
    D.hero:SetTexture("Interface\\AddOns\\BlueprintBazaar\\Media\\"
      .. thumbs[D.heroIndex]:gsub("%.%a+$", ""))
    D.hero:Show()
    D.heroHolder:SetHeight(163)
    if #thumbs > 1 then
      D.heroPrev:Show(); D.heroNext:Show()
      D.heroCount:SetText(D.heroIndex .. " / " .. #thumbs)
      D.heroCount:Show()
    else
      D.heroPrev:Hide(); D.heroNext:Hide(); D.heroCount:Hide()
    end
  end
  D.UpdateHero = updateHero

  local function page(step)
    local thumbs = current and current.thumbs
    if not (thumbs and #thumbs > 1) then return end
    D.heroIndex = ((D.heroIndex - 1 + step) % #thumbs) + 1
    updateHero()
  end

  D.heroPrev = W.Button(D.heroHolder, "<", function() page(-1) end)
  D.heroPrev:SetPoint("LEFT", 4, 0)
  D.heroPrev:SetWidth(22)
  D.heroPrev:SetFrameLevel(D.heroHolder:GetFrameLevel() + 2)
  D.heroPrev:Hide()

  D.heroNext = W.Button(D.heroHolder, ">", function() page(1) end)
  D.heroNext:SetPoint("RIGHT", -4, 0)
  D.heroNext:SetWidth(22)
  D.heroNext:SetFrameLevel(D.heroHolder:GetFrameLevel() + 2)
  D.heroNext:Hide()

  D.heroCount = W.Text(D.heroHolder, S.size.tiny, S.text)
  D.heroCount:SetPoint("BOTTOMRIGHT", -8, 6)
  D.heroCount:Hide()

  D.title = W.Text(panel, S.size.title, S.gold)
  D.title:SetPoint("TOPLEFT", D.heroHolder, "BOTTOMLEFT", 0, -6)
  D.title:SetPoint("RIGHT", -40, 0)
  D.title:SetWordWrap(false)

  D.fav = W.Star(panel, 22, function(star)
    if not current then return end
    ns.db.favorites[current.id] = not ns.db.favorites[current.id] or nil
    star:SetOn(ns.db.favorites[current.id])
    if UI.RefreshFavCount then UI.RefreshFavCount() end
    UI.RefreshGrid(true)
  end)
  D.fav:SetPoint("TOPRIGHT", D.title, "TOPRIGHT", 30, 3)

  -- the grid's stars call this so the panel never shows a stale state
  function UI.SyncDetailFav(id)
    if current and current.id == id then D.fav:SetOn(ns.db.favorites[id]) end
  end

  D.meta = W.Text(panel, S.size.small, S.textDim)
  D.meta:SetPoint("TOPLEFT", D.title, "BOTTOMLEFT", 0, -6)
  D.meta:SetPoint("RIGHT", -pad, 0)

  -- Status block, two lines. The verdict leads because it decides whether
  -- Import will work at all; ownership follows in a smaller size. "100%
  -- buildable" above "can't import" was the contradiction that got the addon
  -- called broken, so the ownership line now says what it measures - owned
  -- decor - and never claims buildability on its own.
  D.verdict = W.Text(panel, S.size.body)
  D.verdict:SetPoint("TOPLEFT", D.meta, "BOTTOMLEFT", 0, -8)
  D.verdict:SetPoint("RIGHT", -pad, 0)
  D.verdict:SetJustifyH("LEFT")
  D.verdict:SetWordWrap(true)

  D.score = W.Text(panel, S.size.small)
  D.score:SetPoint("RIGHT", -pad, 0)
  D.score:SetJustifyH("LEFT")
  D.score:SetWordWrap(true)
  -- anchored under the verdict when there is one, straight under meta when not
  function D.LayoutStatus(hasVerdict)
    D.score:ClearAllPoints()
    D.score:SetPoint("RIGHT", -pad, 0)
    if hasVerdict then
      D.score:SetPoint("TOPLEFT", D.verdict, "BOTTOMLEFT", 0, -3)
    else
      D.score:SetPoint("TOPLEFT", D.meta, "BOTTOMLEFT", 0, -8)
    end
  end
  D.LayoutStatus(false)

  -- The description grows with its text instead of stopping at three lines
  -- and an ellipsis whatever the window size. Everything below anchors to
  -- its bottom, so a long description pushes the code box down rather than
  -- being cut. Capped so one essay cannot push the Import button off-screen.
  D.desc = W.Text(panel, S.size.small)
  D.desc:SetPoint("TOPLEFT", D.score, "BOTTOMLEFT", 0, -8)
  D.desc:SetPoint("RIGHT", -pad, 0)
  D.desc:SetJustifyV("TOP")
  D.desc:SetWordWrap(true)
  D.desc:SetNonSpaceWrap(true)
  if D.desc.SetMaxLines then D.desc:SetMaxLines(8) end

  local codeHead = W.Text(panel, S.size.small, S.gold)
  codeHead:SetPoint("TOPLEFT", D.desc, "BOTTOMLEFT", 0, -8)
  codeHead:SetText("SHARE CODE  (Ctrl+C)")

  D.code = W.CopyBox(panel, S.detail - 2 * pad)
  D.code:SetPoint("TOPLEFT", codeHead, "BOTTOMLEFT", 0, -4)

  D.import = W.Button(panel, "Import", function()
    if not current then return end
    if not ns.Guard.Has("import") then
      D.status:SetText("|cffff5050One-click import isn't available in this game " ..
        "build. Copy the code above and use the Blueprints tab instead.|r")
      return
    end
    -- Ask the client whether an import is possible AT ALL before arming, and
    -- say what it answers. Standing in the wrong place is the usual reason,
    -- and there is no point warning about replacing a layout that is not
    -- going to be replaced.
    local canImport, why = ns.API.CanImport()
    if not canImport then
      D.armedAt = nil
      D.status:SetText("|cffff5050" .. why .. "|r")
      return
    end
    -- Then whether THIS build will take: server flags if seen, else the cost
    -- against the house-level table. Refusing here is kinder than letting the
    -- server refuse after a two-click confirm.
    local iv = UI.ImportVerdict(current)
    if iv.ok == false then
      D.armedAt = nil
      D.status:SetText("|cffff5050" .. (iv.text or "This build can't be imported to your house.") .. "|r")
      return
    end

    -- Importing replaces the player's current layout. With the 12.1 "lost
    -- build" bug still unconfirmed-fixed, one stray click must never be able
    -- to do that: first click arms, second click within 8s imports.
    local now = GetTime()
    if not (D.armedAt and D.armedFor == current.id and (now - D.armedAt) < 8) then
      D.armedAt = now
      D.armedFor = current.id
      -- Partial-import wipe bug: PTR-fixed by Blizzard 2026-08-08. The red
      -- warning only returns if ns.BUG_PARTIAL_WIPE is flipped (bug alive on
      -- live). The two-click confirm stays regardless - replacing a layout
      -- should never be one stray click.
      local t = current.type
      if ns.BUG_PARTIAL_WIPE and (t == "exterior" or t == "interior") then
        local opposite = (t == "exterior") and "interior" or "exterior"
        D.status:SetText("|cffff5050Known 12.1 bug: after importing a " .. t ..
          "-only blueprint, opening Edit Mode on your " .. opposite .. " can " ..
          "wipe its decor. Save a FULL blueprint of your house first - it's " ..
          "the only way to restore. Click Import again to continue.|r")
      else
        D.status:SetText("|cffffcc00This replaces your current layout. Blizzard " ..
          "keeps an auto-save backup, but consider saving a manual blueprint " ..
          "first. Click Import again to continue.|r")
      end
      return
    end
    D.armedAt = nil
    D.status:SetText("Importing...")
    ns.API.Import(current.code, function(ok, err)
      if ok then
        local who = (current.author and current.author.name)
          and (" by " .. current.author.name) or ""
        D.status:SetText("|cff40ff40Imported '" .. (current.title or "build") .. "'" .. who .. "!|r")
        print("|cffc9a227BlueprintBazaar|r imported \"" .. (current.title or "?") .. "\"" .. who .. " - enjoy!")
      else
        -- The server said why. Say that, rather than guessing at two reasons
        -- and leaving the player to pick one.
        D.status:SetText("|cffff5050" .. ns.Result.Explain(err) .. "|r")
      end
    end)
  end)
  D.import:SetPoint("TOPLEFT", D.code, "BOTTOMLEFT", 0, -8)

  D.chatLink = W.Button(panel, "Link in Chat", function()
    if not current then return end
    -- credit travels with every share: "[Housing Blueprint] - build by Name"
    local msg = ns.API.Hyperlink(current.code)
    if current.author and current.author.name then
      msg = msg .. " - build by " .. current.author.name
    end
    ChatFrame_OpenChat(msg)
  end)
  D.chatLink:SetPoint("LEFT", D.import, "RIGHT", 6, 0)

  D.verify = W.Button(panel, "Verify", function()
    if not current then return end
    if not ns.Guard.Has("contents") then
      D.status:SetText("|cffff5050Live blueprint lookups aren't available in this " ..
        "game build.|r")
      return
    end
    D.status:SetText("Checking code with server...")
    ns.API.Request(current.code, function(ok, res)
      if ok then
        D.status:SetText("|cff40ff40Code is live. Score updated.|r")
        ns.bus:Fire("SCORE_INVALIDATE", current.id)
        UI.ShowDetail(current)
      else
        D.status:SetText("|cffff5050This code appears to be no longer valid; it " ..
          "may have been retired by the author.|r")
      end
    end, true)
  end)
  D.verify:SetPoint("LEFT", D.chatLink, "RIGHT", 6, 0)

  -- Status text varies from empty to three wrapped lines (the import warning),
  -- so everything below it FLOWS from its bottom edge instead of sitting at a
  -- fixed offset. Fixed offsets are what let the warning eat the item list.
  D.status = W.Text(panel, S.size.small, S.textDim)
  D.status:SetPoint("TOPLEFT", D.import, "BOTTOMLEFT", 0, -8)
  D.status:SetPoint("RIGHT", -pad, 0)
  D.status:SetJustifyV("TOP")
  D.status:SetWordWrap(true)

  local manHead = W.Text(panel, S.size.small, S.gold)
  manHead:SetPoint("TOPLEFT", D.status, "BOTTOMLEFT", 0, -12)
  manHead:SetText("ITEMS")

  -- Each item row is a frame so it can carry a tooltip. C_HousingCatalog has
  -- no vendor or source data (verified 14 Aug), but every decor piece maps to
  -- a real item, and the item's own tooltip is the closest thing to "what is
  -- this and where might I have seen it".
  D.lines = {}
  local anchor, gap = manHead, -8
  for i = 1, MANIFEST_LINES do
    local row = CreateFrame("Frame", nil, panel)
    row:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, gap)
    row:SetPoint("RIGHT", -pad, 0)
    row:SetHeight(14)           -- fixed: empty rows keep the rhythm
    row:EnableMouse(true)

    local line = W.Text(row, S.size.small)
    line:SetAllPoints()
    line:SetWordWrap(false)
    row.text = line

    row:SetScript("OnEnter", function(self)
      if not self.itemID then return end
      GameTooltip:SetOwner(self, "ANCHOR_LEFT")
      GameTooltip:SetItemByID(self.itemID)
      GameTooltip:AddLine(" ")
      GameTooltip:AddLine("Click: Wowhead link  -  Shift-click: link in chat", 0.6, 0.58, 0.52)
      GameTooltip:AddLine("The Decor Catalog (press H) lists this item's vendor and zone.", 0.6, 0.58, 0.52)
      GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- The API has no source data, but the answer lives two places already:
    -- Wowhead (per-item page, crowd-sourced sources) and Blizzard's own Decor
    -- Catalog. Click hands the player a copyable Wowhead URL; shift-click
    -- links the item in chat so they can ask a guildie.
    row:EnableMouse(true)
    row:SetScript("OnMouseUp", function(self)
      if not self.itemID then return end
      if IsShiftKeyDown() then
        local link = select(2, C_Item.GetItemInfo(self.itemID))
        if link then ChatFrame_OpenChat(link) end
        return
      end
      if D.ShowItemURL then D.ShowItemURL(self.itemID) end
    end)

    D.lines[i] = row
    anchor, gap = row, -4
  end

  D.more = W.Text(panel, S.size.tiny, S.textDim)
  D.more:SetPoint("TOPLEFT", D.lines[MANIFEST_LINES], "BOTTOMLEFT", 0, -8)

  -- artist credit block (bottom of pane): the name is the product's promise
  D.credit = W.Text(panel, S.size.small, S.gold)
  D.credit:SetPoint("BOTTOMLEFT", pad, pad + 14)
  D.creditLink = W.Text(panel, S.size.tiny, S.textDim)
  D.creditLink:SetPoint("BOTTOMLEFT", pad, pad)
  D.creditLink:SetPoint("RIGHT", -pad, 0)
  D.creditLink:SetWordWrap(false)

  -- Popup with a copyable Wowhead address for one item. WoW cannot open
  -- links, so a copy box is as close as an addon gets to "show me where this
  -- comes from".
  local urlBox
  function D.ShowItemURL(itemID)
    if not urlBox then
      -- Parented to UIParent, not the library window: you may want to keep
      -- this open beside the game while you look something up.
      urlBox = W.Panel(UIParent)
      urlBox:SetSize(380, 124)   -- room for the button to clear the field
      local saved = ns.db.settings.itemBoxPos
      if saved then
        urlBox:SetPoint(saved.point, UIParent, saved.point, saved.x, saved.y)
      else
        urlBox:SetPoint("CENTER")
      end
      urlBox:SetFrameStrata("DIALOG")
      urlBox:EnableMouse(true)
      urlBox:SetMovable(true)
      urlBox:SetClampedToScreen(true)
      urlBox:RegisterForDrag("LeftButton")
      urlBox:SetScript("OnDragStart", function(self) self:StartMoving() end)
      urlBox:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        local point, _, _, x, y = self:GetPoint()
        ns.db.settings.itemBoxPos = { point = point, x = x, y = y }
      end)

      urlBox.title = W.Text(urlBox, S.size.body, S.gold)
      urlBox.title:SetPoint("TOPLEFT", pad, -pad)
      urlBox.title:SetPoint("RIGHT", -pad, 0)
      urlBox.title:SetWordWrap(false)

      urlBox.hint = W.Text(urlBox, S.size.tiny, S.textDim)
      urlBox.hint:SetPoint("TOPLEFT", pad, -30)
      urlBox.hint:SetPoint("RIGHT", -pad, 0)
      urlBox.hint:SetText("Ctrl+C to copy, or type your own search. Drag to "
        .. "move. The Decor Catalog (H) lists vendor and zone in game.")
      urlBox.hint:SetHeight(28)
      urlBox.hint:SetWordWrap(true)

      -- A real edit box, not a locked copy field: people want to tweak the
      -- search, paste something else in, or clear it entirely.
      urlBox.box = W.CopyBox(urlBox, 380 - 2 * pad)
      urlBox.box:SetPoint("TOPLEFT", pad, -62)
      urlBox.box:SetScript("OnCursorChanged", nil)   -- stop forcing selection
      urlBox.box:SetScript("OnTextChanged", nil)     -- let it be typed in
      urlBox.box:SetScript("OnEscapePressed", function(self)
        self:ClearFocus(); urlBox:Hide()
      end)

      local close = W.Button(urlBox, "Close", function() urlBox:Hide() end)
      close:SetPoint("TOPRIGHT", urlBox.box, "BOTTOMRIGHT", 0, -8)
      urlBox:Hide()
    end
    local name = C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(itemID)
    urlBox.title:SetText(name or ("Item " .. itemID))
    urlBox.box:SetValue("wowhead.com/item=" .. itemID)
    urlBox:Show()
    urlBox.box:SetFocus()
    urlBox.box:HighlightText()   -- selected on open, but free to edit after
  end

  -- Full-screen viewer. The hero image in the pane is 163px tall, which is
  -- enough to recognise a build but not enough to read its details - and
  -- detail is the whole reason someone opens a build in the first place.
  local viewer
  local function buildViewer()
    viewer = CreateFrame("Frame", "BlueprintBazaarImageFrame", UIParent)
    viewer:SetAllPoints(UIParent)
    viewer:SetFrameStrata("FULLSCREEN_DIALOG")
    viewer:EnableMouse(true)          -- also swallows clicks to the game world
    viewer:EnableMouseWheel(true)
    tinsert(UISpecialFrames, "BlueprintBazaarImageFrame")   -- ESC closes it

    local dim = viewer:CreateTexture(nil, "BACKGROUND")
    dim:SetAllPoints()
    dim:SetColorTexture(0, 0, 0, 0.85)

    viewer.image = viewer:CreateTexture(nil, "ARTWORK")
    viewer.image:SetPoint("CENTER", 0, 12)

    viewer.caption = W.Text(viewer, S.size.body, S.gold)
    viewer.caption:SetPoint("TOP", viewer.image, "BOTTOM", 0, -10)

    viewer.hint = W.Text(viewer, S.size.tiny, S.textDim)
    viewer.hint:SetPoint("TOP", viewer.caption, "BOTTOM", 0, -4)

    viewer.prev = W.Button(viewer, "<", function() D.PageViewer(-1) end)
    viewer.prev:SetWidth(34)
    viewer.prev:SetPoint("RIGHT", viewer.image, "LEFT", -12, 0)

    viewer.next = W.Button(viewer, ">", function() D.PageViewer(1) end)
    viewer.next:SetWidth(34)
    viewer.next:SetPoint("LEFT", viewer.image, "RIGHT", 12, 0)

    -- Clicking the backdrop closes; clicking the picture itself does not, so
    -- paging through a gallery never dismisses it by accident.
    viewer:SetScript("OnMouseUp", function(self) self:Hide() end)
    viewer:SetScript("OnMouseWheel", function(_, delta) D.PageViewer(-delta) end)
    viewer.image:SetScript("OnMouseUp", function() end)
    viewer:Hide()
  end

  --- Point the viewer at the current build's Nth screenshot, sized to the
  --- screen. Textures are 16:9, so one axis decides and the other follows.
  function D.PageViewer(step)
    local thumbs = current and current.thumbs
    if not (viewer and thumbs and #thumbs > 0) then return end
    if step and step ~= 0 then
      D.heroIndex = ((D.heroIndex - 1 + step) % #thumbs) + 1
      D.UpdateHero()                              -- keep the pane in step
    end
    -- Shown 1:1 and never stretched: an upscaled texture just magnifies its
    -- own blur, which is the opposite of what someone opening the picture
    -- wants. It only ever shrinks, to fit a small screen. THUMB_W has to match
    -- the generator in Tools/build_library.py.
    local THUMB_W = 768
    local sw, sh = UIParent:GetWidth(), UIParent:GetHeight()
    local w = math.min(sw * 0.9, (sh * 0.82) * (16 / 9), THUMB_W)
    viewer.image:SetSize(w, w * 9 / 16)
    viewer.image:SetTexture("Interface\\AddOns\\BlueprintBazaar\\Media\\"
      .. thumbs[D.heroIndex]:gsub("%.%a+$", ""))
    local who = (current.author and current.author.name)
      and ("  -  by " .. current.author.name) or ""
    viewer.caption:SetText((current.title or "") .. who)
    if #thumbs > 1 then
      viewer.prev:Show(); viewer.next:Show()
      viewer.hint:SetText(D.heroIndex .. " / " .. #thumbs
        .. "   -   arrows or mouse wheel to page, click the background or press ESC to close")
    else
      viewer.prev:Hide(); viewer.next:Hide()
      viewer.hint:SetText("Click the background or press ESC to close")
    end
  end

  function D.OpenViewer()
    if not (current and current.thumbs and #current.thumbs > 0) then return end
    if not viewer then buildViewer() end
    D.PageViewer(0)
    viewer:Show()
  end

  D.heroHolder:EnableMouse(true)
  D.heroHolder:SetScript("OnMouseUp", D.OpenViewer)
  D.heroHolder:SetScript("OnEnter", function(self)
    if not (current and current.thumbs and #current.thumbs > 0) then return end
    GameTooltip:SetOwner(self, "ANCHOR_LEFT")
    GameTooltip:SetText("Click to view full size", 1, 1, 1)
    GameTooltip:Show()
  end)
  D.heroHolder:SetScript("OnLeave", function() GameTooltip:Hide() end)

  D.empty = W.Text(panel, S.size.body, S.textDim)
  D.empty:SetPoint("CENTER")
  D.empty:SetText("Select a blueprint")

  -- wheel anywhere on the pane scrolls the item list
  panel:EnableMouseWheel(true)
  panel:SetScript("OnMouseWheel", function(_, delta)
    local rows = D.itemRows
    if not (rows and #rows > (D.itemShown or MANIFEST_LINES)) then return end
    D.itemOffset = (D.itemOffset or 0) - delta * 3
    D.RenderItems()
  end)
end

--- Re-run the current build's layout (called after a resize)
function UI.RelayoutDetail()
  if current and D.title then UI.ShowDetail(current) end
end

function UI.ShowDetail(entry)
  current = entry
  if not D.title then return end
  D.empty:Hide()

  D.heroIndex = 1
  D.UpdateHero()

  D.title:SetText(entry.title or "?")
  -- Build the meta line from what we actually know. An unknown type on an
  -- ad-hoc code should say nothing, not "?" - the type enum shifted between
  -- PTR and live once already.
  local bits = {}
  if entry.type then bits[#bits + 1] = W.TypeLabel(entry.type) end
  if entry.author and entry.author.name then
    bits[#bits + 1] = "by " .. entry.author.name
  end
  -- Both sides when both are set (a full house), else whichever is. The old
  -- line read only the interior figure, so exteriors showed no budget here.
  local b = entry.budget
  if b then
    local parts = {}
    if b.exterior and b.exterior > 0 then parts[#parts + 1] = b.exterior .. " exterior" end
    if b.interior and b.interior > 0 then parts[#parts + 1] = b.interior .. " interior" end
    if #parts > 0 then bits[#bits + 1] = "budget " .. table.concat(parts, " / ") end
  end
  D.meta:SetText(table.concat(bits, "  -  "))
  D.desc:SetText(entry.description or "")
  D.code:SetValue(entry.code or "")
  local aname = entry.author and entry.author.name
  D.credit:SetText(aname and ("Build by " .. aname) or "")
  local alink = entry.author and entry.author.link
  D.creditLink:SetText((alink and alink ~= "") and alink or "")
  -- Ad-hoc codes have nowhere to be favourited to: they are not on the shelf,
  -- so the Favorites tab could never show them. Offer the star only where it
  -- leads somewhere.
  local adhoc = type(entry.id) == "string" and entry.id:match("^shared:") ~= nil
  D.fav:SetShown(not adhoc)
  D.fav:SetOn(ns.db.favorites[entry.id])

  -- Say up front when an import cannot happen from where the player is
  -- standing. Waiting for the click means they learn it by failing, and a
  -- first-time player who fails twice does not try a third time - one of them
  -- said exactly that on CurseForge before deleting the addon.
  local canImport, why = ns.API.CanImport()
  D.status:SetText(canImport and "" or ("|cffffcc00" .. why .. "|r"))

  -- Line 1: will the house take it. Colour on the verdict word only; the
  -- numbers behind it dim. Red = no, green = server-confirmed yes, dim = a
  -- table lookup the server has not yet confirmed.
  -- Amber for a goal ("needs level 3", "needs a room"), red only for what
  -- no amount of playing changes, green once it fits. The numbers after it
  -- in the ordinary text colour: dimmed they read as an apology.
  local iv = UI.ImportVerdict(entry)
  if iv.head then
    local col
    if iv.ok == false then col = iv.hard and "|cffd94545" or "|cffe6a626"
    elseif iv.ok == true then col = "|cff4fbf4f"
    else col = "|cffe6a626" end
    local line = col .. iv.head .. "|r"
    if iv.detail then line = line .. "  |cffebe6d9-  " .. iv.detail .. "|r" end
    D.verdict:SetText(line)
    D.verdict:Show()
    D.LayoutStatus(true)
  else
    D.verdict:SetText("")
    D.verdict:Hide()
    D.LayoutStatus(false)
  end

  -- Line 2: how much of the decor is already owned. Percent in the score
  -- colour, the rest dim; items and dyes named separately because "missing
  -- 24 items" when 20 are bottles of dye reads as a wall of collecting when
  -- it is really a vendor trip. Dye the catalog cannot see is "unchecked",
  -- which is true, rather than "missing", which may not be.
  local sc = UI.ScoreFor(entry)
  if sc.pct == nil then
    D.score:SetText("|cff999485Owned decor unknown  -  Verify asks the server.|r")
  else
    local r, g, b = ns.Score.RingColor(sc.pct)
    local pctText = ("|cff%02x%02x%02x%d%%|r|cffebe6d9 of the decor owned|r")
      :format(r * 255, g * 255, b * 255, math.floor(sc.pct * 100 + 0.5))
    local dyes = sc.dyeMissing or 0
    local unchecked = sc.dyeUnknown or 0
    local items = #sc.missing - dyes
    local parts = {}
    if items > 0 then parts[#parts + 1] = "missing " .. items .. " item" .. (items > 1 and "s" or "") end
    if dyes > 0 then parts[#parts + 1] = dyes .. " dye" .. (dyes > 1 and "s" or "") .. " to buy" end
    if unchecked > 0 then
      parts[#parts + 1] = unchecked .. " dye" .. (unchecked > 1 and "s" or "") .. " unchecked"
    end
    local extra = ""
    if #parts > 0 then
      extra = "  -  " .. table.concat(parts, ", ")
      if sc.estGoldTotal > 0 then extra = extra .. ", ~" .. sc.estGoldTotal .. "g" end
      if sc.unobtainableCount > 0 then
        extra = extra .. ", " .. sc.unobtainableCount .. " unobtainable"
      end
      extra = "|cff999485" .. extra .. "|r"
    end
    D.score:SetText(pctText .. extra)
  end

  -- manifest lines: live payload wins, else baked manifest
  local live = ns.API.Cached(entry.code)
  local rows = {}
  if live then
    for _, e in ipairs(live.entries) do
      rows[#rows + 1] = { name = e.name or ("record " .. tostring(e.recordID)),
        qty = e.qty, missing = e.missing, recordID = e.recordID,
        contentType = e.contentType }
    end
  else
    for _, m in ipairs(entry.manifest or {}) do
      local rid = m.recordID or m.entryID
      local owned = ns.Catalog.OwnedByRecord(rid)
      rows[#rows + 1] = { name = m.name or ("record " .. tostring(rid)),
        qty = m.qty or 1, missing = owned and math.max(0, (m.qty or 1) - owned) or nil,
        recordID = rid }
    end
  end
  D.itemRows = rows
  D.itemOffset = 0
  D.RenderItems()
end

--- Draw the visible slice of the item list. First user bug report (Saiffer,
--- day 2): big builds got "+26 more items" with no way to see them. The list
--- scrolls with the mouse wheel now; the row frames stay fixed and the data
--- slides underneath, which keeps the credit-floor clamping intact.
function D.RenderItems()
  local rows = D.itemRows or {}

  -- Clamp against how many rows actually FIT (known from the last render),
  -- not the frame-pool size - otherwise a short window could never scroll to
  -- the end of a long list.
  local capacity = D.itemShown or MANIFEST_LINES
  if capacity < 1 then capacity = 1 end
  local maxOffset = math.max(0, #rows - capacity)
  D.itemOffset = math.max(0, math.min(D.itemOffset or 0, maxOffset))

  for i = 1, MANIFEST_LINES do
    local row = rows[i + D.itemOffset]
    if row then
      local color = "|cff40ff40"
      local suffix = ""
      if row.missing == nil then
        color = "|cff999999"
        -- The catalog cannot count dye (it is a currency-like consumable, not
        -- a collected piece), so before a live check every dye row is grey.
        -- Say what that means rather than leaving a bare grey line under a
        -- summary that just said "1 dye to buy".
        if ns.Score.IsDye(row) then suffix = "  (dye - Verify to check)" end
      elseif row.missing > 0 then
        if ns.Score.IsDye(row) then
          -- Amber, not red: a dye you are short of is a purchase, not a
          -- piece of decor you have yet to find.
          color = "|cffe0a030"
          suffix = "  (buy " .. row.missing .. ")"
        else
          color = "|cffff5050"
          suffix = "  (missing " .. row.missing .. ")"
        end
      end
      D.lines[i].text:SetText(color .. row.qty .. "x " .. row.name .. suffix .. "|r")
      D.lines[i].itemID = row.recordID and ns.Catalog.ItemIDByRecord(row.recordID) or nil
      D.lines[i]:Show()
    else
      D.lines[i].text:SetText("")
      D.lines[i].itemID = nil
      D.lines[i]:Hide()
    end
  end

  -- Only show as many item lines as actually fit above the credit block. The
  -- pane is resizable, and a short window used to run the list straight
  -- through the credit and out of the frame.
  local shown = math.min(#rows - D.itemOffset, MANIFEST_LINES)
  local floorY = D.credit:GetTop()
  if floorY then
    for i = 1, shown do
      local line = D.lines[i]
      local bottom = line:GetBottom()
      if bottom and bottom < floorY + 14 then
        line:Hide()
        shown = math.min(shown, i - 1)
      end
    end
  end
  D.itemShown = math.max(shown, 0)

  local above = D.itemOffset
  local below = #rows - D.itemOffset - D.itemShown
  local bits = {}
  if above > 0 then bits[#bits + 1] = above .. " above" end
  if below > 0 then bits[#bits + 1] = below .. " below" end
  D.more:SetText(#bits > 0 and ("scroll: " .. table.concat(bits, ", ")) or "")
  if floorY then
    local mb = D.more:GetBottom()
    if mb and mb < floorY + 14 then D.more:SetText("") end
  end
end
