--[[
  SpellSmasher Core - namespace, defaults, colors, small utils.

  Product contract (do not break):
  1. Combat spells come ONLY from the sequence list (import / Method / Engine / editor).
  2. Easy helpers (AA / retarget / defensive) only when user turns them On.
  3. Priority = priority triangle (top more often); reverse_priority = reverse triangle (1 21 321).
  4. Runtime = one combat cast per click (+ optional helpers <=255), ALWAYS advance.
  5. [nochanneling] on combat casts so mashing does not clip channels.
  6. NEVER inject extra spells in code (no secret Spikes/Infernal every mash).
  7. Pack wire: !SpellSmasher!1!<base64(utf8 JSON)> - match site ssm-pack.js.
  8. No multi-cast Priority windows (multi-GCD fall-through) - triangle only.
]]

local NS = {}
SpellSmasherNS = NS

NS.VERSION = "4.3.5"
NS.ADDON_NAME = "SpellSmasher"
NS.BUTTON_NAME = "SSMSeqButton"
NS.RELAY_NAME = "SSMSeqButton_KD"
NS.MACROTEXT_MAX = 255
NS.MAX_FLAT_STEPS = 500
NS.MACRO_LINE_MAX = 255

-- SpellSmasher brand tokens (spellsmasher.com :root) — never rebrand to generic gold UI.
-- WoW chrome = edge/structure only; fill + accents stay SS green / blue / navy.
NS.C = {
  bg  = { 0.031, 0.039, 0.063, 0.98 }, -- #080a10
  elevated  = { 0.063, 0.082, 0.133, 0.98 }, -- #101522
  card  = { 0.078, 0.106, 0.173, 0.98 }, -- #141b2c
  cardHover  = { 0.102, 0.137, 0.220, 1.00 }, -- #1a2338
  border  = { 0.165, 0.208, 0.302, 1.00 }, -- #2a354d
  text  = { 0.933, 0.949, 0.980, 1.00 }, -- #eef2fa
  muted  = { 0.545, 0.588, 0.678, 1.00 }, -- #8b96ad
  accent  = { 0.424, 0.549, 1.000, 1.00 }, -- #6c8cff brand blue
  accentSoft = { 0.424, 0.549, 1.000, 0.14 },
  green  = { 0.239, 0.839, 0.549, 1.00 }, -- #3dd68c brand green (the S)
  greenDark  = { 0.024, 0.141, 0.086, 1.00 }, -- #062416 text on green buttons
  greenSoft  = { 0.239, 0.839, 0.549, 0.16 },
  danger  = { 1.000, 0.420, 0.478, 1.00 }, -- #ff6b7a
  gold  = { 0.941, 0.757, 0.290, 1.00 }, -- #f0c14a (status only, not shell)
  warning  = { 0.941, 0.706, 0.161, 1.00 }, -- #f0b429
  -- Header strip / ambient
  headerBg = { 0.045, 0.055, 0.090, 1.00 },
  panelInset = { 0.040, 0.050, 0.080, 0.95 },
}

NS.defaults = {
  point = "CENTER",
  relPoint = "CENTER",
  x = 0,
  y = 200,
  scale = 1.0,
  locked = false,
  hidden = false,
  -- Main window size (user can drag bottom-right corner)
  frameW = 620,
  frameH = 580,
  -- When mash lives on an action-bar slot, hide the floating SSM button (user preference).
  hideFloatingWhenBar = true,
  barSlot = nil, -- 1-10 when placed on main bar
  barKey = nil, -- "1".."0"
  sequences = {},
  activeIndex = 1,
  aoeIndex = nil,
  firstRun = true,
  -- Spotlight tour (Help / Tutorial). coachSeen + coachTourVersion (bump Tour.VERSION to re-flash).
  coachSeen = false,
  coachTourVersion = 0,
  editSeqIndex = 1,
  editStepIndex = 1,
  uiTab = "list",
  -- Easy helpers - default Off (only when user turns them On)
  helperAutoAttack = false,
  helperRetarget = false,
  helperDefensive = false,
  -- Shift: ignore | aoe | pause  (default ignore = true one-button)
  shiftMode = "ignore",
  -- List UI: only show packs for the logged-in character's class (account DB is shared).
  listFilterThisClass = true,
}

