--[[
  SpellSmasher talent Apply (any level, Blizzard-legal).

  Working path (must always change talents the player can see):
    1) Decode pack CUk > entries
    2) Snapshot active tree
    3) ResetTree + multipass PurchaseRank/SetSelection (YOUR free budget)
    4) Dump leftovers + CommitConfig
    5) Optional: ImportLoadout of the RESULT into a NEW unique name, then LoadConfig

  Never call BuildPointsValidMethodString before Apply - that resets the live tree
  and if ImportLoadout fails the player is left looking at N with an empty tree
  ("opens talents, does nothing").

  Copy still works for manual paste > Import.
]]

local NS = SpellSmasherNS
local TL = {}
NS.TalentLevel = TL

TL.LEVEL_MAX = 90
TL.LEVEL_MIN_APPLY = 10
TL.MAX_PASSES = 24

local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local CHAR_TO_NUM = {}
for i = 1, #ALPHABET do
  CHAR_TO_NUM[ALPHABET:sub(i, i)] = i - 1
end

local function Trim(s)
  return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end

function TL.GetPlayerLevel()
  local lv = tonumber(UnitLevel and UnitLevel("player"))
  return (lv and lv >= 1) and lv or 1
end

function TL.GetConfigID()
  if C_ClassTalents and C_ClassTalents.GetActiveConfigID then
    local id = C_ClassTalents.GetActiveConfigID()
    if id then
      return id
    end
  end
  return nil
end

function TL.GetTraitTreeID(configID)
  if C_ClassTalents and C_ClassTalents.GetTraitTreeForSpec then
    local specID = PlayerUtil and PlayerUtil.GetCurrentSpecID and PlayerUtil.GetCurrentSpecID()
    if not specID and GetSpecialization and GetSpecializationInfo then
      local i = GetSpecialization() or 0
      if i >= 1 then
        specID = GetSpecializationInfo(i)
      end
    end
    if specID then
      local tid = C_ClassTalents.GetTraitTreeForSpec(specID)
      if tid then
        return tid
      end
    end
  end
  if configID and C_Traits and C_Traits.GetConfigInfo then
    local ok, info = pcall(C_Traits.GetConfigInfo, configID)
    if ok and info and info.treeIDs and info.treeIDs[1] then
      return info.treeIDs[1]
    end
  end
  return nil
end

function TL.GetLiveNodeOrder(configID)
  local treeID = TL.GetTraitTreeID(configID)
  if not treeID or not C_Traits or not C_Traits.GetTreeNodes then
    return {}, treeID
  end
  local nodes = C_Traits.GetTreeNodes(treeID)
  return (type(nodes) == "table" and nodes) or {}, treeID
end

