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

  Under max level (Method packs):
    1) Decode Method CUk with GetTreeNodes order (proven 75/75 path)
    2) ResetTree on active config
    3) Multipass: SetSelection for choice nodes, PurchaseRank for normal
       only when CanPurchaseRank — spends YOUR point budget along Method
    4) Dump leftover free points so Commit is not blocked
    5) CommitConfig — no ImportLoadout thrash, no unique-name spam

  Level max / live "From me":
    Handled by SSM_Core importFullLoadoutString (ImportLoadout).

  Auto Best/Method is done in NS.ApplyPackTalentsToGame before this runs.
]]

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

local function openTalentsUI()
  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

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

local function uniqueLoadoutName(base)
  base = Trim(base or "SSM Pack")
  if #base > 22 then
    base = base:sub(1, 22)
  end
  local n = 1
  if NS.db then
    NS.db.talentApplySerial = (tonumber(NS.db.talentApplySerial) or 0) + 1
    n = NS.db.talentApplySerial
  end
  local name = base .. " " .. tostring(n)
  if #name > 28 then
    name = name:sub(1, 28)
  end
  return name
end

--- Proven once at 70: ImportLoadout(entries) with unique name.
local function tryImportLoadoutEntries(configID, entries, name, importString)
  if not C_ClassTalents or type(C_ClassTalents.ImportLoadout) ~= "function" then
    return false, "ImportLoadout missing"
  end
  if C_ClassTalents.CanCreateNewConfig and not C_ClassTalents.CanCreateNewConfig() then
    return false, "Too many loadouts — delete old SSM … in N"
  end
  local ok, success, errStr = pcall(function()
    if importString and #importString >= 40 then
      return C_ClassTalents.ImportLoadout(configID, entries, name, importString)
    end
    return C_ClassTalents.ImportLoadout(configID, entries, name)
  end)
  if not ok then
    return false, tostring(success)
  end
  if not success then
    ok, success, errStr = pcall(function()
      return C_ClassTalents.ImportLoadout(configID, entries, name)
    end)
  end
  if not success then
    return false, tostring(errStr or "rejected")
  end
  commitConfig(configID)
  return true, name
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