NS.db = nil
NS.lastBakeInfo = { ok = false, st = 0, aoe = 0, err = nil, compile = nil }
NS.pendingRecompile = false
NS.pendingLayout = false

function NS.Print(msg)
  print("|cff3dd68c[SpellSmasher]|r " .. tostring(msg))
end

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

function NS.NormMode(mode)
  mode = string.lower(NS.Trim(mode))
  if mode == "once" or mode == "1" then
  return "once"
  end
  if mode == "sequential" or mode == "seq" or mode == "sequence" then
  return "sequential"
  end
  -- Reverse Priority (1 21 321 4321) - same slot weights as Priority, different click rhythm
  if mode == "reverse_priority"
  or mode == "reverse-priority"
  or mode == "reverse"
  or mode == "rpriority"
  or mode == "rev_priority"
  or mode == "priority_reverse"
  or mode == "revpri"
  or mode == "rev"
  then
  return "reverse_priority"
  end
  return "priority"
end

--- True for Priority or Reverse Priority contiguous blocks.
function NS.IsPriorityMode(mode)
  mode = NS.NormMode(mode)
  return mode == "priority" or mode == "reverse_priority"
end

function NS.SetFS(fs, color)
  if fs and color then
  fs:SetTextColor(color[1], color[2], color[3], color[4] or 1)
  end
end

--- Real Blizzard backdrops everywhere (same textures as game tooltips / dialogs).
--- Colors only tint via SetBackdropColor / SetBackdropBorderColor (SpellSmasher brand).
--- style: "panel" | "dialog" | "toast" | "leather" (old parchment/leather journal)
function NS.HexBackdrop(frame, bg, border, edgeSize, style)
  if not frame or not frame.SetBackdrop then
  return
  end
  -- Default = normal WoW tooltip panel (not flat white)
  style = style or "panel"
  if style == "flat" then
  style = "panel"
  end
  local info
  if style == "dialog" then
  -- Classic dark dialog box (Character / options style shell)
  info = {
  bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
  edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
  tile = true,
  tileEdge = true,
  tileSize = 32,
  edgeSize = 32,
  insets = { left = 11, right = 12, top = 12, bottom = 11 },
  }
  elseif style == "toast" then
  info = {
  bgFile = "Interface\\FriendsFrame\\UI-Toast-Background",
  edgeFile = "Interface\\FriendsFrame\\UI-Toast-Border",
  tile = true,
  tileEdge = true,
  tileSize = 12,
  edgeSize = 12,
  insets = { left = 5, right = 5, top = 5, bottom = 5 },
  }
  elseif style == "edge" then
  -- Border only — real page image drawn as a texture underneath/over transparent fill
  info = {
  bgFile = "Interface\\Buttons\\WHITE8X8",
  edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
  tile = true,
  tileEdge = true,
  tileSize = 8,
  edgeSize = 16,
  insets = { left = 5, right = 5, top = 5, bottom = 5 },
  }
  elseif style == "leather" or style == "parchment" then
  -- Fallback parchment (prefer Media/seq-page image on sequence rows)
  info = {
  bgFile = "Interface\\AchievementFrame\\UI-Achievement-Parchment-Horizontal",
  edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
  tile = false,
  tileEdge = true,
  tileSize = 32,
  edgeSize = 16,
  insets = { left = 5, right = 5, top = 5, bottom = 5 },
  }
  else
  -- "panel" / "tooltip" / anything else → standard game tooltip chrome
  local es = tonumber(edgeSize) or 16
  if es < 12 then
  es = 16
  end
  if es > 24 then
  es = 16
  end
  info = {
  bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
  edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
  tile = true,
  tileEdge = true,
  tileSize = 16,
  edgeSize = es,
  insets = { left = 4, right = 4, top = 4, bottom = 4 },
  }
  end
  frame:SetBackdrop(info)
  -- Tint the real texture (do NOT cover with a solid SoftFill — that kills the WoW look)
  if style == "edge" then
  -- Transparent fill so Media page art shows through
  frame:SetBackdropColor(0, 0, 0, 0)
  elseif bg then
  local a = bg[4]
  if a == nil then
  a = 0.92
  end
  if style == "leather" or style == "parchment" then
  if a > 0.98 then
  a = 0.98
  end
  else
  if a > 0.95 then
  a = 0.92
  end
  end
  frame:SetBackdropColor(bg[1], bg[2], bg[3], a)
  else
  if style == "leather" or style == "parchment" then
  frame:SetBackdropColor(0.72, 0.55, 0.32, 0.95)
  else
  frame:SetBackdropColor(0, 0, 0, 0.85)
  end
  end
  if border then
  frame:SetBackdropBorderColor(border[1], border[2], border[3], border[4] or 1)
  else
  if style == "leather" or style == "parchment" or style == "edge" then
  frame:SetBackdropBorderColor(0.55, 0.38, 0.18, 1)
  else
  local g = NS.C and NS.C.green
  if g then
  frame:SetBackdropBorderColor(g[1] * 0.55 + 0.35, g[2] * 0.55 + 0.35, g[3] * 0.55 + 0.35, 1)
  else
  frame:SetBackdropBorderColor(0.6, 0.6, 0.6, 1)
  end
  end
  end