local function newStream(exportString)
  local dataValues = {}
  local s = tostring(exportString or "")
  for i = 1, #s do
    local n = CHAR_TO_NUM[s:sub(i, i)]
    if n ~= nil then
      dataValues[#dataValues + 1] = n
    end
  end
  return {
    dataValues = dataValues,
    currentIndex = 1,
    currentExtractedBits = 0,
    currentRemainingValue = dataValues[1] or 0,
  }
end

local function extractValue(stream, bitWidth)
  if bitWidth <= 0 then
    return 0
  end
  if stream.currentIndex > #stream.dataValues and stream.currentExtractedBits == 0 then
    return nil
  end
  local value, bitsNeeded, extractedBits = 0, bitWidth, 0
  while bitsNeeded > 0 do
    if stream.currentIndex > #stream.dataValues and stream.currentExtractedBits == 0 then
      return nil
    end
    local remainingBits = 6 - stream.currentExtractedBits
    local bitsToExtract = math.min(remainingBits, bitsNeeded)
    stream.currentExtractedBits = stream.currentExtractedBits + bitsToExtract
    local maxStorableValue = 2 ^ bitsToExtract
    local remainder = stream.currentRemainingValue % maxStorableValue
    stream.currentRemainingValue = math.floor(stream.currentRemainingValue / maxStorableValue)
    value = value + remainder * (2 ^ extractedBits)
    extractedBits = extractedBits + bitsToExtract
    bitsNeeded = bitsNeeded - bitsToExtract
    if bitsToExtract >= remainingBits then
      stream.currentIndex = stream.currentIndex + 1
      stream.currentExtractedBits = 0
      stream.currentRemainingValue = stream.dataValues[stream.currentIndex] or 0
    end
  end
  return value
end

local function streamBitsLeft(stream)
  return math.max(0, 6 * (#stream.dataValues - stream.currentIndex + 1) - stream.currentExtractedBits)
end

local function hasUnspentPoints()
  if C_ClassTalents and C_ClassTalents.HasUnspentTalentPoints then
    local ok, v = pcall(C_ClassTalents.HasUnspentTalentPoints)
    if ok and v then
      return true
    end
  end
  if C_ClassTalents and C_ClassTalents.HasUnspentHeroTalentPoints then
    local ok, v = pcall(C_ClassTalents.HasUnspentHeroTalentPoints)
    if ok and v then
      return true
    end
  end
  return false
end

--- Open Talents (N) on a LATER frame - never sync from Apply click stack.
--- Sync TogglePlayerSpellsFrame after ResetTree/PurchaseRank left SpellSmasher
--- taint on OverlayPlayerCastingBarFrame (GetTypeInfo / secret barType),
--- especially empty tree > starter package restore.
local function openTalentsUI(delaySec)
  delaySec = tonumber(delaySec)
  if delaySec == nil then
    delaySec = 0.2
  end
  local function doOpen()
    pcall(function()
      if TogglePlayerSpellsFrame then
        TogglePlayerSpellsFrame(2)
      elseif ToggleTalentFrame then
        ToggleTalentFrame()
      elseif PlayerSpellsUtil and PlayerSpellsUtil.TogglePlayerSpellsFrame then
        PlayerSpellsUtil.TogglePlayerSpellsFrame(
          PlayerSpellsUtil.FrameTabs and PlayerSpellsUtil.FrameTabs.classTalents
        )
      end
    end)
  end
  if C_Timer and C_Timer.After and delaySec > 0 then
    C_Timer.After(delaySec, doOpen)
  else
    doOpen()
  end
end

local function pauseSsmIconPoll(reason)
  if NS.Secure and NS.Secure.PauseIconPoll then
    pcall(NS.Secure.PauseIconPoll, reason or "talent-apply")
  end
end

local function resumeSsmIconPoll(delaySec)
  delaySec = tonumber(delaySec) or 0.5
  local function doResume()
    if NS.Secure and NS.Secure.ResumeIconPoll then
      pcall(NS.Secure.ResumeIconPoll)
    end
  end
  if C_Timer and C_Timer.After and delaySec > 0 then
    C_Timer.After(delaySec, doResume)
  else
    doResume()
  end
end

local function commitConfig(configID)
  local ok = false
  pcall(function()
    -- Prefer no-arg / nil savedConfigID (commit active staged) - passing configID can fail silently
    if C_ClassTalents and C_ClassTalents.CommitConfig then
      local a = C_ClassTalents.CommitConfig()
      local b = C_ClassTalents.CommitConfig(configID)
      ok = (a or b) and true or false
    elseif C_Traits and C_Traits.CommitConfig then
      ok = C_Traits.CommitConfig(configID) and true or false
    end
  end)
  return ok
end

local function hasStagedChanges(configID)
  if C_Traits and C_Traits.ConfigHasStagedChanges then
    local ok, v = pcall(C_Traits.ConfigHasStagedChanges, configID)
    return ok and v and true or false
  end
  return false
end

--- Blizzard CanChangeTalents: false + "spend all points" is the stuck Apply Changes state.
local function canCommitTalents()
  if not C_ClassTalents or not C_ClassTalents.CanChangeTalents then
    return true, nil
  end
  local ok, canChange, _canAdd, changeError = pcall(C_ClassTalents.CanChangeTalents)
  if not ok then
    return true, nil
  end
  return canChange and true or false, changeError
end

--- Real free points from currency API (headers can show 0 while quantity > 0).
local function currencyFreeTotal(configID, treeID)
  local total = 0
  if not configID or not treeID or not C_Traits or not C_Traits.GetTreeCurrencyInfo then
    return 0
  end
  -- false = include staged changes (what Apply Changes uses)
  local ok, list = pcall(C_Traits.GetTreeCurrencyInfo, configID, treeID, false)
  if ok and type(list) == "table" then
    for _, c in ipairs(list) do
      total = total + (tonumber(c.quantity) or 0)
    end
  end
  return total
end

local function freePointBreakdown()
  local nClass, nSpec, nHero = 0, 0, 0
  if C_ClassTalents and C_ClassTalents.HasUnspentTalentPoints then
    local ok, has, c, s = pcall(C_ClassTalents.HasUnspentTalentPoints)
    if ok then
      nClass = tonumber(c) or 0
      nSpec = tonumber(s) or 0
      if has and nClass == 0 and nSpec == 0 then
        nClass = has and 1 or 0
      end
    end
  end
  if C_ClassTalents and C_ClassTalents.HasUnspentHeroTalentPoints then
    local ok, has, h = pcall(C_ClassTalents.HasUnspentHeroTalentPoints)
    if ok then
      nHero = tonumber(h) or (has and 1 or 0)
    end
  end
  return nClass, nSpec, nHero
end

local function stillHasFreeBudget(configID, treeID)
  if hasUnspentPoints() then
    return true
  end
  if currencyFreeTotal(configID, treeID) > 0 then
    return true
  end
  return false
end

--- CanPurchaseRank requires (configID, nodeID, nodeEntryID) - 2-arg always fails.
local function canPurchase(configID, nodeID, entryID)
  if not C_Traits or not C_Traits.CanPurchaseRank or not entryID then
    return nil -- unknown; caller should try purchase directly
  end
  local ok, v = pcall(C_Traits.CanPurchaseRank, configID, nodeID, entryID)
  if not ok then
    return nil
  end
  return v and true or false
end

--- Detect choice/selection nodes (use SetSelection, never PurchaseRank).
local function isSelectionNode(configID, nodeID, entry)
  local ok, info = pcall(C_Traits.GetNodeInfo, configID, nodeID)
  if ok and type(info) == "table" then
    if info.type ~= nil then
      local t = info.type
      -- Enum.TraitNodeType: Single=0, Tiered=1, Selection=2 (typical)
      if t == 2 or t == "Selection" then
        return true
      end
    end
    if info.entryIDs and #info.entryIDs > 1 then
      return true
    end
    if info.isSelectionNode then
      return true
    end
  end
  if entry and (entry.ranksPurchased or 0) == 0 and (entry.ranksGranted or 0) > 0 then
    return true
  end
  return false
end

local function getNodeEntryID(configID, nodeID, preferred)
  if preferred then
    return preferred
  end
  local ok, info = pcall(C_Traits.GetNodeInfo, configID, nodeID)
  if ok and type(info) == "table" then
    if info.activeEntry and info.activeEntry.entryID then
      return info.activeEntry.entryID
    end
    if info.entryIDs and info.entryIDs[1] then
      return info.entryIDs[1]
    end
  end
  return nil
end

--- Try one rank on a Method entry. Prefer direct Purchase/SetSelection (Blizzard: CanPurchaseRank is optional).
local function tryBuyEntry(configID, entry)
  if not entry or not entry.nodeID then
    return false
  end
  local nodeID = entry.nodeID
  local sel = entry.selectionEntryID or getNodeEntryID(configID, nodeID, nil)

  if isSelectionNode(configID, nodeID, entry) then
    if not sel or not C_Traits.SetSelection then
      return false
    end
    local gate = canPurchase(configID, nodeID, sel)
    if gate == false then
      return false
    end
    local ok, success = pcall(C_Traits.SetSelection, configID, nodeID, sel)
    return ok and success and true or false
  end

  -- Normal / tiered nodes: PurchaseRank only (not SetSelection)
  local gate = canPurchase(configID, nodeID, sel)
  if gate == false then
    return false
  end
  local ok, success = pcall(C_Traits.PurchaseRank, configID, nodeID)
  return ok and success and true or false
end

--- Multipass Method buys (order of decoded entries).
local function buyMethodEntries(configID, entries)
  local bought = 0
  local maxRanks = {}
  for _, e in ipairs(entries) do
    -- Spendable ranks only (granted ranks are free - do not count as purchase targets)
    local want = tonumber(e.ranksPurchased) or 0
    if want < 1 and (tonumber(e.ranksGranted) or 0) > 0 then
      want = 1 -- still need SetSelection for choice/granted
    end
    if want < 1 then
      want = 1
    end
    maxRanks[e.nodeID] = math.max(maxRanks[e.nodeID] or 0, want)
  end
  local got = {}

  for _ = 1, TL.MAX_PASSES do
    local progress = false
    for _, e in ipairs(entries) do
      local nodeID = e.nodeID
      local have = got[nodeID] or 0
      local want = maxRanks[nodeID] or 1
      if have < want then
        if tryBuyEntry(configID, e) then
          got[nodeID] = have + 1
          bought = bought + 1
          progress = true
        end
      end
    end
    if not progress then
      break
    end
  end
  return bought
end

--- Spend remaining free points so Blizzard "Apply Changes" is not stuck (must spend all).
--- Keeps buying while currency quantity > 0 OR HasUnspent* - headers alone lie.
local function dumpLeftoverPoints(configID, treeID)
  local dumped = 0
  local nodes = {}
  if treeID and C_Traits.GetTreeNodes then
    local ok, list = pcall(C_Traits.GetTreeNodes, treeID)
    if ok and type(list) == "table" then
      nodes = list
    end
  end
  for _ = 1, TL.MAX_PASSES do
    if not stillHasFreeBudget(configID, treeID) then
      break
    end
    local progress = false
    for _, nodeID in ipairs(nodes) do
      local okInfo, info = pcall(C_Traits.GetNodeInfo, configID, nodeID)
      if not okInfo or type(info) ~= "table" then
        local ok, success = pcall(C_Traits.PurchaseRank, configID, nodeID)
        if ok and success then
          dumped = dumped + 1
          progress = true
        end
      else
        local entryIDs = info.entryIDs
        local isChoice = (entryIDs and #entryIDs > 1)
          or info.type == 2
          or info.isSelectionNode

        if isChoice and entryIDs and C_Traits.SetSelection then
          for _, eid in ipairs(entryIDs) do
            local ok, success = pcall(C_Traits.SetSelection, configID, nodeID, eid)
            if ok and success then
              dumped = dumped + 1
              progress = true
              break
            end
          end
        else
          local ok, success = false, false
          if C_Traits.PurchaseAllRanks then
            ok, success = pcall(C_Traits.PurchaseAllRanks, configID, nodeID)
          end
          if not (ok and success) then
            ok, success = pcall(C_Traits.PurchaseRank, configID, nodeID)
          end
          if ok and success then
            dumped = dumped + 1
            progress = true
          end
        end
      end
    end
    if not progress then
      break
    end
  end
  return dumped
end

--- Dump free budget then Commit only when CanChangeTalents allows (or no staged left).
local function finalizeTalents(configID, treeID)
  local totalDump = 0
  local committed = false
  for _ = 1, 12 do
    if stillHasFreeBudget(configID, treeID) then
      totalDump = totalDump + dumpLeftoverPoints(configID, treeID)
    end
    local canChange, changeErr = canCommitTalents()
    if canChange then
      committed = commitConfig(configID)
    else
      -- try commit anyway once free budget is 0
      if not stillHasFreeBudget(configID, treeID) then
        committed = commitConfig(configID)
      end
    end
    local staged = hasStagedChanges(configID)
    local free = stillHasFreeBudget(configID, treeID)
    canChange, changeErr = canCommitTalents()
    -- Success: no staged work left
    if not staged and not free then
      return true, totalDump, nil
    end
    -- Success: can commit and commit worked and no free
    if committed and not free and not staged then
      return true, totalDump, nil
    end
    if not free and staged and not canChange then
      -- classic stuck: red "spend all points" with 0 free on headers
      return false, totalDump, changeErr or "must spend all points (stuck staged)"
    end
  end
  local _, err = canCommitTalents()
  return committed and not hasStagedChanges(configID), totalDump, err
end

local function rollbackConfig(configID)
  pcall(function()
    if C_Traits and C_Traits.RollbackConfig then
      C_Traits.RollbackConfig(configID)
    end
  end)
end

--- Blizzard ReadLoadoutContent + ConvertToImportLoadoutEntryInfo (list form).
function TL.DecodeMethodToEntries(importString, configID)
  local code = Trim(importString)
  local m = code:match("([A-Za-z0-9+/]+)")
  if m and #m >= 40 then
    code = m
  end
  if #code < 20 then
    return nil, "Loadout string too short."
  end

  local treeNodes, treeID = TL.GetLiveNodeOrder(configID)
  if not treeID or #treeNodes == 0 then
    return nil, "Could not get talent tree. Open Talents (N) once."
  end

  local stream = newStream(code)
  local ver = extractValue(stream, 8)
  local specID = extractValue(stream, 16)
  if ver == nil or specID == nil then
    return nil, "Bad loadout header."
  end
  for _ = 1, 16 do
    if extractValue(stream, 8) == nil then
      return nil, "Bad loadout tree hash."
    end
  end

  local playerSpec = PlayerUtil and PlayerUtil.GetCurrentSpecID and PlayerUtil.GetCurrentSpecID()
  if not playerSpec and GetSpecialization and GetSpecializationInfo then
    local i = GetSpecialization() or 0
    if i >= 1 then
      playerSpec = GetSpecializationInfo(i)
    end
  end
  if playerSpec and specID ~= playerSpec then
    return nil, "Method package is for a different specialization."
  end

  local entries = {}
  for _, treeNodeID in ipairs(treeNodes) do
    if streamBitsLeft(stream) < 1 then
      break
    end
    local sel = extractValue(stream, 1)
    if sel == nil then
      break
    end
    local isNodeSelected = sel == 1
    local isNodePurchased = false
    local isPartiallyRanked = false
    local partialRanks = 0
    local isChoiceNode = false
    local choiceSel = 0
    if isNodeSelected then
      local pur = extractValue(stream, 1)
      isNodePurchased = pur == 1
      if isNodePurchased then
        local part = extractValue(stream, 1)
        isPartiallyRanked = part == 1
        if isPartiallyRanked then
          partialRanks = extractValue(stream, 6) or 0
        end
        local ch = extractValue(stream, 1)
        isChoiceNode = ch == 1
        if isChoiceNode then
          choiceSel = extractValue(stream, 2) or 0
        end
      end
    end

    if isNodeSelected then
      local ok, treeNodeInfo = pcall(C_Traits.GetNodeInfo, configID, treeNodeID)
      if ok and type(treeNodeInfo) == "table" then
        local ranksGranted = (isNodeSelected and not isNodePurchased) and 1 or 0
        local ranksPurchased = 0
        if isNodePurchased then
          ranksPurchased = isPartiallyRanked and partialRanks or (tonumber(treeNodeInfo.maxRanks) or 1)
        end
        local selectionEntryID = nil
        if isChoiceNode and treeNodeInfo.entryIDs then
          selectionEntryID = treeNodeInfo.entryIDs[choiceSel + 1]
        elseif treeNodeInfo.activeEntry and treeNodeInfo.activeEntry.entryID then
          selectionEntryID = treeNodeInfo.activeEntry.entryID
        elseif treeNodeInfo.entryIDs then
          selectionEntryID = treeNodeInfo.entryIDs[1]
        end
        if not selectionEntryID and treeNodeInfo.entryIDs then
          selectionEntryID = treeNodeInfo.entryIDs[1]
        end
        if selectionEntryID and (ranksPurchased > 0 or ranksGranted > 0) then
          entries[#entries + 1] = {
            nodeID = treeNodeID,
            ranksPurchased = ranksPurchased,
            ranksGranted = ranksGranted,
            selectionEntryID = selectionEntryID,
          }
        end
      end
    end
  end

  if #entries == 0 then
    return nil, "No talent entries decoded from Method package."
  end
  return entries, nil, { treeID = treeID, count = #entries }
end

--- Collect existing loadout names for this spec (lowercase > true).
function TL.ExistingLoadoutNames()
  local used = {}
  pcall(function()
    if not C_ClassTalents or not C_ClassTalents.GetConfigIDsBySpecID then
      return
    end
    local specID = nil
    if PlayerUtil and PlayerUtil.GetCurrentSpecID then
      specID = PlayerUtil.GetCurrentSpecID()
    end
    if not specID and GetSpecialization and GetSpecializationInfo then
      local i = GetSpecialization() or 0
      if i >= 1 then
        specID = select(1, GetSpecializationInfo(i))
      end
    end
    local ids = C_ClassTalents.GetConfigIDsBySpecID(specID)
    if type(ids) ~= "table" then
      return
    end
    for _, cid in ipairs(ids) do
      local info = C_Traits and C_Traits.GetConfigInfo and C_Traits.GetConfigInfo(cid)
      if type(info) == "table" and info.name and info.name ~= "" then
        used[string.lower(tostring(info.name))] = true
      end
    end
  end)
  return used
end

--- Unique Blizzard loadout name: "SSM Pack", "SSM Pack 2", ...
function TL.NextUniqueLoadoutName(base)
  if NS.SanitizeLoadoutName then
    base = NS.SanitizeLoadoutName(base or "Pack")
  else
    base = Trim(base or "Pack")
    if base == "" then
      base = "SSM Pack"
    end
  end
  local used = TL.ExistingLoadoutNames()
  if not used[string.lower(base)] then
    return base
  end
  for n = 2, 99 do
    local suffix = " " .. tostring(n)
    local maxBase = 28 - #suffix
    if maxBase < 4 then
      maxBase = 4
    end
    local candidate = base:sub(1, maxBase) .. suffix
    if not used[string.lower(candidate)] then
      return candidate
    end
  end
  return "SSM " .. tostring((time and time() or 0) % 100000)
end

function TL.FindConfigIdByName(name)
  name = Trim(name or "")
  if name == "" then
    return nil
  end
  local want = string.lower(name)
  local found = nil
  pcall(function()
    if not C_ClassTalents or not C_ClassTalents.GetConfigIDsBySpecID then
      return
    end
    local specID = nil
    if PlayerUtil and PlayerUtil.GetCurrentSpecID then
      specID = PlayerUtil.GetCurrentSpecID()
    end
    local ids = C_ClassTalents.GetConfigIDsBySpecID(specID)
    if type(ids) ~= "table" then
      return
    end
    for _, cid in ipairs(ids) do
      local info = C_Traits and C_Traits.GetConfigInfo and C_Traits.GetConfigInfo(cid)
      if type(info) == "table" and info.name and string.lower(tostring(info.name)) == want then
        found = cid
        break
      end
    end
  end)
  return found
end

--- Create a NEW named loadout via ImportLoadout.
--- success, importError = C_ClassTalents.ImportLoadout(configID, entries, name [, importString])
function TL.TryCreateNamedLoadout(configID, entries, name, importString)
  if not C_ClassTalents or not C_ClassTalents.ImportLoadout then
    return false, "ImportLoadout API missing"
  end
  name = Trim(name or "SSM Pack")
  importString = Trim(importString or "")
  entries = type(entries) == "table" and entries or {}

  local lastErr = nil
  local function try(fn)
    local ok, a, b = pcall(fn)
    if not ok then
      lastErr = tostring(a)
      return false
    end
    -- ImportLoadout returns success [, importError]
    if a == true or a == 1 then
      return true
    end
    if a == false or a == nil then
      lastErr = tostring(b or a or "rejected")
      return false
    end
    -- some builds return only error code
    lastErr = tostring(a)
    return false
  end

  -- Order: entries+name, entries+name+string, empty+name+string
  -- success, importError = ImportLoadout(...)  (importError is a string on failure)
  if #entries > 0 then
    if try(function()
      return C_ClassTalents.ImportLoadout(configID, entries, name)
    end) then
      return true, nil
    end
  end
  if importString ~= "" then
    if #entries > 0 and try(function()
      return C_ClassTalents.ImportLoadout(configID, entries, name, importString)
    end) then
      return true, nil
    end
    if try(function()
      return C_ClassTalents.ImportLoadout(configID, {}, name, importString)
    end) then
      return true, nil
    end
  end

  return false, lastErr or "ImportLoadout failed"
end

function TL.LoadLoadoutByName(name)
  local id = TL.FindConfigIdByName(name)
  if not id then
    return false
  end
  local ok = false
  pcall(function()
    if C_ClassTalents and C_ClassTalents.LoadConfig then
      ok = C_ClassTalents.LoadConfig(id, true) and true or false
    end
  end)
  if not ok then
    pcall(function()
      if C_ClassTalents and C_ClassTalents.UpdateLastSelectedSavedConfigID then
        C_ClassTalents.UpdateLastSelectedSavedConfigID(id)
      end
    end)
  end
  return ok or (id ~= nil)
end

--- Apply pack talents onto the active tree (always), then clone to a NEW named loadout when possible.
function TL.ApplyPartialMethodPackage(importString, loadoutName, opts)
  opts = type(opts) == "table" and opts or {}
  if InCombatLockdown and InCombatLockdown() then
    return false, "Leave combat first, then Apply talents.", { mode = "status", noCopy = true }
  end

  if not C_Traits or not C_Traits.PurchaseRank or not C_Traits.CanPurchaseRank then
    return false, "Talent purchase API missing. Use Copy  >  N  >  Import.", {
      mode = "copy",
      importString = Trim(importString),
      loadoutName = loadoutName,
    }
  end

  -- Empty tree / wiped loadout names > full package buy is the hot path that
  -- used to open N + poll icons on the same click stack as cast-bar secrets.
  pauseSsmIconPoll("talent-apply")

  local configID = TL.GetConfigID()
  if not configID then
    -- Need the talent config unlocked once; open N on a later frame, do not
    -- purchase in the same stack as a forced UI toggle.
    openTalentsUI(0.05)
    resumeSsmIconPoll(0.6)
    return false, "Open Talents (N) once so talents unlock, then Apply again.", {
      mode = "status",
      noCopy = true,
    }
  end

  local level = TL.GetPlayerLevel()
  importString = Trim(importString)
  loadoutName = Trim(loadoutName or "Pack")
  local uniqueName = TL.NextUniqueLoadoutName(loadoutName)

  -- Decode package (read-only - do NOT ResetTree before we snapshot)
  local entries, err, meta = TL.DecodeMethodToEntries(importString, configID)
  if not entries then
    resumeSsmIconPoll(0.3)
    return false, tostring(err or "Decode failed - pack talent string may be wrong class/spec."), {
      mode = "status",
      noCopy = true,
      level = level,
    }
  end

  local treeID = meta and meta.treeID or TL.GetTraitTreeID(configID)
  if not treeID then
    openTalentsUI(0.1)
    resumeSsmIconPoll(0.6)
    return false, "Could not find talent tree ID. Open N once, then Apply again.", {
      mode = "status",
      noCopy = true,
      level = level,
    }
  end

  if NS.Print then
    local srcNote = ""
    local src = tostring(opts.source or "")
    -- Prefer explicit source over a stale proxy flag (Mine/live must win).
    if src == "live" then
      srcNote = " |cff3dd68c[your live tree on pack - Mine]|r"
    elseif src == "custom" then
      srcNote = " |cff6c8cff[custom string on pack]|r"
    elseif src == "method-proxy" or (opts.proxy and src ~= "live" and src ~= "custom") then
      local hero = Trim(opts.hero or "")
      srcNote = " |cfff0b429[shared Method Class|Spec gold"
        .. (hero ~= "" and (" - not hero-exact for " .. hero) or "")
        .. "]|r"
    elseif src == "method" or opts.heroExact then
      srcNote = " |cff3dd68c[Method package]|r"
    end
    NS.Print(
      "Apply v"
        .. tostring(NS.VERSION or "?")
        .. " level "
        .. tostring(level)
        .. ": spend package on active tree ("
        .. tostring(#entries)
        .. " entries), then save as |cff3dd68c"
        .. uniqueName
        .. "|r if slots free..."
        .. srcNote
    )
  end

  -- Snapshot so a failed purchase never leaves a wiped tree open in N
  local snapshot = nil
  pcall(function()
    if C_Traits.GenerateImportString then
      snapshot = C_Traits.GenerateImportString(configID)
    end
  end)

  local resetOk = false
  pcall(function()
    if C_Traits.ResetTree then
      resetOk = C_Traits.ResetTree(configID, treeID) and true or false
    end
  end)
  if not resetOk and C_Traits.ResetTreeByCurrency then
    pcall(function()
      local cur = C_Traits.GetTreeCurrencyInfo and C_Traits.GetTreeCurrencyInfo(configID, treeID, true)
      if type(cur) == "table" then
        for _, c in ipairs(cur) do
          if c.traitCurrencyID then
            C_Traits.ResetTreeByCurrency(configID, treeID, c.traitCurrencyID)
            resetOk = true
          end
        end
      end
    end)
  end

  local bought = buyMethodEntries(configID, entries)
  local how = "purchase-active"
  local committed, dumped, finErr = false, 0, nil

  if bought > 0 then
    committed, dumped, finErr = finalizeTalents(configID, treeID)
  end

  if bought == 0 then
    if NS.Print then
      NS.Print("Purchase bought 0 - restoring previous talents.")
    end
    rollbackConfig(configID)
    if snapshot and #tostring(snapshot) >= 40 then
      if hasStagedChanges(configID) or stillHasFreeBudget(configID, treeID) then
        if C_ClassTalents and C_ClassTalents.ImportLoadout then
          pcall(function()
            C_ClassTalents.ImportLoadout(configID, {}, "SSM Restore", snapshot)
          end)
        end
        commitConfig(configID)
      end
    end
    -- Defer N open; empty snapshot (wiped tree) is normal - do not thrash cast UI.
    openTalentsUI(0.25)
    resumeSsmIconPoll(0.75)
    return false,
      "Could not spend package talents (0 buys). Open N once, check class/spec matches the pack, or use Copy  >  Import.",
      {
        mode = "copy",
        importString = importString,
        loadoutName = uniqueName,
        level = level,
        bought = 0,
        how = "stuck-0-buys",
        apiError = finErr or "0 buys",
      }
  end

  -- Commit as hard as we can
  local freeLeft = stillHasFreeBudget(configID, treeID)
  local staged = hasStagedChanges(configID)
  local canChange, changeErr = canCommitTalents()
  local reallyDone = committed and not freeLeft and not staged

  if not reallyDone and canChange then
    commitConfig(configID)
    freeLeft = stillHasFreeBudget(configID, treeID)
    staged = hasStagedChanges(configID)
    reallyDone = not freeLeft and not staged
  end
  if not reallyDone and bought > 0 and not freeLeft and staged then
    commitConfig(configID)
    staged = hasStagedChanges(configID)
    reallyDone = not staged
  end

  -- Live string of what we just spent (points-valid for this character)
  local liveStr = nil
  pcall(function()
    if C_Traits.GenerateImportString then
      liveStr = C_Traits.GenerateImportString(configID)
    end
  end)
  liveStr = Trim(liveStr or "")

  -- Clone result into a NEW uniquely named loadout when Blizzard allows it
  local newLoadout = false
  local canCreate = true
  if C_ClassTalents and C_ClassTalents.CanCreateNewConfig then
    local okC, r = pcall(C_ClassTalents.CanCreateNewConfig)
    if okC and r == false then
      canCreate = false
    end
  end

  if canCreate and C_ClassTalents and C_ClassTalents.ImportLoadout and #liveStr >= 40 then
    local liveEntries = select(1, TL.DecodeMethodToEntries(liveStr, configID))
    local created, createErr = TL.TryCreateNamedLoadout(
      configID,
      liveEntries or {},
      uniqueName,
      liveStr
    )
    if created then
      newLoadout = true
      how = how .. "+import-named"
      -- Defer loadout switch off Apply click stack (cast-bar taint).
      if C_Timer and C_Timer.After then
        C_Timer.After(0.05, function()
          pcall(function()
            TL.LoadLoadoutByName(uniqueName)
          end)
        end)
      else
        TL.LoadLoadoutByName(uniqueName)
      end
      if NS.Print then
        NS.Print("Saved as new loadout: " .. uniqueName)
      end
    elseif NS.Print then
      NS.Print(
        "Talents applied to current tree; could not also create named loadout ("
          .. tostring(createErr or "slots full?")
          .. "). Delete a loadout in N if full."
      )
    end
  elseif not canCreate and NS.Print then
    NS.Print("Talents applied to current tree (no free loadout slots for a new name).")
  end

  -- Review in N on a later frame - never sync after mass PurchaseRank.
  openTalentsUI(0.25)
  resumeSsmIconPoll(0.8)

  local nClass, nSpec, nHero = freePointBreakdown()
  local curFree = currencyFreeTotal(configID, treeID)
  local proxyTail = ""
  local srcEnd = tostring(opts.source or "")
  if srcEnd ~= "live" and srcEnd ~= "custom"
    and (opts.proxy or srcEnd == "method-proxy")
  then
    proxyTail = " |cfff0b429Proxy note:|r spell list is hero-honest; verify hero nodes in N."
  elseif srcEnd == "live" then
    proxyTail = " |cff3dd68cSource: Mine/live snapshot.|r"
  end
  local fillBit = dumped > 0 and (" (+" .. tostring(dumped) .. " leftover fill)") or ""
  local msg
  if reallyDone and newLoadout then
    msg = string.format(
      "Apply complete (level %d): %d buys > new loadout |cff3dd68c%s|r%s. Open N to review.%s",
      level,
      bought,
      uniqueName,
      fillBit,
      proxyTail
    )
  elseif reallyDone then
    msg = string.format(
      "Apply complete (level %d): %d buys on |cffffff00current|r loadout%s. No free name slot - delete a loadout in N and Apply again if you want SSM * as a named save.%s",
      level,
      bought,
      fillBit,
      proxyTail
    )
  elseif freeLeft or curFree > 0 then
    -- Partial is expected under max level / choice nodes / proxy hero leftovers - not a failed Apply.
    msg = string.format(
      "Partial Apply (level %d): spent %d buys; free points still open (class/spec %d/%d, currency %d). Open N > pick remaining choice/hero nodes > Blizzard |cffffff00Apply Changes|r. Mash list is separate from this tree.%s",
      level,
      bought,
      nClass,
      nSpec,
      curFree,
      proxyTail
    )
  elseif staged then
    msg = string.format(
      "Partial Apply (level %d): staged %d buys but Blizzard did not commit%s. Open N and click |cffffff00Apply Changes|r if shown.%s",
      level,
      bought,
      changeErr and (": " .. tostring(changeErr)) or "",
      proxyTail
    )
  else
    msg = string.format(
      "Apply (level %d): %d package buys.%s",
      level,
      bought,
      proxyTail
    )
  end

  -- Post-Apply verify: entry ranks vs intended package (not raw CUk string equality).
  local v = TL.VerifyAgainstIntended(configID, entries)
  local vBit = ""
  if v and v.total and v.total > 0 then
    vBit = string.format(
      " |cff6c8cffVerify:|r %d/%d package nodes (%.0f%%)%s.",
      tonumber(v.matched) or 0,
      tonumber(v.total) or 0,
      (tonumber(v.ratio) or 0) * 100,
      v.choiceMismatch and v.choiceMismatch > 0 and (" - " .. tostring(v.choiceMismatch) .. " choice differ") or ""
    )
    msg = msg .. vBit
  end

  if NS.Print then
    NS.Print(msg:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""))
  end

  if reallyDone then
    local vStatus = "verified"
    if v and v.total and v.total > 0 then
      if v.ok then
        vStatus = "verified"
      elseif v.partial or (tonumber(v.matched) or 0) > 0 then
        vStatus = "partial"
      else
        vStatus = "failed"
      end
    end
    return true, msg, {
      mode = "api",
      noCopy = true,
      loadoutName = uniqueName,
      newLoadout = newLoadout,
      level = level,
      bought = bought,
      dumped = dumped,
      liveString = liveStr,
      how = how,
      committed = true,
      needApplyChanges = false,
      source = opts.source,
      proxy = opts.proxy and true or false,
      partial = (vStatus == "partial"),
      verify = v,
      verifyStatus = vStatus,
    }
  end

  return false, msg, {
    mode = "partial",
    noCopy = freeLeft and true or false,
    loadoutName = uniqueName,
    newLoadout = newLoadout,
    level = level,
    bought = bought,
    dumped = dumped,
    liveString = liveStr,
    how = how,
    committed = false,
    needApplyChanges = staged or freeLeft or true,
    source = opts.source,
    proxy = opts.proxy and true or false,
    partial = true,
    verify = v,
    verifyStatus = "partial",
  }
end

--- Compare live tree ranks to intended ImportLoadout entries (from DecodeMethodToEntries).
--- Do NOT use raw CUk string equality - GenerateImportString re-encodes.
--- @return table { matched, total, missing, choiceMismatch, ratio, ok, partial, status }
function TL.VerifyAgainstIntended(configID, intendedEntries)
  local out = {
    matched = 0,
    total = 0,
    missing = 0,
    choiceMismatch = 0,
    ratio = 0,
    ok = false,
    partial = false,
    status = "unknown",
  }
  if not configID or type(intendedEntries) ~= "table" or #intendedEntries == 0 then
    out.status = "no-data"
    return out
  end
  if not C_Traits or not C_Traits.GetNodeInfo then
    out.status = "no-api"
    return out
  end

  for _, e in ipairs(intendedEntries) do
    if type(e) == "table" and e.nodeID then
      local wantPurch = tonumber(e.ranksPurchased) or 0
      local wantGrant = tonumber(e.ranksGranted) or 0
      local want = wantPurch + wantGrant
      if want < 1 then
        want = 1
      end
      out.total = out.total + 1
      local okInfo, info = pcall(C_Traits.GetNodeInfo, configID, e.nodeID)
      if not okInfo or type(info) ~= "table" then
        out.missing = out.missing + 1
      else
        local have = tonumber(info.currentRank)
          or tonumber(info.ranksPurchased)
          or tonumber(info.activeRank)
          or 0
        if have < 1 and info.activeEntry and info.activeEntry.rank then
          have = tonumber(info.activeEntry.rank) or have
        end
        local entryOk = true
        if e.selectionEntryID and info.entryIDs and #info.entryIDs > 1 then
          local active = info.activeEntry and info.activeEntry.entryID
          if active and active ~= e.selectionEntryID then
            entryOk = false
            out.choiceMismatch = out.choiceMismatch + 1
          end
        end
        if have >= want and entryOk then
          out.matched = out.matched + 1
        elseif have > 0 then
          -- Partial rank / wrong choice still counts half toward ratio via matched floor
          out.matched = out.matched + 0
          out.missing = out.missing + 1
        else
          out.missing = out.missing + 1
        end
      end
    end
  end

  if out.total > 0 then
    out.ratio = out.matched / out.total
  end
  -- >=90% intended package nodes present = verified package install
  out.ok = out.total > 0 and out.ratio >= 0.90
  out.partial = out.total > 0 and out.ratio >= 0.50 and out.ratio < 0.90
  if out.ok then
    out.status = "verified"
  elseif out.partial or (out.matched > 0) then
    out.status = "partial"
  elseif out.total > 0 then
    out.status = "failed"
  end
  return out
end

function TL.ShouldUsePartialApply(_playerLevel, _talentSource)
  return true
end

--- Build a Method-path CUk that spends YOUR free budget (for Copy > Import under max level).
--- Full Method CUk at e.g. 76 leaves free points after Blizzard import; this stages Method
--- buys + leftover dump, GenerateImportString, then Rollback so your tree is unchanged.
--- Returns importString, errMsg, detail.
function TL.BuildPointsValidMethodString(importString)
  if InCombatLockdown and InCombatLockdown() then
    return nil, "Leave combat first for a points-valid Copy.", { level = TL.GetPlayerLevel() }
  end
  if not C_Traits or not C_Traits.PurchaseRank then
    return nil, "Talent API missing.", { level = TL.GetPlayerLevel() }
  end

  local configID = TL.GetConfigID()
  if not configID then
    openTalentsUI()
    configID = TL.GetConfigID()
  end
  if not configID then
    return nil, "Open Talents (N) once, then Copy again.", { level = TL.GetPlayerLevel() }
  end

  local level = TL.GetPlayerLevel()
  importString = Trim(importString)
  local entries, err, meta = TL.DecodeMethodToEntries(importString, configID)
  if not entries then
    return nil, tostring(err or "Decode failed."), { level = level }
  end

  local treeID = meta and meta.treeID or TL.GetTraitTreeID(configID)
  if not treeID then
    return nil, "Could not find talent tree. Open N once.", { level = level }
  end

  -- Stage Method on free budget, encode, then roll back (do not commit / keep tree as-was).
  local resetOk = false
  pcall(function()
    if C_Traits.ResetTree then
      resetOk = C_Traits.ResetTree(configID, treeID) and true or false
    end
  end)
  if not resetOk and C_Traits.ResetTreeByCurrency then
    pcall(function()
      local cur = C_Traits.GetTreeCurrencyInfo and C_Traits.GetTreeCurrencyInfo(configID, treeID, true)
      if type(cur) == "table" then
        for _, c in ipairs(cur) do
          if c.traitCurrencyID then
            C_Traits.ResetTreeByCurrency(configID, treeID, c.traitCurrencyID)
            resetOk = true
          end
        end
      end
    end)
  end

  local bought = buyMethodEntries(configID, entries)
  local dumped = dumpLeftoverPoints(configID, treeID)
  -- Extra dump pass if currency still free
  if stillHasFreeBudget(configID, treeID) then
    dumped = dumped + dumpLeftoverPoints(configID, treeID)
  end

  local validStr = nil
  pcall(function()
    if C_Traits.GenerateImportString then
      validStr = C_Traits.GenerateImportString(configID)
    end
  end)
  validStr = Trim(validStr or "")
  -- Measure free budget on the staged Method tree BEFORE rollback.
  local freeLeftStaged = stillHasFreeBudget(configID, treeID)
  local curFreeStaged = currencyFreeTotal(configID, treeID)

  -- Always restore player's previous tree (staged only - never commit).
  rollbackConfig(configID)

  if #validStr < 40 then
    return nil, "Could not build a points-valid string (0 buys?). Use Apply or open N.", {
      level = level,
      bought = bought,
      dumped = dumped,
    }
  end

  return validStr, nil, {
    level = level,
    bought = bought,
    dumped = dumped,
    freeLeft = freeLeftStaged and true or false,
    currencyFree = curFreeStaged,
    how = "copy-points-valid",
  }
end
