-- VDS.TooltipRecipes
-- ============================================================================
-- Central registry for every tooltip in VDS. Spec authors reference recipes
-- by name; the layout validator enforces that every widget either declares
-- `tooltip = { recipe = "Name" }` or explicit `tooltip = false`.
--
-- Recipe value types accepted by TooltipEngine:
--   string   -- wraps to { title = s, anchor = "ANCHOR_TOPRIGHT" }
--   table    -- passed directly as TE def
--   function -- called per hover (function(self) -> def | nil)
--
-- Adding a recipe:
--   1. Add the entry below (alphabetical within its section)
--   2. Reference it via { recipe = "YourRecipeName" } in spec or row registration
--   3. Validator catches name typos at boot
--
-- Future extension (not yet): symbolic constants
--   VDS.TooltipKeys = { CLOSE = "Close", ... }
--   tooltip = { recipe = VDS.TooltipKeys.CLOSE }
-- One-day refactor when tooltip count justifies; until then bare strings.

VDS = VDS or {}
VDS.TooltipRecipes = VDS.TooltipRecipes or {}

local R = VDS.TooltipRecipes

-- ===== Static text recipes ==================================================

-- Window chrome
R.Close = { title = "Close", anchor = "ANCHOR_LEFT" }
R.Config = "Open the config sidebar"
R.Help   = "Show help"

-- About / outreach
R.Discord = "Join the Vamoose Discord community"
R.Coffee  = "Support the addon - buy me a coffee"

-- Config sidebar checkboxes
R.OwnedOnly             = "Show only dyes you've collected"
R.ReplaceBlizzardPicker = "Use VDS's dye picker instead of Blizzard's when editing housing decor"
R.MinimapToggle         = "Show the VDS minimap icon"
R.DebugToggle           = "Print VDS debug breadcrumbs to chat (same as /vds debug)"
R.OpenDebugLog          = "Open the VDS log window (buffered debug trace; requires Debug Mode on to populate)"

-- Live edit footer
R.AutoSave = { title = "Auto-save", body = "Save the applied dye combination as a variant" }
R.Snapshot = "Save these dyes as a variant. Already saved? Bumped to top."
-- Keybind line for the live-action tooltips: shows the player's bound key, or
-- nudges them to set one. Shared by Apply + Revert.
local function keybindExtra(command)
    local k = _G.GetBindingKey and _G.GetBindingKey(command)  -- exception(boundary): GetBindingKey nil pre-Bindings load
    if k then
        return { text = "Keybind: " .. k, r = 0.55, g = 0.62, b = 0.70 }
    end
    return { text = "Tip: bind a key for this in Options > Keybindings.", r = 0.50, g = 0.54, b = 0.60 }
end

function R.Revert()
    return {
        title      = "Revert",
        anchor     = "ANCHOR_TOP",
        extraLines = {
            { text = "Revert pending dye changes back to the last applied state.", r = 0.82, g = 0.82, b = 0.85 },
            keybindExtra("VDSREVERTDYES"),
        },
    }