end

--- Classic light parchment/leather (4.0.85 look) + high-contrast black ink.
NS.LEATHER = {
  fill = { 0.78, 0.58, 0.34, 0.97 },
  fillIdle = { 0.72, 0.52, 0.30, 0.95 },
  fillSel = { 0.55, 0.62, 0.38, 0.97 },
  edge = { 0.50, 0.32, 0.12, 1 },
  edgePri = { 0.35, 0.55, 0.28, 1 },
  edgeRev = { 0.35, 0.42, 0.70, 1 },
  edgeSel = { 0.25, 0.75, 0.45, 1 },
  -- Near-black on parchment (muddy brown was unreadable)
  ink = { 0.02, 0.02, 0.02, 1 },
  inkDim = { 0.10, 0.08, 0.05, 1 },
  inkNum = { 0.05, 0.05, 0.05, 1 },
  inkSel = { 0.00, 0.00, 0.00, 1 },
  inkMacSel = { 0.08, 0.10, 0.06, 1 },
}

--- Inner cards / rows / buttons — normal tooltip backdrop
function NS.PanelBackdrop(frame, bg, border)
  NS.HexBackdrop(frame, bg or NS.C.card, border or NS.C.border, 16, "panel")
end

--- Outer windows / popups — normal dark dialog backdrop
function NS.DialogBackdrop(frame, bg, border)
  NS.HexBackdrop(frame, bg or NS.C.bg, border or NS.C.green, 32, "dialog")
end

function NS.CopyDefaults(src)
  local t = {}
  for k, v in pairs(src) do
  if type(v) == "table" then
  t[k] = NS.CopyDefaults(v)
  else
  t[k] = v
  end
  end
  return t
end

--- Full deep copy of a pack/sequence (steps, helpers, nested tables).
--- Do NOT use CopyTable(t, true): in retail the 2nd arg is "shallow" and shares nested tables.
function NS.DeepCopy(src)
  if type(src) ~= "table" then
  return src
  end
  local t = {}
  for k, v in pairs(src) do
  if type(v) == "table" then
  t[k] = NS.DeepCopy(v)
  else
  t[k] = v
  end
  end
  return t
end

function NS.MergeDB()
  SpellSmasherDB = SpellSmasherDB or {}
  local db = SpellSmasherDB
  for k, v in pairs(NS.defaults) do
  if db[k] == nil then
  if type(v) == "table" then
  db[k] = NS.CopyDefaults(v)
  else
  db[k] = v
  end
  end
  end
  if type(db.sequences) ~= "table" then
  db.sequences = {}
  end
  NS.db = db
  return db
end