--- Under max level Method: ResetTree + multipass purchase (no ImportLoadout).
function TL.ApplyPartialMethodPackage(importString, loadoutName)
  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 on this client. Use Copy  >  N  >  Import.", {
      mode = "copy",
      importString = Trim(importString),
      loadoutName = loadoutName,
    }
  end

  local configID = TL.GetConfigID()
  if not configID then
    openTalentsUI()
    configID = TL.GetConfigID()
  end
  if not configID then
    return false, "Open Talents (N) once so talents unlock, then Apply again.", {
      mode = "status",
      noCopy = true,
    }
  end

  local level = TL.GetPlayerLevel()
  importString = Trim(importString)

  local entries, err, meta = TL.DecodeMethodToEntries(importString, configID)
  if not entries then
    return false, tostring(err or "Decode failed."), { mode = "status", noCopy = true, level = level }
  end

  local treeID = meta and meta.treeID or TL.GetTraitTreeID(configID)
  if not treeID then
    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
    NS.Print(
      "Apply v"
        .. tostring(NS.VERSION or "?")
        .. " level "
        .. tostring(level)
        .. ": purchase path, "
        .. tostring(#entries)
        .. " Method entries..."
    )
  end

  -- Snapshot for restore if we buy nothing after reset
  local snapshot = nil
  pcall(function()
    if C_Traits.GenerateImportString then
      snapshot = C_Traits.GenerateImportString(configID)
    end
  end)

  -- Reset tree so Method order can be filled from free points
  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-multipass"
  local committed, dumped, finErr = false, 0, nil

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

  -- Purchase stuck with red "spend all points" / grey Apply Changes, or 0 buys → ImportLoadout
  local needImport = (bought == 0) or (bought > 0 and not committed and finErr)

  if needImport then
    if bought > 0 then
      if NS.Print then
        NS.Print(
          "Purchase staged but could not commit ("
            .. tostring(finErr or "blocked")
            .. ") — rolling back, trying ImportLoadout..."
        )
      end
      rollbackConfig(configID)
    else
      if NS.Print then
        NS.Print("Purchase bought 0 — trying ImportLoadout entries fallback...")
      end
    end

    local iname = uniqueLoadoutName(loadoutName or "SSM Pack")
    local iok, ires = tryImportLoadoutEntries(configID, entries, iname, importString)
    if iok then
      how = "importloadout-entries"
      local c2, d2 = finalizeTalents(configID, treeID)
      committed = c2 or not hasStagedChanges(configID)
      dumped = dumped + (d2 or 0)
      bought = math.max(bought, #entries)
      local liveStr = nil
      pcall(function()
        if C_Traits.GenerateImportString then
          liveStr = C_Traits.GenerateImportString(configID)
        end
      end)
      local msg = string.format(
        "Level %d: Applied Method via ImportLoadout — %d entries as |cff3dd68c%s|r.%s",
        level,
        #entries,
        iname,
        committed and " Loadout ready — pick it in N if not active." or " Open N, select the SSM loadout, Apply Changes."
      )
      if NS.Print then
        NS.Print(msg:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""))
      end
      openTalentsUI()
      return true, msg, {
        mode = "api-partial",
        noCopy = true,
        loadoutName = iname,
        level = level,
        bought = bought,
        liveString = liveStr,
        how = how,
        committed = committed,
      }
    end

    -- Restore snapshot if we wiped the tree
    if snapshot and #tostring(snapshot) >= 40 and C_ClassTalents and C_ClassTalents.ImportLoadout then
      pcall(function()
        C_ClassTalents.ImportLoadout(configID, {}, "SSM Restore", snapshot)
      end)
      commitConfig(configID)
    else
      rollbackConfig(configID)
    end
    openTalentsUI()
    return false,
      "Could not finish talents ("
        .. tostring(finErr or ires or "0 buys")
        .. "). In N: spend every free point, or use Copy  >  Import. Blizzard blocks Apply Changes until points are fully spent.",
      {
        mode = "copy",
        importString = importString,
        loadoutName = loadoutName,
        level = level,
        bought = bought,
        how = "stuck-spend-all",
        apiError = finErr or ires,
      }
  end

  local liveStr = nil
  pcall(function()
    if C_Traits.GenerateImportString then
      liveStr = C_Traits.GenerateImportString(configID)
    end
  end)

  local freeLeft = stillHasFreeBudget(configID, treeID)
  local staged = hasStagedChanges(configID)
  local canChange, changeErr = canCommitTalents()
  local reallyDone = committed and not freeLeft and not staged

  -- Last chance commit if Blizzard says we can
  if not reallyDone and canChange then
    commitConfig(configID)
    freeLeft = stillHasFreeBudget(configID, treeID)
    staged = hasStagedChanges(configID)
    reallyDone = not freeLeft and not staged
  end

  openTalentsUI()

  local nClass, nSpec, nHero = freePointBreakdown()
  local curFree = currencyFreeTotal(configID, treeID)
  local msg
  if reallyDone then
    msg = string.format(
      "Level %d: Applied Method path — %d Method buys%s. Talents committed.",
      level,
      bought,
      dumped > 0 and (" +" .. tostring(dumped) .. " leftover") or ""
    )
  elseif freeLeft or curFree > 0 then
    msg = string.format(
      "Level %d: Method path staged (%d buys). Free budget left (API class/spec %d/%d, currency %d). In N spend remaining nodes, then |cffffff00Apply Changes|r.",
      level,
      bought,
      nClass,
      nSpec,
      curFree
    )
  elseif staged then
    msg = string.format(
      "Level %d: Method path staged (%d buys) but Blizzard blocked commit%s. In N: if Apply Changes is grey, you still have a points issue — try clicking empty yellow nodes or Cancel then use Copy.",
      level,
      bought,
      changeErr and (": " .. tostring(changeErr)) or ""
    )
  else
    msg = string.format("Level %d: Applied Method path — %d Method buys.", level, bought)
  end

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

  return reallyDone or bought > 0, msg, {
    mode = "api-partial",
    noCopy = not reallyDone and freeLeft,
    loadoutName = loadoutName,
    level = level,
    bought = bought,
    dumped = dumped,
    liveString = liveStr,
    how = how,
    committed = reallyDone,
    needApplyChanges = staged or freeLeft,
  }
end

function TL.ShouldUsePartialApply(playerLevel, talentSource)
  playerLevel = tonumber(playerLevel) or TL.GetPlayerLevel()
  if playerLevel >= TL.LEVEL_MAX then
    return false
  end
  return string.lower(tostring(talentSource or "method")) ~= "live"
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
