-- DST_Unlocks.lua
-- Build 42.x — Centralized unlock namespace for DST
-- Owns: Auto-Learn cache & readers + RequiredSkill Craft/Buildable readers.
-- Exposes: DST.Unlocks.{Cache, buildAutoLearnCache, getAutoLearnedFor, getCraftUnlocksBase, getBuildableUnlocksBase}

local DST = rawget(_G, "DST") or {}
DST.SkillTooltips = DST.SkillTooltips or {}
local ST = DST.SkillTooltips

local Unlocks = {}
Unlocks.Cache = Unlocks.Cache or {}   -- unified cache home
Unlocks.Cache.CraftUnlocksByPerk    = Unlocks.Cache.CraftUnlocksByPerk    or {}
Unlocks.Cache.BuildableUnlocksByPerk = Unlocks.Cache.BuildableUnlocksByPerk or {}

-- Helpers (local to this file)
local function _stableRecipeId(r)
    -- prefer module/name style if available; otherwise fall back to getOriginalName / getName
    -- ⚠️ Empirical (Build 42.11): API surface varies a bit across subversions and mods
    local okMod, mod = pcall(function() return r.getModule and r:getModule() or nil end)
    local okName, nm  = pcall(function() return r.getOriginalName and r:getOriginalName() or (r.getName and r:getName()) end)
    if okName and nm then
        if okMod and mod then
            local okMName, mname = pcall(function() return mod.getName and mod:getName() or nil end)
            if okMName and mname and mname ~= "" then
                return tostring(mname) .. ":" .. tostring(nm)
            end
        end
        return tostring(nm)
    end
    -- last resort
    return tostring(r)
end

-- Localized, user-facing recipe name (mirror Auto-Learn path)
-- ⚠️ Empirical — Build 42.x. Requires in-game SP testing.
local function _displayName(r)
    -- 1) Preferred: game-provided translation
    local okT, tr = pcall(function() return r.getTranslationName and r:getTranslationName() or nil end)
    if okT and tr and tr ~= "" then return tostring(tr) end

    -- 2) Fallbacks from the object
    local okN, nm = pcall(function()
        return (r.getName and r:getName())
            or (r.getOriginalName and r:getOriginalName())
    end)
    if okN and nm and nm ~= "" then return tostring(nm) end

    -- 3) Last resort: stable technical id
    return _stableRecipeId(r)
end

local function _push(tbl, perk, level, entry)
    tbl[perk] = tbl[perk] or {}
    tbl[perk][level] = tbl[perk][level] or {}
    -- dedupe by id
    local seen = tbl[perk][level]._seen or {}
    if not seen[entry.id] then
        table.insert(tbl[perk][level], entry)
        seen[entry.id] = true
        tbl[perk][level]._seen = seen
    end
end