--- Class list for New pack UI (includes Devourer).
NS.CLASSES = {
  "Death Knight", "Demon Hunter", "Druid", "Evoker", "Hunter", "Mage",
  "Monk", "Paladin", "Priest", "Rogue", "Shaman", "Warlock", "Warrior",
}

NS.SPECS = {
  ["Death Knight"] = { "Blood", "Frost", "Unholy" },
  ["Demon Hunter"] = { "Havoc", "Vengeance", "Devourer" },
  ["Druid"] = { "Balance", "Feral", "Guardian", "Restoration" },
  ["Evoker"] = { "Devastation", "Preservation", "Augmentation" },
  ["Hunter"] = { "Beast Mastery", "Marksmanship", "Survival" },
  ["Mage"] = { "Arcane", "Fire", "Frost" },
  ["Monk"] = { "Brewmaster", "Mistweaver", "Windwalker" },
  ["Paladin"] = { "Holy", "Protection", "Retribution" },
  ["Priest"] = { "Discipline", "Holy", "Shadow" },
  ["Rogue"] = { "Assassination", "Outlaw", "Subtlety" },
  ["Shaman"] = { "Elemental", "Enhancement", "Restoration" },
  ["Warlock"] = { "Affliction", "Demonology", "Destruction" },
  ["Warrior"] = { "Arms", "Fury", "Protection" },
}

NS.DEFENSIVE_BY_CLASS = {
  ["Demon Hunter"] = "Demon Spikes",
  ["Death Knight"] = "Anti-Magic Shell",
  ["Druid"] = "Barkskin",
  ["Evoker"] = "Obsidian Scales",
  ["Hunter"] = "Exhilaration",
  ["Mage"] = "Ice Barrier",
  ["Monk"] = "Fortifying Brew",
  ["Paladin"] = "Divine Protection",
  ["Priest"] = "Desperate Prayer",
  ["Rogue"] = "Feint",
  ["Shaman"] = "Astral Shift",
  ["Warlock"] = "Unending Resolve",
  ["Warrior"] = "Ignore Pain",
}

function NS.ActiveSequence()
  local db = NS.db
  if not db or type(db.sequences) ~= "table" then
  return nil
  end
  return db.sequences[db.activeIndex or 1] or db.sequences[1]
end

--- Blizzard classFile  >  English product class name (matches MethodOrders keys).
NS.CLASS_FILE_TO_NAME = {
  DEMONHUNTER = "Demon Hunter",
  DEATHKNIGHT = "Death Knight",
  DRUID = "Druid",
  EVOKER = "Evoker",
  HUNTER = "Hunter",
  MAGE = "Mage",
  MONK = "Monk",
  PALADIN = "Paladin",
  PRIEST = "Priest",
  ROGUE = "Rogue",
  SHAMAN = "Shaman",
  WARLOCK = "Warlock",
  WARRIOR = "Warrior",
}

function NS.ActiveClassName()
  local st = NS.ActiveSequence()
  if st and NS.Trim(st.className) ~= "" then
  return NS.Trim(st.className)
  end
  local _, classFile = UnitClass("player")
  return NS.CLASS_FILE_TO_NAME[classFile or ""]
end

--- Current character class + spec in English product names (not UI locale).
--- Spec index maps through NS.SPECS so export matching works on non-en clients.
function NS.GetPlayerClassSpec()
  local _, classFile = UnitClass("player")
  local className = NS.CLASS_FILE_TO_NAME[classFile or ""]
  if not className then
  return nil, nil
  end
  local specs = NS.SPECS[className]
  local idx = (GetSpecialization and GetSpecialization()) or 0
  local specName = (specs and idx >= 1 and specs[idx]) or nil
  if (not specName or specName == "") and GetSpecializationInfo and idx >= 1 then
  local _, name = GetSpecializationInfo(idx)
  if name and name ~= "" then
  specName = name
  end
  end
  return className, specName
end

function NS.ClassSpecEquals(aClass, aSpec, bClass, bSpec)
  return string.lower(NS.Trim(aClass)) == string.lower(NS.Trim(bClass))
  and string.lower(NS.Trim(aSpec)) == string.lower(NS.Trim(bSpec))