end
-- Apply: dynamic -- when the ownership gate blocks the commit,
-- Controller_Live stamps the unowned staged dye IDs on the widget; resolve
-- names per-hover so a late-arriving catalog still names them.
function R.Apply(widget)
    local missing = widget._unownedStagedDyeIDs
    if not missing then
        -- Can-apply: list the dyes this commit will consume. Each staged change to
        -- a non-empty channel costs one of that dye from your bags (a re-applied or
        -- cleared channel costs nothing), so diff the live slots against the snapshot.
        local live = VDS.Store:GetState().session.ui.live  -- top-level only: hover-time cost read
        local origBySlot = {}
        for _, orig in ipairs(live.originalDyes or {}) do  -- exception(nullable): originalDyes absent pre-LIVE_MODE_ENTER
            origBySlot[orig.slotID] = orig.dyeID or 0  -- exception(boundary): Blizzard no-dye read is nil/0
        end
        local counts, order = {}, {}
        for _, slot in ipairs(live.slots or {}) do  -- exception(nullable): slots absent until LIVE_MODE_ENTER
            local cur = slot.dyeColorID or 0  -- exception(boundary): Blizzard no-dye read is nil/0
            if cur > 0 and cur ~= (origBySlot[slot.ID] or 0) then
                if not counts[cur] then order[#order + 1] = cur end
                counts[cur] = (counts[cur] or 0) + 1
            end
        end
        local extras
        if #order == 0 then  -- only clears staged; nothing consumed
            extras = { { text = "Commits the pending changes.", r = 0.82, g = 0.82, b = 0.85 } }
        else
            extras = { { text = "Commits the pending changes. Consumes:", r = 0.82, g = 0.82, b = 0.85 } }
            for _, dyeID in ipairs(order) do
                local info = VDS.DyeOwnershipObserver:ResolveInfo(dyeID)
                local name = (info and info.name) or ("dye " .. dyeID)  -- exception(nullable): catalog miss pre-CATALOG_READY
                extras[#extras + 1] = { text = "  " .. name .. "  x" .. counts[dyeID], r = 1, g = 1, b = 1 }
            end
        end
        extras[#extras + 1] = keybindExtra("VDSAPPLYDYES")  -- bound key, or a nudge to set one
        return { title = "Apply", anchor = "ANCHOR_TOP", extraLines = extras }
    end
    local c = VDS.Theme:GetColor("semantic.error")
    local extras = {}
    for _, dyeID in ipairs(missing) do
        local info = VDS.DyeOwnershipObserver:ResolveInfo(dyeID)
        extras[#extras + 1] = {
            text = "You don't own " .. (info and info.name or ("dye " .. dyeID)),  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
            r = c.r, g = c.g, b = c.b,
        }
    end
    extras[#extras + 1] = {
        text = "Buy or craft it, or pick a dye you own. The game previews "
            .. "unowned dyes but silently reverts them on commit.",
        r = 0.7, g = 0.7, b = 0.7,
    }
    return {
        title      = "Can't apply yet",
        anchor     = "ANCHOR_TOP",
        extraLines = extras,
    }
end
R.ClearAllSlots = "Clear every dye selection on this decor."

-- Search inputs
R.SearchDyesHerbs = "Filter dyes and herbs by name"
R.SearchDecor    = "Filter dyeable decor by name"

-- Detail panel buttons
R.HerbSort      = "Change herb sort order"
R.PaletteRename = "Rename this palette"
R.PaletteDelete = "Delete this palette"
R.PaletteExport = "Export this palette as a sharable code"
R.PaletteDone   = "Finish editing this palette"

-- Custom palette panel
R.PaletteNew    = "Create a new custom palette"
R.PaletteImport = "Import a palette from a sharable code"

-- Canvas controls
R.CategoryFilter  = "Filter dyeable decor by category"
R.CanvasRandom    = "Apply a random combination from the current scope"
R.CanvasHarmonise = "Auto-fill every channel with a colour scheme built from the active channel."
R.CanvasClear     = "Clear all dye channels on the previewed decor"
R.ScopeFilter    = "Narrow the dye browser to a specific scope (e.g. one palette)"
R.ScopeReset     = "Clear the scope back to All Dyes"

-- Stock tab controls
R.StockModeToggle    = "Switch between buying dyes and crafting herbs"
R.StockCollapseAll   = "Collapse all color groups"
R.StockExpandAll     = "Expand all color groups"
R.StockRestock       = "Send this list to Auctionator's shopping list"
R.StockResetTargets  = "Reset every stock target"
R.StockBuyGap        = "Subtract herbs you already own (bag, bank, warband) from the totals."

-- Live edit slot row: dynamic per-hover so the title shows the current slot
-- index and the body lists the dye name (if any) plus click affordances.
function R.LiveSlot(widget)
    local idx   = widget._slotIdx
    local slots = VDS.Store:GetState().session.ui.live.slots  -- top-level only: tooltip read of current slot
    local slot  = slots and idx and slots[idx]  -- exception(nullable): hover can outlive live mode; slots/idx gone = bare tooltip
    local extras = {}
    if slot and slot.dyeColorID and slot.dyeColorID ~= 0 then  -- exception(nullable): empty slot or no-dye sentinel 0
        local info = VDS.DyeOwnershipObserver:ResolveInfo(slot.dyeColorID)
        if info and info.name then  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
            extras[#extras + 1] = { text = info.name, r = 1, g = 1, b = 1 }
        end
    end
    extras[#extras + 1] = { text = "Left-click: focus this slot", r = 0.7, g = 0.7, b = 0.7 }
    extras[#extras + 1] = { text = "Right-click: clear the dye",  r = 0.7, g = 0.7, b = 0.7 }
    extras[#extras + 1] = { text = "Apply commits the change.",   r = 0.55, g = 0.55, b = 0.55 }
    return {
        title      = "Slot " .. tostring(idx or "?"),
        anchor     = "ANCHOR_TOP",
        extraLines = extras,
    }
end

-- Recent-dye strip marker: static explainer for the strip itself (one
-- shared table; no per-hover garbage).
R.RecentStrip = {
    title  = "Recent dyes",
    anchor = "ANCHOR_TOP",
    extraLines = {
        { text = ("The last %d dyes you applied in live mode, newest first.")
            :format(VDS.Constants.LIVE_RECENT_DYES_MAX),
          r = 0.7, g = 0.7, b = 0.7 },
        { text = "Click a swatch to apply it to the focused slot.",
          r = 0.55, g = 0.55, b = 0.55 },
    },
}

-- Recent-dye swatch: dynamic per-hover so the title shows the dye name
-- (donor parity VDS_LivePreview.lua:555-562).
function R.RecentDye(widget)
    local info = VDS.DyeOwnershipObserver:ResolveInfo(widget._dyeID)
    return {
        title      = info and info.name or "Dye",  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
        anchor     = "ANCHOR_TOP",
        extraLines = {
            { text = "Click: apply to the focused slot", r = 0.7,  g = 0.7,  b = 0.7 },
            { text = "Apply commits the change.",        r = 0.55, g = 0.55, b = 0.55 },
        },
    }
end
R.StockMinusAll      = "Decrease every stock target by 1"
R.StockPlusAll       = "Increase every stock target by 1"

-- ===== Dynamic recipes (read row/widget state per hover) ====================

-- Dye row + herb row: shared attach helper in VDS_Rows_Dye.lua dispatches on
-- self._lsRowKind. The composer reads current Store state to vary lines per
-- filter mode (custom / canvas / default) and own/unown status.
function R.DyeOrHerbRow(self)
    local extras = {}  -- exception(false-positive): per-row-kind tooltip grammar; herb/dye branches are each flat
    if self._lsRowKind == "herb" then
        local name = self._herbNameText and self._herbNameText:GetText() or "Herb"
        if self._herbID then
            local seenCats = {}
            local primary = VDS.StaticData.GetHerbPigmentEntry(self._herbID)
            if primary and primary.pigment.category then  -- exception(nullable): herb-pigment lookup can miss
                seenCats[primary.pigment.category] = true
            end
            local others = {}
            for _, pig in pairs(VDS.StaticData.GetPigments()) do
                if pig.category and not seenCats[pig.category] then
                    for _, h in ipairs(pig.herbs or {}) do
                        if h == self._herbID then
                            others[#others + 1] = pig.category
                            seenCats[pig.category] = true
                            break
                        end
                    end
                end
            end
            if #others > 0 then
                extras[#extras + 1] = { text = "Also makes: " .. table.concat(others, ", "),
                                        r = 0.58, g = 0.72, b = 0.88 }
            end
        end
        extras[#extras + 1] = { text = "Shift-click to search AH", r = 0.5, g = 0.5, b = 0.5 }
        return { title = name, anchor = "ANCHOR_RIGHT", extraLines = extras }
    end

    -- dye row
    local name = self._nameText and self._nameText:GetText() or "Dye"
    local C = VDS.Constants.COLOR_CODE
    local state = VDS.Store:GetState()  -- top-level only: tooltip composer
    local owned = self._dyeID and state.session.dyes.ownership.perDye[self._dyeID] or 0
    if owned > 0 then
        extras[#extras + 1] = C.success .. "Owned: " .. owned .. C.reset
    end
    local mode = state.session.ui.filterMode
    if mode == "custom" and state.session.ui.byView[mode].editingThemeName then
        extras[#extras + 1] = { text = "Click to add or remove from palette", r = 0.7, g = 0.7, b = 0.7 }
    elseif mode == "canvas" then
        extras[#extras + 1] = { text = "Click to apply to active channel", r = 0.7, g = 0.7, b = 0.7 }
        extras[#extras + 1] = { text = "Right-click to clear channel",    r = 0.5, g = 0.5, b = 0.5 }
    else
        extras[#extras + 1] = { text = "Shift-click to search AH",        r = 0.5, g = 0.5, b = 0.5 }
    end
    return { title = name, anchor = "ANCHOR_RIGHT", extraLines = extras }
end

-- Custom palette row: title is the palette name; subtitle counts dyes.
function R.CustomPaletteRow(self)
    if not self._paletteName then return nil end
    local count = self._count and self._count:GetText() or "0"
    return {
        title  = self._paletteName,
        anchor = "ANCHOR_RIGHT",
        extraLines = {
            { text = count .. " dyes", r = 0.7, g = 0.7, b = 0.7 },
            { text = "Click to edit",  r = 0.5, g = 0.5, b = 0.5 },
        },
    }
end

-- Canvas loadout chip: dispatches on loadout vs save-tile vs (pulsing) state.
function R.LoadoutChip(self)
    if self._loadout then
        return {
            title  = self._loadout.name or ("Loadout " .. self._loadout.idx),
            anchor = "ANCHOR_RIGHT",
            extraLines = {
                { text = "Left-click to apply",         r = 0.7, g = 0.7, b = 0.7 },
                { text = "Right-click to rename",       r = 0.7, g = 0.7, b = 0.7 },
                { text = "Shift+right-click to delete", r = 0.7, g = 0.7, b = 0.7 },
            },
        }
    elseif self._isSaveTile then
        if self._pulsing then
            return {
                title  = "Loadout not saved",
                anchor = "ANCHOR_RIGHT",
                extraLines = { { text = "Click to save", r = 0.7, g = 0.7, b = 0.7 } },
            }
        end
        return { title = "Save current as new loadout", anchor = "ANCHOR_RIGHT" }
    end
    return nil
end

-- Herb dye icon: Blizzard item tooltip via itemID.
function R.HerbDyeIcon(self)
    if not self._itemID then return nil end
    return { itemID = self._itemID, anchor = "ANCHOR_RIGHT" }
end

-- Custom-palette chip in the chip wall: shows dye name + remove hint.
-- Only fires for customDyeChip-marked chips (button stamps _isCustomDyeChip
-- + _chipID at build time so this recipe reads self directly, no closure).
function R.CustomPaletteChip(self)
    if not (self._isCustomDyeChip and self._chipID) then return nil end
    local info = VDS.DyeOwnershipObserver:ResolveInfo(self._chipID)
    return {
        title      = info and info.name or ("Dye " .. self._chipID),  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
        anchor     = "ANCHOR_TOP",
        extraLines = { { text = "Click to remove from palette", r = 0.7, g = 0.7, b = 0.7 } },
    }
end

-- Warehouse dye row: have/target/need + cheapest herb price + override note.
-- Title prepended with the dye item icon via |T<fileID>:16:16|t escape.
-- Gates on _lsRowKind: pooled Button can rebind to a header kind, so we
-- check the CURRENT kind to avoid firing on stale _dyeID stamps.
function R.WarehouseDyeRow(self)
    if self._lsRowKind ~= "warehouseRow" then return nil end
    if not self._dyeID then return nil end
    local state = VDS.Store:GetState()  -- top-level only: hover composer
    local info  = state.session.dyes.catalog.infos[self._dyeID]
    if not info then return nil end

    local have, target = self._have, self._target
    local need = math.max(0, target - have)
    local lines = { string.format("Have %d  -  Target %d  -  Need %d", have, target, need) }

    local recipe = info.itemID and VDS.StaticData.GetDyeRecipe(info.itemID)
    if recipe then
        local pick = VDS.StaticData.PickCheapestHerb(recipe.herbItemIDs)
        if pick then
            -- Peek (sync cache) is pre-warmed at PLAYER_LOGIN; falls through
            -- to "item N" only for truly uncached itemIDs.
            local herbName = VDS.ItemNameResolver:Peek(pick.itemID) or ("item " .. pick.itemID)
            local priceStr = VDS.StaticData.FormatGold(pick.unitPrice) or "?"
            lines[#lines + 1] = string.format("Cheapest herb: %s @ %s (x%d per craft)",
                herbName, priceStr, recipe.qtyPerCraft)
        end
    end
    if self._hasOverride then
        lines[#lines + 1] = string.format("(per-dye override -- group default %d)",
            self._groupDefault)
    end

    local titleText = info.name or ("Dye " .. self._dyeID)
    local titleIcon = info.itemID and _G.C_Item.GetItemIconByID(info.itemID)
    if titleIcon then titleText = "|T" .. titleIcon .. ":16:16|t " .. titleText end
    return { title = titleText, anchor = "ANCHOR_RIGHT", extraLines = lines }
end

-- ===== Live harmony strategy buttons (Stage B, §5.3) ========================
-- Per-button tooltip: strategy name + effect line + the dye names it would
-- fill into the non-focused slots. Reads the current live state to preview
-- the result WITHOUT applying it. Widget stamps _harmonyKey at Wire time.
--
-- "Focus a slot with a dye first" shown when live.focusedSlot has no dye, or
-- when live mode is not active.

-- Proportion bar of solid colour blocks via inline-texture vertex tint
-- (|T...:r:g:b|t -- tint args are 0-255, per Reference/LUA_AND_WOW_PATTERNS.md).
-- A literal "|" can't be a glyph (it is WoW's escape char), so draw real blocks.
-- Shared by the Live strategy buttons (R.HarmoniseBtn) and the band chips (R.HarmonyChip).
local BAR_PX = 180  -- total bar width, pixels (wide enough to read the proportions)
local function buildProportionBar(picks, ratios)
    local total = 0
    for i = 1, #picks do total = total + (ratios[i] or 0) end
    if total <= 0 then return "" end
    local segments = {}
    for i, pick in ipairs(picks) do
        local w = math.max(6, math.floor(BAR_PX * (ratios[i] or 0) / total))
        local c = pick.swatchRGB
        segments[#segments + 1] = string.format(
            "|TInterface\\Buttons\\WHITE8X8:11:%d:0:0:8:8:0:8:0:8:%d:%d:%d|t",
            w, math.floor(c.r * 255), math.floor(c.g * 255), math.floor(c.b * 255))
    end
    return table.concat(segments)
end

-- Exposed so the Canvas Harmonise menu renders the same proportion "area guide"
-- bar as the Colors detail tooltip (picks = {swatchRGB=...}, ratios = BAR[rel]).
R.BuildProportionBar = buildProportionBar

local HARMONISE_EFFECT = {
    Neutral       = "Grounds the colour. Safe with anything.",
    Monochromatic = "Lighter and darker of the same hue. Calm and cohesive.",
    Analogous     = "Wheel neighbours. Natural, blended.",
    Complement    = "The opposite. Bold pop of contrast.",
    Split         = "Two flanking the opposite. Bold but softer. Best for 3 slots.",
    Triad         = "Two evenly-spaced hues. Most energy.",
    Clash         = "Deliberately discordant. Creates vibration, not mud.",
    -- Neutral-anchor strategies (focused dye is Brown/Grey/Black/White).
    Tones         = "Same undertone -- a calm, cohesive neutral palette.",
    Contrast      = "Warm meets cool -- subtle contrast within the neutrals.",
    Accent        = "A vivid pop against the neutral. Use it on the smallest areas.",
}

-- Resolve a dye name from the catalog, falling back to "Dye N" on miss.
local function harmonyDyeName(infos, dyeID)
    local info = infos[dyeID]  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
    if not info then return "Dye " .. dyeID end
    return info.name or ("Dye " .. dyeID)  -- exception(nullable): info.name absent on partial catalog entry
end

-- Lighten very dark dye colours so a tinted name stays legible on the dark tooltip.
local function legibleTint(c)
    local m = math.max(c.r, c.g, c.b)
    if m >= 0.45 or m <= 0 then return c.r, c.g, c.b end
    local s = 0.45 / m
    return c.r * s, c.g * s, c.b * s
end

-- Append per-pick name lines onto extras, each tinted its own dye colour.
-- Extracted to keep HarmoniseBtn flat.
local function appendPickLines(extras, picks, infos)
    if #picks == 0 then
        extras[#extras + 1] = { text = "No matching dyes found.", r = 0.6, g = 0.6, b = 0.6 }
        return
    end
    extras[#extras + 1] = { text = "Fills other slots with:", r = 0.6, g = 0.6, b = 0.6 }
    for _, pick in ipairs(picks) do
        local name    = harmonyDyeName(infos, pick.dyeID)
        local ownText = pick.owned and "" or " (unowned)"
        local r, g, b = legibleTint(pick.swatchRGB or { r = 1, g = 1, b = 1 })
        extras[#extras + 1] = { text = "  " .. name .. ownText, r = r, g = g, b = b }
    end
end

-- Walk live.slots looking for the focused slot's dyeColorID. Returns nil when
-- the focused slot has no dye (sentinel 0 or absent).
local function focusedSlotDyeID(live)
    if not live.focusedSlot then return nil end  -- exception(nullable): no slot focused yet
    if not live.slots then return nil end         -- exception(nullable): slots absent until LIVE_MODE_ENTER
    for _, slot in ipairs(live.slots) do
        if slot.ID == live.focusedSlot then
            local id = slot.dyeColorID
            return (id and id ~= 0) and id or nil  -- exception(boundary): Blizzard sentinel 0 = no dye
        end
    end
    return nil
end

-- How many non-focused channels the auto-fill targets (= channels that get dyed).
local function otherSlotCount(live)
    if not live.slots then return 0 end  -- exception(nullable): slots absent until LIVE_MODE_ENTER
    local n = 0
    for _, slot in ipairs(live.slots) do
        if slot.ID ~= live.focusedSlot then n = n + 1 end
    end
    return n
end

function R.HarmoniseBtn(self)
    local key = self._harmonyKey
    if not key then return nil end

    local state = VDS.Store:GetState()  -- top-level only: hover-time preview
    local live  = state.session.ui.live

    if not live.active then
        return { title = key, anchor = "ANCHOR_TOP",
                 extraLines = { { text = "Enter housing customize mode to use.", r = 0.6, g = 0.6, b = 0.6 } } }
    end

    local focusedDyeID = focusedSlotDyeID(live)
    if not focusedDyeID then
        return { title = key, anchor = "ANCHOR_TOP",
                 extraLines = { { text = "Focus a slot with a dye first.", r = 0.7, g = 0.7, b = 0.7 } } }
    end

    local infos  = state.session.dyes.catalog.infos
    local effect = HARMONISE_EFFECT[key] or key
    local extras = { { text = effect, r = 0.78, g = 0.78, b = 0.80 } }

    local res = VDS.Harmony.forDye(focusedDyeID, infos, state.session.dyes.ownership.perDye)
    if not res then
        extras[#extras + 1] = { text = "Catalog not ready.", r = 0.6, g = 0.6, b = 0.6 }
        return { title = key, anchor = "ANCHOR_TOP", extraLines = extras }
    end

    local n = otherSlotCount(live)
    if n == 0 then
        extras[#extras + 1] = { text = "Single-channel decor -- nothing to auto-fill.", r = 0.6, g = 0.6, b = 0.6 }
        return { title = key, anchor = "ANCHOR_TOP", extraLines = extras }
    end

    -- Preview EXACTLY what the click applies: the full auto-fill for every channel.
    local picks = VDS.Harmony.autoFill(res, key, n)

    -- Proportion bar: the focused (main) dye + every auto-filled dye, sized by
    -- deployment area. BAR[key] covers the strategy's own picks; pad picks
    -- (grounding neutrals / mono shades) get a medium grounding share.
    local ratios = {}
    for i, v in ipairs(VDS.HarmonyWheel.BAR[key] or {}) do ratios[i] = v end
    local anchorRGB = infos[focusedDyeID] and infos[focusedDyeID].swatchRGB  -- exception(nullable): catalog miss pre-ready
    local allPicks  = {}
    if anchorRGB then allPicks[1] = { swatchRGB = anchorRGB } end
    for _, p in ipairs(picks) do allPicks[#allPicks + 1] = p end
    while #ratios < #allPicks do ratios[#ratios + 1] = 28 end  -- grounding share for pad picks
    if #allPicks >= 2 then
        extras[#extras + 1] = { text = buildProportionBar(allPicks, ratios), r = 1, g = 1, b = 1 }
    end

    appendPickLines(extras, picks, infos)
    extras[#extras + 1] = { text = "Fills every other slot. Main slot unchanged.", r = 0.50, g = 0.50, b = 0.52 }

    return { title = key, anchor = "ANCHOR_TOP", extraLines = extras }
end

-- ===== Harmony band chips ====================================================
-- Per-chip tooltip: relationship name + one-line effect + a proportion bar
-- (2-/3-segment) in the actual harmonised dye colours. Widget stamps:
--   _harmonyRel     : "Neutral" | "Analogous" | "Complement" | "Discordant" | "Triad"
--   _harmonyPicks   : array of pick tables { dyeID, swatchRGB, owned }
--   _harmonyAnchorID: dyeID of the anchor dye
--
-- The bar is scene advice (how much area each colour occupies in the room),
-- NOT per-channel assignment. Shown for all relationships including 1-channel.

local HARMONY_EFFECT = {
    Neutral       = "Grounds the composition. Pairs well with any colour.",
    Monochromatic = "Lighter and darker of the same hue. Calm and cohesive.",
    Analogous     = "Natural and cohesive -- neighbouring hues blend without tension.",
    Complement    = "Bold pop -- the opposite colour creates maximum contrast.",
    Split         = "The two colours flanking the opposite. Bold but softer.",
    Triad         = "Three equidistant hues: dynamic energy, one colour dominates.",
    Clash         = "Deliberately dissonant. Creates energy and visual vibration.",
    Tones         = "Same undertone -- a calm, cohesive neutral palette.",
    Contrast      = "Warm meets cool -- subtle contrast within the neutrals.",
    Accent        = "A vivid pop against the neutral. Use it on the smallest areas.",
}

-- buildProportionBar + BAR_PX are defined above the Harmonise section (shared by
-- the Live strategy buttons and these band chips).

function R.HarmonyChip(self)
    local rel    = self._harmonyRel
    local picks  = self._harmonyPicks
    if not (rel and picks and #picks > 0) then return nil end

    local effect  = HARMONY_EFFECT[rel] or rel
    local ratios  = VDS.HarmonyWheel.BAR[rel] or {}

    -- Build anchor pick (the selected dye) + suggestion picks for the bar.
    local anchorID = self._harmonyAnchorID
    local allPicks = {}
    if anchorID then
        local info = VDS.DyeOwnershipObserver:ResolveInfo(anchorID)
        if info and info.swatchRGB then  -- exception(nullable): catalog lookup can miss pre-CATALOG_READY
            allPicks[#allPicks + 1] = { swatchRGB = info.swatchRGB }
        end
    end
    for _, p in ipairs(picks) do
        allPicks[#allPicks + 1] = p
        if #allPicks >= #ratios then break end
    end

    local extraLines = {
        { text = effect, r = 0.78, g = 0.78, b = 0.80 },
    }
    if #allPicks >= 2 then
        local bar = buildProportionBar(allPicks, ratios)
        extraLines[#extraLines + 1] = { text = bar,                        r = 1, g = 1, b = 1 }
        extraLines[#extraLines + 1] = { text = "Area guide for the decor", r = 0.78, g = 0.78, b = 0.80 }
    end
    extraLines[#extraLines + 1] = { text = "Click to select and scroll to this dye.", r = 0.78, g = 0.78, b = 0.80 }
    if not picks[1].owned then
        extraLines[#extraLines + 1] = { text = "You don't own this dye.", r = 0.85, g = 0.35, b = 0.35 }
    end

    return {
        title      = rel,
        anchor     = "ANCHOR_TOP",
        extraLines = extraLines,
    }
end

-- Herb list row: Blizzard item tooltip + reverse-lookup of which dyes use this
-- herb. Hover-time catalog walk; memoized reverse index would let this become
-- ed-projected, but at current catalog size the walk is cheap.
function R.HerbListRow(self)
    if not self._itemID then return nil end
    local catalog = VDS.Store:GetState().session.dyes.catalog  -- top-level only
    local consumers = {}
    for _, dyeID in ipairs(catalog.dyes or {}) do
        local info = catalog.infos[dyeID]
        if info and info.itemID then  -- exception(nullable): catalog info/itemID can miss for unmapped dyes
            local recipe = VDS.StaticData.GetDyeRecipe(info.itemID)
            if recipe then
                for _, hid in ipairs(recipe.herbItemIDs) do
                    if hid == self._itemID then
                        consumers[#consumers + 1] = info.name or ("Dye " .. dyeID)
                        break
                    end
                end
            end
        end
    end
    local body = (#consumers > 0)
        and ("Used in: " .. table.concat(consumers, ", "))
        or  "Used in: (no recipes match)"
    return {
        itemID     = self._itemID,
        anchor     = "ANCHOR_RIGHT",
        extraLines = { { text = body, r = 0.7, g = 0.7, b = 0.7, wrap = true } },
    }
end