-- Co-requirements pretty string (for UI usage if needed)
local function _coReqString(coReq)
    if not coReq or #coReq == 0 then return nil end
    local parts = {}
    for i=1,#coReq do
        local c = coReq[i]
        parts[#parts+1] = string.format("%s %d", tostring(c.perk), tonumber(c.level or 0))
    end
    return table.concat(parts, ", ")
end

-- Sorting: displayName ASC, then id
local function _sortList(arr)
    table.sort(arr, function(a,b)
        if a.name == b.name then return a.id < b.id end
        return a.name < b.name
    end)
end

----------------------------------------------------------------
-- Utility: gather all unlock entries from level 1..lvlMax for a perk
-- Dedupes by .id and returns a sorted flat array like the per-level buckets.
-- Used for "cumulative" mode in character creation.
--
-- @param byPerkTbl table  -- e.g. Unlocks.Cache.CraftUnlocksByPerk[perk]
-- @param lvlMax    int    -- highest level to include (1..10)
-- @return table|nil       -- flat array of {id=..., name=..., ...} or nil if empty
----------------------------------------------------------------
local function _gatherLevelsRange(byPerkTbl, lvlMax)
    if not byPerkTbl or not lvlMax or lvlMax < 1 then return nil end
    local out = {}
    local seen = {}
    for L = 1, math.min(lvlMax, 10) do
        local bucket = byPerkTbl[L]
        if bucket and #bucket > 0 then
            for i = 1, #bucket do
                local e = bucket[i]
                if e and e.id and (not seen[e.id]) then
                    out[#out+1] = e
                    seen[e.id] = true
                end
            end
        end
    end
    if #out == 0 then return nil end
    _sortList(out)
    return out
end

----------------------------------------------------------------
-- Utility: format a flat unlock array into wrapped lines
-- Respects opts.namesPerLine, opts.maxLines, opts.includeCoreq.
-- Appends "+X more" if truncated.
--
-- @param arr  table       -- array of recipe/buildable entries
-- @param opts table       -- { namesPerLine, maxLines, includeCoreq }
-- @return lines table     -- array of strings
----------------------------------------------------------------
local function _formatBucketToLines(arr, opts)
    local npl = tonumber(opts.namesPerLine or 3) or 3
    -- note: different callsites use either nil or 0 to mean "no cap".
    local rawMax = opts.maxLines
    local max = tonumber(rawMax or 0) or 0  -- 0 => unlimited rows

    local includeCoreq = opts.includeCoreq == true

    local lines, chunk, shown = {}, {}, 0

    local function flush()
        if #chunk > 0 then
            lines[#lines+1] = table.concat(chunk, "    ")
            chunk = {}
        end
    end

    for i = 1, #arr do
        local e = arr[i]
        local label = e.name
        if includeCoreq and e.reqCount and e.reqCount > 1 then
            local coreq = _coReqString(e.coReq)
            if coreq and coreq ~= "" then
                label = string.format("%s  (%s)", label, coreq)
            end
        end

        chunk[#chunk+1] = label
        shown = shown + 1

        if #chunk >= npl then
            flush()
            if max > 0 and #lines >= max then
                break
            end
        end
    end

    if #chunk > 0 and (max == 0 or #lines < max) then
        flush()
    end

    local remaining = #arr - shown
    if remaining > 0 then
        lines[#lines+1] = ST.getText("IGUI_DST_Recipe_more", remaining)
    end

    return lines
end

-- Collect all RequiredSkill pairs on a recipe for display alongside the “key” perk.
-- Returns: array of { perk = <resolved perk key>, level = <int> }
local function _collectCoReq(r)
    local out = {}
    local okCnt, cnt = pcall(function() return r.getRequiredSkillCount and r:getRequiredSkillCount() or 0 end)
    if not okCnt or not cnt or cnt <= 0 then return out end

    for j = 0, cnt - 1 do
        local okRS, rs = pcall(function() return r.getRequiredSkill and r:getRequiredSkill(j) or nil end)
        if okRS and rs then
            local okPerk, perkObj = pcall(function() return rs.getPerk and rs:getPerk() or nil end)
            local okLvl,  lvl     = pcall(function() return rs.getLevel and rs:getLevel() or nil end)
            if okPerk and perkObj and okLvl and lvl then
                local pName = ST.resolveSkillKey(perkObj) or tostring(perkObj)
                out[#out+1] = { perk = pName, level = tonumber(lvl) or 0 }
            end
        end
    end
    return out
end

----------------------------------------------------------------
-- == Recipe Auto-Learn Cache & Inventive Helpers (Build 42.x) ==
-- Purpose: Build a one-time cache of auto-learn recipes by Perk+Level,
--          and provide fast, Inventive-aware lookups for tooltips.
----------------------------------------------------------------

-- Perk→Level index of auto-learn recipes:
--   Unlocks.Cache.AutoLearnByPerk[perkName][level] = { {id=origName, name=displayName}, ... }
Unlocks.Cache.AutoLearnByPerk = Unlocks.Cache.AutoLearnByPerk or {}

-- Resolve local player (SP-safe)
local function _getLocalPlayer()
    if getSpecificPlayer then return getSpecificPlayer(0) end
    return nil
end

---------------------------------------------------------------------
-- DST.hasTraitInventive
--
-- Purpose:
--   Return true if the "Inventive" trait is present.
--   Works both:
--     - In-game (IsoPlayer exists)
--     - During character creation (no player yet)
--
-- Inputs:
--   player (IsoPlayer|nil)  -- optional explicit player
--
-- Outputs:
--   boolean
--
-- Notes:
--   Safe Alternative (Build 42.x): reads CharacterCreationProfession.instance
--   which is set in CharacterCreationProfession:create() and is also used
--   by vanilla when finalizing traits. This should be stable in SP.
---------------------------------------------------------------------
function DST.hasTraitInventive(player)
    -- 1) Live player path (in-world)
    local p = player or _getLocalPlayer()
    if p then
        -- B42.13+ (registry traits): IsoGameCharacter.hasTrait(CharacterTrait)
        if p.hasTrait and CharacterTrait and CharacterTrait.INVENTIVE then
            if p:hasTrait(CharacterTrait.INVENTIVE) then
                return true
            end
        end

        -- B42.12 (string traits): IsoPlayer.HasTrait(String)
        if p.HasTrait and p:HasTrait("Inventive") then
            return true
        end
    end

    -- 2) Character Creation path (no in-world player yet)
    local ccp = CharacterCreationProfession and CharacterCreationProfession.instance
    if not ccp then return false end

    local lb = ccp.listboxTraitSelected
    if not (lb and lb.items) then return false end

    for _, row in pairs(lb.items) do
        local it = row and row.item
        if it then
            if it.getType then
                local t = it:getType()

                -- 42.13+ (t is a CharacterTrait object)
                if CharacterTrait and CharacterTrait.INVENTIVE and t == CharacterTrait.INVENTIVE then
                    return true
                end

                -- 42.12 (t is a string id)
                if t == "Inventive" or t == "base:Inventive" then
                    return true
                end
            end
        end
    end

    return false
end

-- Internal: dedupe insert into cache bucket
local function _cacheInsert(perkName, level, rid, rname)
    if not perkName or not level or level < 1 then return end
    local byPerk = Unlocks.Cache.AutoLearnByPerk
    byPerk[perkName] = byPerk[perkName] or {}
    byPerk[perkName][level] = byPerk[perkName][level] or {}
    local bucket = byPerk[perkName][level]
    for i = 1, #bucket do
        if bucket[i].id == rid then return end
    end
    bucket[#bucket+1] = { id = rid, name = rname }
end

--- Build the auto learn cache once for Build 42 craft recipes
-- Sources per 42.11 JavaDocs:
--   getAutoLearnAnySkills() -> ArrayList<String>  e.g. "Maintenance:2"
--   getAutoLearnAllSkills() -> ArrayList<String>
--   getAutoLearnAnySkillCount() / getAutoLearnAnySkill(i) -> RequiredSkill
--   getAutoLearnAllSkillCount() / getAutoLearnAllSkill(i) -> RequiredSkill
-- Each RequiredSkill has getPerk() and getLevel(). Perk exposes getId().
-- Returns: integer total inserted entries across perks and levels.
function Unlocks.buildAutoLearnCache()
    Unlocks.Cache.AutoLearnByPerk = {}

    ----------------------------------------------------------------
    -- Helpers
    ----------------------------------------------------------------
    local function _sz(x) return (x and x.size and x:size()) or (type(x) == "table" and #x) or 0 end

    local function _rid(r, i)
        local okMod, modID = pcall(function() return r.getModID and r:getModID() or nil end)
        local okNm, name   = pcall(function() return r.getName and r:getName() or nil end)
        local id = (okNm and name and name ~= "" and name) or ("craft_" .. tostring(i or "?"))
        if okMod and modID and modID ~= "" then
            return tostring(modID) .. ":" .. tostring(id) -- stable across mods
        end
        return id
    end

    local function _rname(r, fallback)
        local okT, tr = pcall(function() return r.getTranslationName and r:getTranslationName() or nil end)
        if okT and tr and tr ~= "" then return tr end
        local okN, nm = pcall(function() return r.getName and r:getName() or nil end)
        if okN and nm and nm ~= "" then return nm end
        return fallback
    end

    local function _perkKeyFromPerkObj(perkObj)
        if not perkObj then return nil end
        local okID, pid = pcall(function() return perkObj.getId and perkObj:getId() or nil end)
        if okID and pid and pid ~= "" then return tostring(pid) end
        return tostring(perkObj)
    end

    local function _insert(perk, level, rid, rname, mode)
        if not perk or not level or level < 1 then return false end
        -- Normalize perk key to our definition key (shared resolver)
        local perkKey = tostring(perk)
        if DST and DST.SkillTooltips and DST.SkillTooltips.resolveSkillKey then
            perkKey = DST.SkillTooltips.resolveSkillKey(perkKey) or perkKey
        end
        Unlocks.Cache.AutoLearnByPerk[perkKey] = Unlocks.Cache.AutoLearnByPerk[perkKey] or {}
        Unlocks.Cache.AutoLearnByPerk[perkKey][level] = Unlocks.Cache.AutoLearnByPerk[perkKey][level] or {}
        local bucket = Unlocks.Cache.AutoLearnByPerk[perkKey][level]
        for i = 1, #bucket do
            if bucket[i].id == rid then return false end
        end
        bucket[#bucket + 1] = { id = rid, name = rname, mode = mode } -- mode = "any" or "all"
        return true
    end

    ----------------------------------------------------------------
    -- Parse autolearn text into {perk, level} pairs.
    -- Supports:
    --   • "Perk:Level" / "Perk=Level"   (vanilla strings)
    --   • "Perk Level"                  (wiki-style / some modded)
    -- Filters to known skill names via the shared resolver + fallback map.
    ----------------------------------------------------------------
    local _SKILL_SET -- lazy-built from ST.PERK_FALLBACKS values/keys
    local function _isSkillName(name)
        if type(name) ~= "string" or name == "" then return false end
        if not _SKILL_SET then
            _SKILL_SET = {}
            local F = (DST and DST.SkillTooltips and DST.SkillTooltips.PERK_FALLBACKS) or {}
            for k, v in pairs(F) do
                if type(k) == "string" and k ~= "" then _SKILL_SET[k] = true end
                if type(v) == "string" and v ~= "" then _SKILL_SET[v] = true end
            end
        end
        return _SKILL_SET[name] == true
    end

    local function _resolve(perk)
        local key = tostring(perk or "")
        if DST and DST.SkillTooltips and DST.SkillTooltips.resolveSkillKey then
            key = DST.SkillTooltips.resolveSkillKey(key) or key
        end
        return key
    end

    -- NOTE: pass 'out' explicitly so we don't close over a nil upvalue.
    local function _addPair(out, perk, lvl, loose)
        if type(out) ~= "table" then return end
        local n = tonumber(lvl)
        if not n or n < 1 or n > 10 then return end
        local key = _resolve(perk)
        -- When parsing the looser "Perk Level" pattern, guard with a whitelist.
        if (not loose) or _isSkillName(key) then
            out[#out + 1] = { perk = key, level = n }
        end
    end

    local function _parsePairsFromStringChunk(s, out)
        if type(s) ~= "string" or type(out) ~= "table" then return end

        -- 1) Strict separators first: "Perk:Level" / "Perk=Level"
        for perk, lvl in s:gmatch("([%w_]+)%s*[:=]%s*(%d+)") do
            _addPair(out, perk, lvl, false)
        end

        -- 2) Looser wiki-style: "Perk Level"
        --    Only accept tokens that resolve to known skill names.
        for perk, lvl in s:gmatch("([%a][%w_]*)%s+(%d+)") do
            _addPair(out, perk, lvl, true)
        end
    end

    -- Collect ArrayList<String> or Lua table of strings
    local function _collectPairsFromList(listLike, out)
        if not listLike then return end
        if listLike.get and listLike.size then
            for i = 0, listLike:size() - 1 do
                local ok, v = pcall(function() return listLike:get(i) end)
                if ok and v then _parsePairsFromStringChunk(v, out) end
            end
        elseif type(listLike) == "table" then
            for i = 1, #listLike do
                _parsePairsFromStringChunk(listLike[i], out)
            end
        end
    end

    -- Collect pairs from RequiredSkill via count + index
    local function _collectPairsFromIndexed(r, countName, itemName, out)
        local fCnt = r[countName]
        local fGet = r[itemName]
        if type(fCnt) ~= "function" or type(fGet) ~= "function" then return end
        local okC, n = pcall(fCnt, r)
        if not okC or type(n) ~= "number" or n <= 0 then return end
        for i = 0, n - 1 do
            local okI, rs = pcall(fGet, r, i)
            if okI and rs then
                local okP, perkObj = pcall(function() return rs.getPerk and rs:getPerk() or nil end)
                local okL, lvl     = pcall(function() return rs.getLevel and rs:getLevel() or nil end)
                local pkey = okP and _perkKeyFromPerkObj(perkObj) or nil
                if pkey and okL and lvl and lvl >= 1 then
                    out[#out + 1] = { perk = pkey, level = tonumber(lvl) or 0 }
                end
            end
        end
    end

    -- Unified collector for Any/All -> returns an array of { perk, level }, plus mode
    local function _collectAutoPairs(r)
        local anyPairs, allPairs = {}, {}

        -- List getters: ArrayList<String>
        local gaAny = r.getAutoLearnAnySkills
        if type(gaAny) == "function" then
            local okL, L = pcall(gaAny, r)
            if okL then _collectPairsFromList(L, anyPairs) end
        end
        local gaAll = r.getAutoLearnAllSkills
        if type(gaAll) == "function" then
            local okL, L = pcall(gaAll, r)
            if okL then _collectPairsFromList(L, allPairs) end
        end

        -- Count + index: RequiredSkill
        _collectPairsFromIndexed(r, "getAutoLearnAnySkillCount", "getAutoLearnAnySkill", anyPairs)
        _collectPairsFromIndexed(r, "getAutoLearnAllSkillCount", "getAutoLearnAllSkill", allPairs)

        -- Optional string helpers if present
        local anyStr = r.getAutoLearnAnyString and r:getAutoLearnAnyString() or nil
        if anyStr then _parsePairsFromStringChunk(anyStr, anyPairs) end
        local allStr = r.getAutoLearnAllString and r:getAutoLearnAllString() or nil
        if allStr then _parsePairsFromStringChunk(allStr, allPairs) end

        -- Fallback: scan toString once if nothing else found
        if #anyPairs == 0 and #allPairs == 0 and r.toString then
            local okS, s = pcall(function() return r:toString() end)
            if okS and type(s) == "string" then
                for line in s:gmatch("[^\r\n]+") do
                    local L = line:lower()
                    if L:find("autolearnany") then _parsePairsFromStringChunk(line, anyPairs) end
                    if L:find("autolearnall") then _parsePairsFromStringChunk(line, allPairs) end
                end
            end
        end

        return anyPairs, allPairs
    end

    ----------------------------------------------------------------
    -- Fetch craft list
    ----------------------------------------------------------------
    local sm = (getScriptManager and getScriptManager()) or (ScriptManager and ScriptManager.instance) or nil
    if not sm or not sm.getAllCraftRecipes then
        print("[DST] craft provider missing")
        Unlocks.Cache._AutoLearnBuilt = false
        return 0
    end

    print("[DST] probe sm getAllCraftRecipes")
    local ok, list = pcall(function() return sm:getAllCraftRecipes() end)
    if not ok or not list then
        print("[DST] craft list fetch failed")
        Unlocks.Cache._AutoLearnBuilt = false
        return 0
    end

    local totalCraft = _sz(list)
    print("[DST] craft list size " .. tostring(totalCraft))
    if totalCraft <= 0 then
        print("[DST] craft list empty waiting")
        Unlocks.Cache._AutoLearnBuilt = false
        return 0
    end

    ----------------------------------------------------------------
    -- Build index
    ----------------------------------------------------------------
    local totalInserted, autoPairsFound = 0, 0

    if list.get and list.size then
        for i = 0, list:size() - 1 do
            local r = list:get(i)
            if r then
                local anyPairs, allPairs = _collectAutoPairs(r)
                local pairsCount = (#anyPairs) + (#allPairs)
                if pairsCount > 0 then
                    autoPairsFound = autoPairsFound + pairsCount
                    local rid   = _rid(r, i)
                    local rname = _rname(r, rid)
                    for _, pr in ipairs(anyPairs) do
                        if _insert(pr.perk, tonumber(pr.level) or 0, rid, rname, "any") then
                            totalInserted = totalInserted + 1
                        end
                    end
                    for _, pr in ipairs(allPairs) do
                        if _insert(pr.perk, tonumber(pr.level) or 0, rid, rname, "all") then
                            totalInserted = totalInserted + 1
                        end
                    end
                end
            end
        end
    else
        -- Lua array fallback
        for i = 1, #list do
            local r = list[i]
            if r then
                local anyPairs, allPairs = _collectAutoPairs(r)
                local pairsCount = (#anyPairs) + (#allPairs)
                if pairsCount > 0 then
                    autoPairsFound = autoPairsFound + pairsCount
                    local rid   = _rid(r, i)
                    local rname = _rname(r, rid)
                    for _, pr in ipairs(anyPairs) do
                        if _insert(pr.perk, tonumber(pr.level) or 0, rid, rname, "any") then
                            totalInserted = totalInserted + 1
                        end
                    end
                    for _, pr in ipairs(allPairs) do
                        if _insert(pr.perk, tonumber(pr.level) or 0, rid, rname, "all") then
                            totalInserted = totalInserted + 1
                        end
                    end
                end
            end
        end
    end

    print("[DST] craft autolearn pairs " .. tostring(autoPairsFound))
    print("[DST] cache inserted entries " .. tostring(totalInserted))

    local built = (totalInserted > 0)
    Unlocks.Cache._AutoLearnBuilt = built
    return totalInserted
end

--- Get auto-learned recipes for a given perk+level, Inventive-aware.
-- Applies the Inventive display rule: look up base thresholds at L+1 for a player at level L.
-- If opts.cumulative == true, merges all unlocks from 1..effectiveLevel instead of only that exact level.
--
-- Compacts into ≤maxLines, with ≤namesPerLine per line; adds "+X more" if truncated.
--
-- @param perkName   string                e.g., "Maintenance"
-- @param level      integer               current skill level 1..10 (pre-shift)
-- @param player     IsoPlayer|nil         nil → resolve local player
-- @param opts       table|nil             {
--                                           maxLines=3,
--                                           namesPerLine=3,
--                                           cumulative=false|true
--                                         }
-- @return headerKey string|nil            IGUI_DST_hdr_RecipesAuto(…Inventive)
-- @return lines     table|nil             array of display strings or nil if none
function Unlocks.getAutoLearnedFor(perkName, level, player, opts)
    opts = opts or {}
    if type(perkName) ~= "string" or type(level) ~= "number" then return nil, nil end

    -- Lazy build: if cache wasn't ready at bootstrap, try once now
    if not (Unlocks.Cache and Unlocks.Cache._AutoLearnBuilt)
        and type(Unlocks.buildAutoLearnCache) == "function"
    then
        local c = Unlocks.buildAutoLearnCache()
        print(string.format("[DST] (lazy) Auto-learn recipe cache built %d entries", tonumber(c) or 0))
        Unlocks.Cache._AutoLearnBuilt = true
    end

    -- Normalize perk key once
    local normKey
    if type(clean) == "function" then
        normKey = clean(perkName)
    elseif DST and DST.SkillTooltips and DST.SkillTooltips.resolveSkillKey then
        normKey = DST.SkillTooltips.resolveSkillKey(perkName)
    end
    if not normKey or normKey == "" then
        normKey = (type(perkName) == "string" and perkName:gsub("^%l", string.upper))
                   or tostring(perkName or "")
    end

    local byPerk = Unlocks.Cache.AutoLearnByPerk and Unlocks.Cache.AutoLearnByPerk[normKey]
    if not byPerk then return nil, nil end

    local hasInv = DST.hasTraitInventive(player)

    -- Inventive effect for display:
    -- When Inventive is present, a base unlock at (L+1) is effectively granted at L.
    -- So we show bucket[ L+1 ] when hovering L.
    local effectiveLevel = level
    if hasInv then
        effectiveLevel = level + 1
    end

    -- Build the working bucket depending on cumulative mode.
    local bucket
    if opts.cumulative then
        -- cumulative mode: merge 1..effectiveLevel
        bucket = _gatherLevelsRange(byPerk, effectiveLevel)
    else
        -- per-level mode (current in-world behaviour)
        bucket = byPerk[effectiveLevel]
        if bucket and #bucket > 0 then
            -- make a shallow copy so sorting doesn't mutate cache order
            local copy = {}
            for i = 1, #bucket do copy[i] = bucket[i] end
            bucket = copy
        end
    end

    if not bucket or #bucket == 0 then return nil, nil end

    -- Sort the bucket by name/id (same logic as before)
    table.sort(bucket, function(a, b)
        local an = a.name or ""
        local bn = b.name or ""
        if an == bn then return (a.id or "") < (b.id or "") end
        return an < bn
    end)

    -- Wrap into lines, respecting maxLines/namesPerLine
    local maxLines     = (opts and type(opts.maxLines) == "number" and opts.maxLines > 0) and opts.maxLines or nil
    local namesPerLine = (opts and opts.namesPerLine) or 3

    local lines, idx, total, shown = {}, 1, #bucket, 0
    local function addLine()
        local parts = {}
        for j = 1, namesPerLine do
            local entry = bucket[idx]; if not entry then break end
            parts[#parts+1] = tostring(entry.name or entry.id)
            idx, shown = idx + 1, shown + 1
        end
        if #parts > 0 then lines[#lines+1] = table.concat(parts, ", ") end
    end

    if maxLines then
        for ln = 1, maxLines do
            if idx > total then break end
            addLine()
        end
        if shown < total then
            lines[#lines+1] = ST.getText("IGUI_DST_Recipe_more", total - shown)
        end
    else
        while idx <= total do
            addLine()
        end
    end

    local headerKey = hasInv and "IGUI_DST_hdr_RecipesAutoInventive"
                              or  "IGUI_DST_hdr_RecipesAuto"

    -- Optional future tweak:
    -- if opts.cumulative then headerKey = headerKey .. "_Cumulative" end
    -- For now we keep same header to avoid translation churn.

    return headerKey, lines
end

-- Debug helper list first N craft recipes that have any required skills
function DST_DebugDumpCraftReqs(n)
    local sm = (getScriptManager and getScriptManager()) or (ScriptManager and ScriptManager.instance) or nil
    if not sm or not sm.getAllCraftRecipes then
        print("[DST] req dump sm or method missing")
        return
    end
    local ok, list = pcall(function() return sm:getAllCraftRecipes() end)
    if not ok or not list then
        print("[DST] req dump list fetch failed")
        return
    end
    local size = (list.size and list:size()) or 0
    local shown = 0
    for i = 0, size - 1 do
        local r = list:get(i)
        local okc, cnt = pcall(function() return r.getRequiredSkillCount and r:getRequiredSkillCount() or 0 end)
        if okc and cnt and cnt > 0 then
            local name = ""
            local okn, nm = pcall(function() return r.getName and r:getName() or nil end)
            name = (okn and nm) and nm or ("craft_" .. tostring(i))
            print("[DST] req recipe " .. tostring(name) .. " count " .. tostring(cnt))
            for k = 0, cnt - 1 do
                local okPL, pl = pcall(function() return r:getRequiredSkill(k) end)
                if okPL and pl then
                    local okP, perk = pcall(function() return pl.getPerk and pl:getPerk() or nil end)
                    local okL, lvl  = pcall(function() return pl.getLevel and pl:getLevel() or nil end)
                    print("[DST]   perk " .. tostring(okP and tostring(perk) or "nil") .. " level " .. tostring(okL and lvl or "nil"))
                end
            end
            shown = shown + 1
            if shown >= (n or 8) then break end
        end
    end
end

--- Build base mapping for all craft recipes (RequiredSkill gates)
local function _buildCraftReqBase()
    if Unlocks.Cache._CraftReqBuilt then return end

    -- Ensure cache table exists before we start writing into it
    Unlocks.Cache.CraftUnlocksByPerk = Unlocks.Cache.CraftUnlocksByPerk or {}

    local okSM, SM = pcall(function() return getScriptManager and getScriptManager() or ScriptManager.instance end)
    if not okSM or not SM then return end  -- lazy-try again next call

    local okList, all = pcall(function() return SM.getAllCraftRecipes and SM:getAllCraftRecipes() or nil end)
    if not okList or not all then return end

    -- All writes go to this table
    local byPerk = Unlocks.Cache.CraftUnlocksByPerk

    local size = (all and all.size) and all:size() or 0
    for i=0,(size-1) do
        local r = all:get(i)
        if r then
            -- collect RequiredSkill vector
            local reqs = {}
            local okCnt, cnt = pcall(function() return r.getRequiredSkillCount and r:getRequiredSkillCount() or 0 end)
            if okCnt and cnt and cnt > 0 then
                for j=0,(cnt-1) do
                    local okRS, rs = pcall(function() return r.getRequiredSkill and r:getRequiredSkill(j) or nil end)
                    if okRS and rs then
                        local okPerk, perk = pcall(function() return rs.getPerk and rs:getPerk() or nil end)
                        local okLvl,  lvl  = pcall(function() return rs.getLevel and rs:getLevel() or nil end)
                        if okPerk and perk and okLvl and lvl then
                            local pName = ST.resolveSkillKey(perk) or tostring(perk)
                            reqs[#reqs+1] = { perk=pName, level=tonumber(lvl) or 0 }
                        end
                    end
                end
            end

            if #reqs > 0 then
                local id   = _stableRecipeId(r)
                local name = _displayName(r)
                if id and name then
                    -- For each exact RequiredSkill gate, add this recipe under that (perk,level)
                    for _, rs in ipairs(reqs) do
                        if rs.perk and rs.perk ~= "" and rs.level and rs.level >= 1 and rs.level <= 10 then
                            _push(byPerk, rs.perk, rs.level, {
                                id = id, name = name, reqCount = #reqs, isBuildable = false
                            })
                        end
                    end
                end
            end
        end
    end

    -- sort each bucket
    for perk, byLevel in pairs(byPerk) do
        for lvl, arr in pairs(byLevel) do
            if type(arr) == "table" then _sortList(arr) end
        end
    end

    Unlocks.Cache._CraftReqBuilt = true
end

-- Returns header key and formatted lines for Craftable unlocks.
-- Inputs:
--   perkName (string), level (1..10)
--   opts = {
--     namesPerLine = 3,      -- how many names per row
--     maxLines     = nil|n,  -- nil/0 => unlimited
--     includeCoreq = true|false,
--     raw          = false,
--     cumulative   = false|true  -- if true: merge levels [1..level]
--   }
function Unlocks.getCraftUnlocksBase(perkName, level, opts)
    opts = opts or {}
    local perk = ST.resolveSkillKey(perkName) or tostring(perkName or "")
    local lvl  = tonumber(level or 0) or 0
    if lvl < 1 or lvl > 10 or perk == "" then return nil end

    if not Unlocks.Cache._CraftReqBuilt then
        DebugLog.log(DebugType.General, "[DST] CraftReq: building")
    end
    if not Unlocks.Cache._CraftReqBuilt then _buildCraftReqBase() end

    Unlocks.Cache.CraftUnlocksByPerk = Unlocks.Cache.CraftUnlocksByPerk or {}
    local byPerk = Unlocks.Cache.CraftUnlocksByPerk[perk]
    if not byPerk then return nil end

    local arr
    if opts.cumulative then
        -- cumulative: gather from 1..lvl
        arr = _gatherLevelsRange(byPerk, lvl)
    else
        arr = byPerk and byPerk[lvl]
        -- shallow copy for safety if we might mutate/sort
        if arr and #arr > 0 then
            local copy = {}
            for i = 1, #arr do copy[i] = arr[i] end
            arr = copy
        end
    end

    if not arr or #arr == 0 then return nil end
    if opts.raw then return arr end

    -- ensure sorted (cumulative path already sorts, single-level path copied but may already be sorted)
    _sortList(arr)

    local lines = _formatBucketToLines(arr, opts)

    return "IGUI_DST_hdr_CraftReqBase", lines
end

--- Build base mapping for all buildable recipes (RequiredSkill gates)
local function _buildBuildableReqBase()
    if Unlocks.Cache._BuildReqBuilt then return end

    -- Ensure cache table exists before we start writing into it
    Unlocks.Cache.BuildableUnlocksByPerk = Unlocks.Cache.BuildableUnlocksByPerk or {}

    local okSM, SM = pcall(function() return getScriptManager and getScriptManager() or ScriptManager.instance end)
    if not okSM or not SM then return end

    local okList, all = pcall(function() return SM.getAllBuildableRecipes and SM:getAllBuildableRecipes() or nil end)
    if not okList or not all then return end

    -- All writes go to this table
    local byPerk = Unlocks.Cache.BuildableUnlocksByPerk

    local size = (all and all.size) and all:size() or 0
    for i=0,(size-1) do
        local r = all:get(i)
        if r then
            -- collect RequiredSkill vector
            local reqs = {}
            local okCnt, cnt = pcall(function() return r.getRequiredSkillCount and r:getRequiredSkillCount() or 0 end)
            if okCnt and cnt and cnt > 0 then
                for j=0,(cnt-1) do
                    local okRS, rs = pcall(function() return r.getRequiredSkill and r:getRequiredSkill(j) or nil end)
                    if okRS and rs then
                        local okPerk, perk = pcall(function() return rs.getPerk and rs:getPerk() or nil end)
                        local okLvl,  lvl  = pcall(function() return rs.getLevel and rs:getLevel() or nil end)
                        if okPerk and perk and okLvl and lvl then
                            local pName = ST.resolveSkillKey(perk) or tostring(perk)
                            reqs[#reqs+1] = { perk=pName, level=tonumber(lvl) or 0 }
                        end
                    end
                end
            end

            if #reqs > 0 then
                local id   = _stableRecipeId(r)
                local name = _displayName(r)
                if id and name then
                    for _, rs in ipairs(reqs) do
                        if rs.perk and rs.perk ~= "" and rs.level and rs.level >= 1 and rs.level <= 10 then
                            _push(byPerk, rs.perk, rs.level, {
                                id = id, name = name, reqCount = #reqs, coReq = _collectCoReq(r), isBuildable = true
                            })
                        end
                    end
                end
            end
        end
    end

    -- sort each bucket
    for perk, byLevel in pairs(byPerk) do
        for lvl, arr in pairs(byLevel) do
            if type(arr) == "table" then _sortList(arr) end
        end
    end

    Unlocks.Cache._BuildReqBuilt = true
end

-- Returns header key and a list of formatted lines for Buildable unlocks.
-- Inputs:
--   perkName (string), level (1..10)
--   opts = {
--     namesPerLine = 3,      -- how many names per row
--     maxLines     = nil|n,  -- nil/0 => unlimited rows
--     includeCoreq = true|false,
--     raw          = false,  -- if true: returns raw array instead of formatted lines
--     cumulative   = false|true  -- if true: merge levels [1..level]
--   }
function Unlocks.getBuildableUnlocksBase(perkName, level, opts)
    opts = opts or {}
    local perk = ST.resolveSkillKey(perkName) or tostring(perkName or "")
    local lvl  = tonumber(level or 0) or 0
    if lvl < 1 or lvl > 10 or perk == "" then return nil end

    if not Unlocks.Cache._BuildReqBuilt then
        DebugLog.log(DebugType.General, "[DST] BuildReq: building")
    end
    if not Unlocks.Cache._BuildReqBuilt then _buildBuildableReqBase() end

    Unlocks.Cache.BuildableUnlocksByPerk = Unlocks.Cache.BuildableUnlocksByPerk or {}
    local byPerk = Unlocks.Cache.BuildableUnlocksByPerk[perk]
    if not byPerk then return nil end

    local arr
    if opts.cumulative then
        -- cumulative mode: merge 1..lvl
        arr = _gatherLevelsRange(byPerk, lvl)
    else
        arr = byPerk and byPerk[lvl]
        if arr and #arr > 0 then
            local copy = {}
            for i = 1, #arr do copy[i] = arr[i] end
            arr = copy
        end
    end

    if not arr or #arr == 0 then return nil end
    if opts.raw then return arr end

    -- keep sorted order (cumulative path sorted already, copy path may need sort)
    _sortList(arr)

    local lines = _formatBucketToLines(arr, opts)

    return "IGUI_DST_hdr_BuildReqBase", lines
end

DST.Unlocks = Unlocks
return Unlocks