end

function NS.ClassNameEquals(aClass, bClass)
  return string.lower(NS.Trim(aClass)) == string.lower(NS.Trim(bClass))
end

--- True if pack class matches the logged-in character (any spec of that class).
function NS.PackMatchesPlayerClass(seq)
  if type(seq) ~= "table" then
  return false
  end
  local pClass = select(1, NS.GetPlayerClassSpec())
  if not pClass or pClass == "" then
  return true -- unknown player class: show all
  end
  local cn = NS.Trim(seq.className or "")
  if cn == "" then
  return true -- untagged packs stay visible
  end
  return NS.ClassNameEquals(cn, pClass)
end

--- If Active pack is another class, point Active at first pack for this character's class.
function NS.EnsureActivePackForPlayer()
  local db = NS.db
  if not db or type(db.sequences) ~= "table" or #db.sequences == 0 then
  return false
  end
  local pClass = select(1, NS.GetPlayerClassSpec())
  if not pClass or pClass == "" then
  return false
  end
  local act = db.sequences[db.activeIndex or 1]
  if act and NS.PackMatchesPlayerClass(act) then
  return false
  end
  for i, s in ipairs(db.sequences) do
  if NS.PackMatchesPlayerClass(s) then
  db.activeIndex = i
  db.listSelectIndex = i
  db.editSeqIndex = i
  return true
  end
  end
  return false
end

--- Try one configID  >  CUk string (or nil).
local function tryGenerateImportString(configID)
  if not configID or not C_Traits or not C_Traits.GenerateImportString then
  return nil
  end
  local ok, str = pcall(C_Traits.GenerateImportString, configID)
  if not ok or type(str) ~= "string" then
  return nil
  end
  str = NS.Trim(str)
  if #str < 40 then
  return nil
  end
  return str
end

--- Live talent loadout string for this character (CUk...), or nil.
--- Tries active config, then last-selected saved loadout, then first config for spec.
function NS.GetLiveTalentLoadoutString()
  if not C_Traits or not C_Traits.GenerateImportString then
  return nil
  end
  if C_ClassTalents and C_ClassTalents.GetActiveConfigID then
  local live = tryGenerateImportString(C_ClassTalents.GetActiveConfigID())
  if live then
  return live
  end
  end
  local specIndex = (GetSpecialization and GetSpecialization()) or 0
  local specID = nil
  if GetSpecializationInfo and specIndex >= 1 then
  specID = GetSpecializationInfo(specIndex)
  end
  if not specID or not C_ClassTalents then
  return nil
  end
  if C_ClassTalents.GetLastSelectedSavedConfigID then
  local saved = C_ClassTalents.GetLastSelectedSavedConfigID(specID)
  local live = tryGenerateImportString(saved)
  if live then
  return live
  end
  end
  if C_ClassTalents.GetConfigIDsBySpecID then
  local ids = C_ClassTalents.GetConfigIDsBySpecID(specID)
  if type(ids) == "table" then
  for _, cid in ipairs(ids) do
  local live = tryGenerateImportString(cid)
  if live then
  return live
  end
  end
  end
  end
  return nil
end

--- If seq class|spec matches the logged-in character, write live CUk onto the pack.
--- Returns true when applied. Does not invent talents for a different class/spec.
--- Method curated strings are only a fallback when this cannot run (or specs differ).
function NS.ApplyLiveTalentLoadout(seq)
  if type(seq) ~= "table" then
  return false
  end
  local live = NS.GetLiveTalentLoadoutString()
  if not live then
  return false
  end
  local pClass, pSpec = NS.GetPlayerClassSpec()
  if not pClass or not pSpec or pSpec == "" then
  return false
  end
  if not NS.ClassSpecEquals(seq.className, seq.spec, pClass, pSpec) then
  return false
  end
  seq.talentLoadout = live
  seq.talents = live
  seq.talentLoadoutSource = "live" -- vs "method" when curated was used
  return true
end

--- Prefer live CUk when pack matches this character; else leave existing / Method.
function NS.EnsureTalentLoadoutOnPack(seq)
  if type(seq) ~= "table" then
  return false
  end
  if NS.ApplyLiveTalentLoadout(seq) then
  return true
  end
  local existing = NS.Trim(seq.talentLoadout or seq.talents or "")
  if #existing >= 40 then
  return false
  end
  if SpellSmasherMethodLoadouts then
  local lo = SpellSmasherMethodLoadouts.Get(seq.className, seq.spec)
  if lo and #lo >= 40 then
  seq.talentLoadout = lo
  seq.talents = lo
  seq.talentLoadoutSource = "method"
  return true
  end
  end
  return false
end

--- Sanitize a Blizzard loadout display name (length + safe chars).
function NS.SanitizeLoadoutName(name)
  name = NS.Trim(tostring(name or ""))
  if name == "" then
  name = "SpellSmasher"
  end
  -- Prefix so users can find our loadouts in the dropdown
  if not name:lower():find("ssm", 1, true) and not name:lower():find("spellsmasher", 1, true) then
  name = "SSM " .. name
  end
  name = name:gsub("[%c%z]", "")
  if #name > 28 then
  name = name:sub(1, 28)
  end
  return name
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

--- Full CUk import via Blizzard (level 90 / live trees). Unchanged behavior.
local function importFullLoadoutString(importString, loadoutName, configID)
  local importFn = C_ClassTalents.ImportLoadout
  if type(importFn) ~= "function" then
  return false, "ImportLoadout API not available - use Ctrl+C  >  Talents Import.", {
  mode = "copy",
  importString = importString,
  loadoutName = loadoutName,
  }
  end

  local okCall, ret1, ret2 = pcall(function()
  return importFn(configID, {}, loadoutName, importString)
  end)

  if okCall and ret1 then
  pcall(function()
  if C_ClassTalents.CommitConfig then
  C_ClassTalents.CommitConfig(configID)
  elseif C_Traits and C_Traits.CommitConfig then
  C_Traits.CommitConfig(configID)
  end
  end)
  return true, "Loadout applied as |cff3dd68c" .. loadoutName .. "|r (Blizzard ImportLoadout).", {
  mode = "api",
  loadoutName = loadoutName,
  configID = configID,
  }
  end

  local errMsg = "rejected"
  if not okCall then
  errMsg = tostring(ret1)
  elseif ret2 then
  errMsg = tostring(ret2)
  end

  pcall(function()
  if C_ClassTalents.ViewLoadout then
  C_ClassTalents.ViewLoadout(importString)
  elseif C_ClassTalents.InitializeViewLoadout and C_ClassTalents.ViewLoadout then
  C_ClassTalents.InitializeViewLoadout(importString)
  end
  end)

  return false, "Auto-import not accepted (" .. errMsg .. "). Use Ctrl+C popup.", {
  mode = "copy",
  importString = importString,
  loadoutName = loadoutName,
  apiError = errMsg,
  }
end

--- Best-effort: push pack talent string into Blizzard.
--- Method under max level: ResetTree + multipass CanPurchaseRank (SSM_TalentLevel).
--- Level max / live: full ImportLoadout.
function NS.ImportTalentStringToGame(importString, loadoutName, opts)
  opts = type(opts) == "table" and opts or {}
  importString = NS.Trim(importString or "")
  if #importString < 40 then
  return false, "No talent string (need a CUk... loadout on the pack).", { mode = "status", noCopy = true }
  end
  if InCombatLockdown and InCombatLockdown() then
  return false, "Leave combat first, then Apply talents.", { mode = "status", noCopy = true }
  end
  if not C_ClassTalents then
  return false, "Talent API missing on this client.", { mode = "status", noCopy = true }
  end

  loadoutName = NS.SanitizeLoadoutName(loadoutName)

  local level = 1
  if NS.TalentLevel and NS.TalentLevel.GetPlayerLevel then
  level = NS.TalentLevel.GetPlayerLevel()
  elseif UnitLevel then
  level = tonumber(UnitLevel("player")) or 1
  end
  local src = string.lower(tostring(opts.source or "method"))
  local isLive = src == "live"
  local levelMax = (NS.TalentLevel and NS.TalentLevel.LEVEL_MAX) or 90

  -- Under max Method: purchase multipass (any level with a talent tree).
  if level < levelMax and not isLive then
  if NS.TalentLevel and NS.TalentLevel.ApplyPartialMethodPackage then
  return NS.TalentLevel.ApplyPartialMethodPackage(importString, loadoutName)
  end
  return false, "Talent apply module missing — fully exit WoW and restart.", {
  mode = "status",
  noCopy = true,
  level = level,
  }
  end

  local configID = nil
  if C_ClassTalents.GetActiveConfigID then
  configID = C_ClassTalents.GetActiveConfigID()
  end
  if not configID and C_ClassTalents.GetConfigIDsBySpecID then
  local specIndex = GetSpecialization and GetSpecialization() or 0
  local specID = GetSpecializationInfo and GetSpecializationInfo(specIndex)
  if specID then
  local ids = C_ClassTalents.GetConfigIDsBySpecID(specID)
  if type(ids) == "table" and ids[1] then
  configID = ids[1]
  end
  end
  end
  if not configID then
  return false, "Open Talents (N) once so a loadout config exists, then try Apply again.", {
  mode = "status",
  noCopy = true,
  }
  end

  openTalentsUI()
  return importFullLoadoutString(importString, loadoutName, configID)
end

--- Ensure pack has a CUk (Best/Method), then try Apply to game as named loadout.
--- Returns same shape as ImportTalentStringToGame.
function NS.ApplyPackTalentsToGame(seq)
  if type(seq) ~= "table" then
  return false, "No pack selected.", { mode = "copy" }
  end
  local pClass, pSpec = NS.GetPlayerClassSpec()
  if not NS.ClassSpecEquals(seq.className, seq.spec, pClass, pSpec) then
  return false, "Pack is "
  .. tostring(seq.className)
  .. " / "
  .. tostring(seq.spec)
  .. " - you are "
  .. tostring(pClass or "?")
  .. " / "
  .. tostring(pSpec or "?")
  .. ". Switch character/spec first.", { mode = "copy" }
  end

  -- From me = user's live tree only. Everything else uses Method/Best package.
  local src = seq.talentLoadoutSource
  if src ~= "live" then
  src = "method"
  end

  -- Under 90 (and any Method path): always ensure Best/Method package is on the pack.
  -- Players should not have to remember to click Best first — Apply does it.
  local lo = NS.Trim(seq.talentLoadout or seq.talents or "")
  if src ~= "live" then
  local needBest = #lo < 40
  local level = (NS.TalentLevel and NS.TalentLevel.GetPlayerLevel and NS.TalentLevel.GetPlayerLevel())
  or (UnitLevel and UnitLevel("player"))
  or 1
  -- Below max level always refresh Method/Best so Apply has the guide package to scale
  if needBest or level < 90 then
  local R = SpellSmasherRebuild
  if R and R.RecommendTalentsForList then
  R.RecommendTalentsForList(seq)
  lo = NS.Trim(seq.talentLoadout or seq.talents or "")
  end
  end
  if #lo < 40 and SpellSmasherMethodLoadouts then
  local m = SpellSmasherMethodLoadouts.Get(seq.className, seq.spec, seq.heroTalent)
  if m and #m >= 40 then
  seq.talentLoadout = m
  seq.talents = m
  seq.talentLoadoutSource = "method"
  lo = m
  end
  end
  end

  lo = NS.Trim(seq.talentLoadout or seq.talents or "")
  if #lo < 40 then
  return false, "No talent package yet. Click |cffffff00Best|r (or New Method pack), then Apply.", {
  mode = "status",
  noCopy = true,
  }
  end

  local name = NS.SanitizeLoadoutName(seq.name or "Pack")
  return NS.ImportTalentStringToGame(lo, name, { source = src })
end

_G["BINDING_HEADER_SPELLSMASHER"] = "SpellSmasher"
_G["BINDING_NAME_CLICK SSMSeqButton:LeftButton"] = "SpellSmasher (one-button)"
