--[[
  SpellSmasher rebuild engine - same behavior as the website theme:
  Method.gg orders + Method talents, or SpellSmasher Engine orders + Method talents.
  Role: ST uses order.steps, AoE uses aoeSteps (when authored).
  New pack / Rebuild honor the ST or AoE chip.
  Data: MethodOrders.lua + EngineOrders.lua (ported from site JS).
]]

local Rebuild = {}

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

local function NormKey(s)
  s = string.lower(tostring(s or ""))
  s = s:gsub("['']", "")
  s = s:gsub("[^%w%+]+", " ")
  s = s:gsub("%s+", " ")
  return Trim(s)
end

-- Tanks/healers keep @player ground drops. DPS ground reticles go @cursor.
local TANK_OR_HEAL = {
  blood = true,
  vengeance = true,
  guardian = true,
  brewmaster = true,
  protection = true,
  restoration = true,
  preservation = true,
  mistweaver = true,
  holy = true,
  discipline = true,
}

-- True ground-reticle spells only (Wowhead 12.1). Not Frozen Orb (launches
-- forward), Comet Storm / Upheaval / Graveyard / Tentacle Slam (unit/facing),
-- Starfall / Convoke / IA / Blade Dance / The Hunt / Felblade.
local GROUND_CURSOR = {
  ["blizzard"] = true,
  ["flamestrike"] = true,
  ["meteor"] = true,
  ["rain of fire"] = true,
  ["cataclysm"] = true,
  ["earthquake"] = true,
  ["shadowfury"] = true,
  ["summon infernal"] = true,
  ["volley"] = true,
  ["force of nature"] = true,
  ["binding shot"] = true,
  ["freezing trap"] = true,
  ["tar trap"] = true,
  ["steel trap"] = true,
  ["implosive trap"] = true,
  ["flare"] = true,
  ["heroic leap"] = true,
  ["champion's spear"] = true,
  ["metamorphosis"] = true,
  ["sigil of flame"] = true,
  ["sigil of spite"] = true,
  ["sigil of silence"] = true,
  ["sigil of misery"] = true,
  ["sigil of chains"] = true,
  ["elysian decree"] = true,
  ["ring of frost"] = true,
  ["ursol's vortex"] = true,
  ["landslide"] = true,
  ["demonic gateway"] = true,
}

-- Method stamped player=true; these are NOT feet drops.
local NEVER_PLAYER = {
  ["frozen orb"] = true,
}

-- Ground-placed DPS buttons always @cursor (Meteor is on Fire ST and AoE).
-- Tanks/healers stay @player. Pack role is not a gate.
local function WantDpsAoeCursor(name, spec, _role)
  if TANK_OR_HEAL[NormKey(spec)] then
  return false
  end
  return GROUND_CURSOR[NormKey(name)] == true
end

local function MacroForStep(t, spec, role)
  local name = t.name or t.label or "Spell"
  -- Infernal Strike: hop in place. Never @cursor (that waits for a click).
  if NormKey(name) == "infernal strike" then
    return "/cast [nochanneling,@player] Infernal Strike"
  end
  -- [nochanneling] on every combat cast: mash will not interrupt a long channel
  -- (Fel Dev / Eye Beam / etc.). Compile still re-normalizes in SpellSmasher.lua.
  -- DPS AoE ground spells: @cursor. Tanks/healers keep authored @player.
  if WantDpsAoeCursor(name, spec, role) then
  return "/cast [nochanneling,@cursor] " .. name
  end
  if t.player and not NEVER_PLAYER[NormKey(name)] then
  return "/cast [nochanneling,@player] " .. name
  end
  if t.cursor then
  return "/cast [nochanneling,@cursor] " .. name
  end
  if t.focus then
  return "/cast [nochanneling,@focus] " .. name
  end
  if t.macro and t.macro ~= "" then
  return tostring(t.macro)
  end
  return "/cast [nochanneling] " .. name
end

local function GetMethodOrder(className, spec, hero)
  -- Classic never falls through to Midnight Method.gg tables.
  -- MoP and Era are different list packs (do not share tables).
  if SpellSmasherNS and SpellSmasherNS.IsMistsFlavor then
    if SpellSmasherMopMethodOrders and SpellSmasherMopMethodOrders.GetOrder then
      return SpellSmasherMopMethodOrders.GetOrder(className, spec, hero)
    end
    return nil
  end
  if SpellSmasherNS and SpellSmasherNS.IsTbcFlavor then
    if SpellSmasherTbcMethodOrders and SpellSmasherTbcMethodOrders.GetOrder then
      return SpellSmasherTbcMethodOrders.GetOrder(className, spec, hero)
    end
    return nil
  end
  if SpellSmasherNS and SpellSmasherNS.IsClassicFlavor then
    if SpellSmasherClassicMethodOrders and SpellSmasherClassicMethodOrders.GetOrder then
      return SpellSmasherClassicMethodOrders.GetOrder(className, spec, hero)
    end
    return nil
  end
  if SpellSmasherMethodOrders and SpellSmasherMethodOrders.GetOrder then
  return SpellSmasherMethodOrders.GetOrder(className, spec, hero)
  end
  return nil
end

local function GetEngineOrder(className, spec, hero)
  if SpellSmasherEngineOrders and SpellSmasherEngineOrders.GetOrder then
  return SpellSmasherEngineOrders.GetOrder(className, spec, hero)
  end
  return nil
end

--- Like site PriorityOrders.getOrder(class, spec, source [, hero])
function Rebuild.GetOrder(className, spec, source, hero)
  source = string.lower(tostring(source or "method"))
  hero = Trim(hero)
  local useEngine = (source == "engine" or source == "spellsmasher" or source == "ss")
  if useEngine then
  local eng = GetEngineOrder(className, spec, hero)
  if eng then
  -- Engine mash order + Method talent package (site injectMethodLoadout)
  local method = GetMethodOrder(className, spec, hero)
  local out = {}
  for k, v in pairs(eng) do
  out[k] = v
  end
  if method and method.talents then
  out.talents = method.talents
  end
  out.source = "SpellSmasher Engine"
  out.engine = true
  out.patch = out.patch or "Midnight 12.0.7"
  if hero ~= "" then
  out.heroTalent = hero
  end
  return out
  end
  end
  local m = GetMethodOrder(className, spec, hero)
  if m then
  local out = {}
  for k, v in pairs(m) do
  out[k] = v
  end
  out.source = out.source or "Method.gg"
  out.patch = out.patch or "Midnight 12.0.7"
  if hero ~= "" then
  out.heroTalent = hero
  end
  return out
  end
  return nil
end

--- List hero talent paths for class|spec (registry + order data).
function Rebuild.ListHeroes(className, spec)
  if SpellSmasherHeroPaths and SpellSmasherHeroPaths.ListHeroes then
  local t = SpellSmasherHeroPaths.ListHeroes(className, spec)
  if t and #t > 0 then
  return t
  end
  end
  if SpellSmasherMethodOrders and SpellSmasherMethodOrders.ListHeroes then
  return SpellSmasherMethodOrders.ListHeroes(className, spec) or {}
  end
  return {}
end

function Rebuild.DefaultHero(className, spec)
  if SpellSmasherHeroPaths and SpellSmasherHeroPaths.DefaultHero then
  local h = SpellSmasherHeroPaths.DefaultHero(className, spec)
  if h and h ~= "" then
  return h
  end
  end
  local t = Rebuild.ListHeroes(className, spec)
  return t[1] or ""
end

--- Like site PriorityOrders.rebuildSequence
--- @return table { ok, steps, name, talents, usedAoeSteps, ... }
function Rebuild.RebuildSequence(className, spec, currentSteps, options)
  options = options or {}
  local keepExtras = options.keepExtras ~= false
  local role = string.lower(tostring(options.role or "hybrid"))
  local source = string.lower(tostring(options.source or "method"))
  local hero = Trim(options.hero)
  local order = Rebuild.GetOrder(className, spec, source, hero)

  if not order then
  return {
  ok = false,
  message = "No priority list yet for " .. tostring(className) .. " / " .. tostring(spec) .. " (" .. source .. ").",
  }
  end

  local stepSource = order.steps or {}
  local usedAoe = false
  if role == "aoe" and order.aoeSteps and #order.aoeSteps > 0 then
  stepSource = order.aoeSteps
  usedAoe = true
  end

  local built = {}
  local CG = SpellSmasherNS and SpellSmasherNS.CastGuard
  for _, t in ipairs(stepSource) do
  local name = t.name or t.label
  if name and name ~= "" then
  -- Skip pure talent passives - never put them on a cast sequence
  local skip = false
  if CG and CG.IsPassiveSpellName and CG.IsPassiveSpellName(name) then
  skip = true
  end
  if not skip and CG and CG.IsSequenceable and not CG.IsSequenceable(name, t.macro or "", t.spellId or t.spellID, className, spec) then
  skip = true
  end
  -- Classic: drop spells not in the spellbook yet (grey FutureSpell / not learned).
  -- Midnight Set me up stays a full starter (site does 70-90 level-legal lists).
  if not skip and SpellSmasherNS and SpellSmasherNS.IsClassicFlavor and SpellSmasherNS.PlayerKnowsSpell then
    if not SpellSmasherNS.PlayerKnowsSpell(name) then
      skip = true
    end
  end
  if not skip then
  local mac = MacroForStep(t, spec, role)
  if CG and CG.CombatMacroFor then
  mac = CG.CombatMacroFor(name, mac)
  end
  if mac and WantDpsAoeCursor(name, spec, role) and not string.lower(mac):find("@cursor", 1, true) then
  mac = "/cast [nochanneling,@cursor] " .. name
  end
  if mac and mac ~= "" then
  local row = {
  label = name,
  macro = mac,
  mode = t.mode or "priority",
  interval = 3,
  looped = (t.mode or "priority") ~= "once",
  }
  -- Stamp spellId for icons/tooltips (works for other-class packs)
  -- Prefer castable IDs; skip pure-passive stamps (wrong higher-ID collisions)
  local sid = tonumber(t.spellId or t.spellID)
  if sid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(sid) then
  sid = nil
  end
  if not sid and SpellSmasherNS and SpellSmasherNS.SpellIDMap and SpellSmasherNS.SpellIDMap.GetSpellID then
  sid = SpellSmasherNS.SpellIDMap.GetSpellID(name, className, spec)
  end
  if sid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(sid) then
  sid = nil
  end
  if sid then
  row.spellId = sid
  end
  local lr = tonumber(t.loopRepeat)
  if lr and lr >= 2 then
  row.loopRepeat = math.min(99, math.floor(lr))
  end
  built[#built + 1] = row
  end
  end
  end
  end

  local used = {}
  for _, b in ipairs(built) do
  used[NormKey(b.label)] = true
  end

  local extras = {}
  if keepExtras and type(currentSteps) == "table" then
  for _, s in ipairs(currentSteps) do
  local k = NormKey(s.label)
  local covered = used[k] == true
  if not covered then
  for u in pairs(used) do
  if k:find(u, 1, true) or u:find(k, 1, true) then
  covered = true
  break
  end
  end
  end
  if not covered and s.label and Trim(s.label) ~= "" then
  if not (CG and CG.IsPassiveSpellName and CG.IsPassiveSpellName(s.label)) then
  local emac = s.macro or MacroForStep({ name = s.label }, spec, role)
  if CG and CG.CombatMacroFor then
  emac = CG.CombatMacroFor(s.label, emac)
  end
  if emac and emac ~= "" then
  local extra = {
  label = s.label,
  macro = emac,
  mode = s.mode or "priority",
  interval = s.interval or 3,
  looped = (s.mode or "priority") ~= "once",
  }
  local esid = tonumber(s.spellId or s.spellID)
  if esid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(esid) then
  esid = nil
  end
  if not esid and SpellSmasherNS and SpellSmasherNS.SpellIDMap and SpellSmasherNS.SpellIDMap.GetSpellID then
  esid = SpellSmasherNS.SpellIDMap.GetSpellID(s.label, className, spec)
  end
  if esid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(esid) then
  esid = nil
  end
  if esid then
  extra.spellId = esid
  end
  local lr = tonumber(s.loopRepeat)
  if lr and lr >= 2 then
  extra.loopRepeat = math.min(99, math.floor(lr))
  end
  extras[#extras + 1] = extra
  used[k] = true
  end
  end
  end
  end
  end

  -- Preserve per-spell loopRepeat (xN) on template spells that still exist
  local loopByKey = {}
  if type(currentSteps) == "table" then
  for _, s in ipairs(currentSteps) do
  local lr = tonumber(s.loopRepeat)
  if lr and lr >= 2 then
  loopByKey[NormKey(s.label)] = math.min(99, math.floor(lr))
  end
  end
  end
  for _, b in ipairs(built) do
  local lr = loopByKey[NormKey(b.label)]
  if lr then
  b.loopRepeat = lr
  end
  end

  local steps = {}
  for _, b in ipairs(built) do
  steps[#steps + 1] = b
  end
  for _, e in ipairs(extras) do
  steps[#steps + 1] = e
  end

  local heroOut = hero
  if heroOut == "" and order.heroTalent then
  heroOut = tostring(order.heroTalent)
  end
  if heroOut == "" and order.talents and order.talents.hero then
  heroOut = tostring(order.talents.hero)
  end

  return {
  ok = true,
  name = order.name or (className .. " " .. spec),
  description = order.description or "",
  source = order.source or (order.engine and "SpellSmasher Engine" or "Method.gg"),
  patch = order.patch or "",
  talents = order.talents,
  heroTalent = heroOut,
  role = role,
  engine = order.engine and true or false,
  packSource = source,
  usedAoeSteps = usedAoe,
  steps = steps,
  templateCount = #built,
  extrasCount = #extras,
  }
end

local function MakeStepFromBuilt(b, className, spec)
  local label = b.label or b.name or ""
  local macro = b.macro or ""
  className = className or b.className
  spec = spec or b.spec
  -- Never build a step for pure talent passives (no real cast button).
  local CG = SpellSmasherNS and SpellSmasherNS.CastGuard
  if CG then
  if CG.IsPassiveSpellName and CG.IsPassiveSpellName(label) then
  return nil
  end
  if CG.IsSequenceable and not CG.IsSequenceable(label, macro, b.spellId or b.spellID, className, spec) then
  return nil
  end
  if CG.CombatMacroFor then
  local fixed = CG.CombatMacroFor(label, macro)
  if not fixed then
  return nil
  end
  macro = fixed
  end
  end
  local out = {
  label = label,
  macro = macro,
  mode = b.mode or "priority",
  interval = b.interval or 3,
  looped = b.looped ~= false and (b.mode or "priority") ~= "once",
  }
  local sid = tonumber(b.spellId or b.spellID)
  if sid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(sid) then
  sid = nil
  end
  if not sid and SpellSmasherNS and SpellSmasherNS.SpellIDMap and SpellSmasherNS.SpellIDMap.GetSpellID then
  sid = SpellSmasherNS.SpellIDMap.GetSpellID(label, className, spec)
  end
  if sid and CG and CG.IsPassiveSpellId and CG.IsPassiveSpellId(sid) then
  sid = nil
  end
  if sid then
  out.spellId = sid
  end
  local lr = tonumber(b.loopRepeat)
  if lr and lr >= 2 then
  out.loopRepeat = math.min(99, math.floor(lr))
  end
  return out
end

local function NewId()
  return string.format("%s-%04d", tostring(time() or 0), math.random(1000, 9999))
end

--- Build one sequence object. role "st" = Method ST steps; "aoe" = aoeSteps.
--- @param hero optional hero talent path name (Class|Spec|Hero)
function Rebuild.BuildSequenceObject(className, spec, role, characterLevel, packSource, hero)
  local lv
  if SpellSmasherNS and SpellSmasherNS.IsMistsFlavor then
    lv = math.max(1, math.min(90, math.floor(tonumber(characterLevel) or 90)))
  elseif SpellSmasherNS and SpellSmasherNS.IsTbcFlavor then
    lv = math.max(1, math.min(70, math.floor(tonumber(characterLevel) or 70)))
  elseif SpellSmasherNS and SpellSmasherNS.IsClassicFlavor then
    lv = math.max(1, math.min(70, math.floor(tonumber(characterLevel) or 60)))
  else
    lv = math.max(70, math.min(90, math.floor(tonumber(characterLevel) or 90)))
  end
  local source = string.lower(tostring(packSource or "method"))
  local isEngine = (source == "engine" or source == "spellsmasher" or source == "ss")
  hero = Trim(hero)
  if hero == "" then
  hero = Rebuild.DefaultHero(className, spec)
  end
  local packRole = string.lower(tostring(role or "aoe"))
  if packRole ~= "st" then
  packRole = "aoe"
  end

  local result = Rebuild.RebuildSequence(className, spec, {}, {
  keepExtras = false,
  role = packRole == "st" and "st" or "aoe",
  source = isEngine and "engine" or "method",
  hero = hero,
  })

  -- If aoe requested but no aoeSteps authored, fall back to ST steps once.
  if (not result.ok or not result.steps or #result.steps == 0) and packRole == "aoe" then
  result = Rebuild.RebuildSequence(className, spec, {}, {
  keepExtras = false,
  role = "st",
  source = isEngine and "engine" or "method",
  hero = hero,
  })
  packRole = "st"
  end

  local steps = {}
  if result.ok and result.steps then
  for _, s in ipairs(result.steps) do
  local st = MakeStepFromBuilt(s, className, spec)
  if st then
  steps[#steps + 1] = st
  end
  end
  end

  local tag = isEngine and "SpellSmasher" or "Method"
  local heroLabel = hero ~= "" and hero or ""
  local roleTag = packRole == "st" and "ST" or "AoE"
  local name = Trim((spec or "") .. (heroLabel ~= "" and ("  -  " .. heroLabel) or "") .. " " .. tag .. " " .. roleTag)
  name = Trim(name:gsub("%s+", " "))

  local notes = (result.description or (isEngine and "SpellSmasher Engine starter" or "Method-style starter"))
  .. (result.patch and (" (" .. result.patch .. ")") or "")
  .. "  -  level "
  .. tostring(lv)
  .. (isEngine and "  -  SpellSmasher" or "  -  Method")
  if packRole == "st" then
  notes = notes .. "  -  ST steps (boss list)."
  elseif result.usedAoeSteps then
  notes = notes .. "  -  aoeSteps (packs / M+)."
  else
  notes = notes .. "  -  ST steps (no dedicated aoeSteps; used ST)."
  end

  local talentChecklist = ""
  local talentLoadout = ""
  if result.talents then
  if result.talents.recommended then
  talentChecklist = table.concat(result.talents.recommended, ", ")
  end
  if result.talents.loadout and #tostring(result.talents.loadout) >= 40 and lv >= 90 then
  talentLoadout = result.talents.loadout
  end
  end
  -- Method curated CUk when registry has one (placeholder until live snap).
  local talentSource = (talentLoadout ~= "" and #tostring(talentLoadout) >= 40) and "method" or ""
  local talentHeroExact = false
  local talentProxy = false
  if (talentLoadout == "" or #tostring(talentLoadout) < 40) and SpellSmasherMethodLoadouts then
  local r = SpellSmasherMethodLoadouts.GetResult
  and SpellSmasherMethodLoadouts.GetResult(className, spec, hero)
  or nil
  local fromReg = (r and r.loadout) or (SpellSmasherMethodLoadouts.Get and SpellSmasherMethodLoadouts.Get(className, spec, hero))
  if fromReg and #tostring(fromReg) >= 40 then
  talentLoadout = fromReg
  talentSource = (r and r.source) or "method"
  talentHeroExact = r and r.heroExact or false
  talentProxy = r and r.proxy or false
  end
  elseif #tostring(talentLoadout) >= 40 and SpellSmasherMethodLoadouts then
    -- Order had a CUk: classify honesty (proxy vs exact) - never guess from hero alone
    if SpellSmasherMethodLoadouts.ClassifyLoadout then
      local c = SpellSmasherMethodLoadouts.ClassifyLoadout(className, spec, hero, talentLoadout)
      talentSource = c.source or "method"
      talentHeroExact = c.heroExact or false
      talentProxy = c.proxy or false
    elseif SpellSmasherMethodLoadouts.GetResult then
      local r = SpellSmasherMethodLoadouts.GetResult(className, spec, hero)
      if r and r.loadout and r.loadout == talentLoadout then
        talentSource = r.source or "method"
        talentHeroExact = r.heroExact or false
        talentProxy = r.proxy or false
      end
    end
  end

  local heroFinal = hero
  if heroFinal == "" and result.heroTalent then
  heroFinal = tostring(result.heroTalent)
  end
  if heroFinal == "" and result.talents and result.talents.hero then
  heroFinal = tostring(result.talents.hero)
  end

  return {
  name = name,
  className = className,
  spec = spec,
  heroTalent = heroFinal,
  role = packRole == "hybrid" and "hybrid" or packRole,
  characterLevel = lv,
  notes = notes,
  steps = steps,
  talents = talentLoadout ~= "" and talentLoadout or talentChecklist,
  talentChecklist = talentChecklist,
  talentLoadout = talentLoadout,
  talentLoadoutSource = (talentLoadout ~= "" and (talentSource ~= "" and talentSource or "method")) or "",
  talentLoadoutHeroExact = talentHeroExact and true or false,
  talentLoadoutProxy = talentProxy and true or false,
  packSource = isEngine and "engine" or "method",
  usedDedicatedAoe = result.usedAoeSteps and true or false,
  sequentialRepeat = 1,
  priorityRepeat = 1,
  variables = {},
  }, result
end

--- Empty pack for Classic (no Method/Engine gold on this client).
function Rebuild.CreateEmptyPack(className, spec, characterLevel, role)
  local packRole = string.lower(tostring(role or "aoe"))
  if packRole ~= "st" then
    packRole = "aoe"
  end
  local lv = math.max(1, math.floor(tonumber(characterLevel) or 1))
  className = Trim(className)
  spec = Trim(spec)
  local roleTag = packRole == "st" and "ST" or "AoE"
  local name = Trim((spec ~= "" and spec or className) .. " " .. roleTag)
  local seq = {
    name = name,
    className = className,
    spec = spec,
    heroTalent = "",
    role = packRole,
    characterLevel = lv,
    notes = "Empty pack - add spells in Edit, or Import a !SpellSmasher! string.",
    steps = {},
    talents = "",
    talentChecklist = "",
    talentLoadout = "",
    talentLoadoutSource = "",
    talentLoadoutHeroExact = false,
    talentLoadoutProxy = false,
    packSource = "custom",
    usedDedicatedAoe = false,
    sequentialRepeat = 1,
    priorityRepeat = 1,
    variables = {},
    packId = NewId(),
    packMateRole = nil,
  }
  local msg = string.format(
    "Created empty %s pack for %s / %s (level %s). Add spells in Edit, or Import.",
    roleTag,
    className ~= "" and className or "?",
    spec ~= "" and spec or "?",
    tostring(lv)
  )
  return seq, seq.packId, msg
end

--- Create one pack. role "st" or "aoe" (default aoe). Returns seq, packId, message
--- @param hero optional hero talent path
function Rebuild.CreatePack(className, spec, characterLevel, packSource, hero, role)
  local packRole = string.lower(tostring(role or "aoe"))
  if packRole ~= "st" then
    packRole = "aoe"
  end
  -- Classic: use Era/TBC lists when present; empty pack only if no order.
  if SpellSmasherNS and SpellSmasherNS.IsClassicFlavor then
    local seq, res = Rebuild.BuildSequenceObject(className, spec, packRole, characterLevel, "method", hero)
    if seq and res and res.ok and seq.steps and #seq.steps > 0 then
      seq.packId = NewId()
      seq.packMateRole = nil
      seq.role = packRole
      seq.talentLoadout = ""
      seq.talents = ""
      seq.talentLoadoutSource = ""
      seq.heroTalent = ""
      local msg = string.format(
        "Created %s %s pack for %s / %s (level %s) - %d spells you know.",
        (SpellSmasherNS.FlavorShort and SpellSmasherNS.FlavorShort() or "Classic"),
        packRole == "st" and "ST" or "AoE",
        className or "?",
        spec or "?",
        tostring(characterLevel or seq.characterLevel or 1),
        seq.steps and #seq.steps or 0
      )
      return seq, seq.packId, msg
    end
    return Rebuild.CreateEmptyPack(className, spec, characterLevel, packRole)
  end
  local seq, res = Rebuild.BuildSequenceObject(className, spec, packRole, characterLevel, packSource, hero)
  if not res or not res.ok or not seq or not seq.steps or #seq.steps == 0 then
  return nil, nil, (res and res.message) or "No order for this class/spec."
  end

  local packId = NewId()
  seq.packId = packId
  seq.packMateRole = nil
  seq.role = packRole

  -- Method/Engine packs keep the curated Method talent CUk that matches the spell list.
  -- Do NOT overwrite with live character talents here (fresh toons / partial trees look "wrong").
  -- Live snap is only: Edit  >  From me, or Export (push YOUR tree to the site).
  local isEngine = string.lower(tostring(packSource or "")) == "engine"
  or string.lower(tostring(packSource or "")) == "spellsmasher"
  local label = isEngine and "SpellSmasher" or "Method"
  local hasMethodLo = seq.talentLoadout and #tostring(seq.talentLoadout) >= 40
  -- Preserve honesty from BuildSequenceObject (method vs method-proxy)
  if hasMethodLo and (not seq.talentLoadoutSource or seq.talentLoadoutSource == "") then
  seq.talentLoadoutSource = "method"
  end

  local heroNote = (seq.heroTalent and seq.heroTalent ~= "") and ("  -  " .. seq.heroTalent) or ""
  local listName = packRole == "st" and "ST steps" or "aoeSteps"
  local msg = string.format(
  "Created %s %s pack for %s / %s%s (level %s) - %d spells from %s.",
  label,
  packRole == "st" and "ST" or "AoE",
  className,
  spec,
  heroNote,
  tostring(characterLevel or 90),
  seq.steps and #seq.steps or 0,
  listName
  )
  if packRole == "aoe" and seq.usedDedicatedAoe then
  msg = msg .. "\n(dedicated aoeSteps)"
  elseif packRole == "st" then
  msg = msg .. "\n(Method ST list)"
  end
  if hasMethodLo then
    if seq.talentLoadoutProxy or seq.talentLoadoutSource == "method-proxy" then
      msg = msg
        .. "\n|cfff0b429Talent string = shared Method Class|Spec gold (proxy)|r - hero chip spell list is honest;"
        .. " tree is primary gold until Method posts a distinct hero scrape.\n"
        .. "Edit  >  |cffffff00Apply|r spends the package (confirm if proxy) then saves a named loadout if slots free;"
        .. " check hero nodes in N. |cffffff00Mine|r = YOUR live CUk."
    else
      msg = msg
        .. "\n|cff3dd68cTalent string = Method loadout for this list|r - Edit  >  Apply, or Copy  >  Talents  >  Import.\n"
        .. "Your current tree is separate; use |cffffff00Mine|r only if you want YOUR live CUk on the pack."
    end
    if isEngine then
      msg = msg
        .. "\n|cff8b96adEngine pack:|r mash order is SpellSmasher Engine; talent package is still Method family"
        .. " (not a separate Engine CUk). Method packs are never rewritten by Engine."
    end
  else
    msg = msg .. "\n|cfff0b429No Method talent string for this spec yet.|r"
  end

  return seq, packId, msg
end

local function NormalizePackSource(src)
  src = string.lower(tostring(src or "method"))
  if src == "spellsmasher" or src == "ss" then
    return "engine"
  end
  if src == "engine" then
    return "engine"
  end
  return "method"
end

--- Rebuild current sequence from aoeSteps (product default). No pack-mate dual update.
--- Method packs stay MethodOrders; Engine packs stay EngineOrders (no thrash across).
function Rebuild.RebuildInPlace(seq, sequences, options)
  if not seq then
    return false, "No sequence."
  end
  options = options or {}
  -- Lock pack family: never silently swap Method list <-> Engine list.
  local locked = NormalizePackSource(seq.packSource or "method")
  local requested = NormalizePackSource(options.source or locked)
  local source = locked
  if options.forceSource and requested ~= locked then
    source = requested
  elseif requested ~= locked then
    -- Ignore thrash attempt; keep locked family
    source = locked
  end
  -- Honor pack role: ST uses steps, AoE uses aoeSteps.
  local role = string.lower(tostring(options.role or seq.role or "aoe"))
  if role ~= "st" then
    role = "aoe"
  end

  local hero = Trim(options.hero or seq.heroTalent)
  local result = Rebuild.RebuildSequence(seq.className, seq.spec, seq.steps or {}, {
    keepExtras = options.keepExtras == true, -- default: replace with clean aoe list
    role = role,
    source = source,
    hero = hero,
  })
  if not result.ok then
    -- Fallback to ST steps if no aoeSteps for this spec
    if role == "aoe" then
      result = Rebuild.RebuildSequence(seq.className, seq.spec, seq.steps or {}, {
        keepExtras = false,
        role = "st",
        source = source,
        hero = hero,
      })
      role = "st"
    end
  end
  if not result.ok then
    return false, result.message, false
  end

  seq.steps = {}
  for _, s in ipairs(result.steps) do
    local st = MakeStepFromBuilt(s, seq.className, seq.spec)
    if st then
      seq.steps[#seq.steps + 1] = st
    end
  end
  seq.role = role == "st" and "st" or "aoe"
  -- Keep locked family (do not flip method<->engine from result.engine alone)
  seq.packSource = source
  seq.packMateRole = nil
  if result.heroTalent and result.heroTalent ~= "" then
    seq.heroTalent = result.heroTalent
  elseif hero ~= "" then
    seq.heroTalent = hero
  end
  if result.talents and result.talents.recommended then
    seq.talentChecklist = table.concat(result.talents.recommended, ", ")
  end
  -- Always re-attach Method curated CUk on Method/Engine rebuild (matches spell list).
  -- Engine packs still use Method talent package family - never invent Engine CUks.
  if SpellSmasherNS and SpellSmasherNS.AttachMethodLoadout then
    SpellSmasherNS.AttachMethodLoadout(seq, seq.heroTalent or hero)
  elseif result.talents and result.talents.loadout and #tostring(result.talents.loadout) >= 40 then
    seq.talentLoadout = result.talents.loadout
    seq.talents = result.talents.loadout
  end
  if SpellSmasherNS and SpellSmasherNS.StampMethodLoadoutHonesty then
    SpellSmasherNS.StampMethodLoadoutHonesty(seq, seq.heroTalent or hero)
  elseif SpellSmasherMethodLoadouts and SpellSmasherMethodLoadouts.StampSeqHonesty then
    SpellSmasherMethodLoadouts.StampSeqHonesty(seq, seq.heroTalent or hero)
  end
  seq.notes = (source == "engine" and "SpellSmasher rebuild: " or "Method rebuild: ")
    .. (result.name or "")
    .. (role == "st" and "  -  ST steps" or (result.usedAoeSteps and "  -  aoeSteps" or "  -  steps"))

  local preview = {}
  for i = 1, math.min(4, #seq.steps) do
    preview[#preview + 1] = seq.steps[i].label or seq.steps[i].name
  end
  local msg = string.format(
    "Rebuilt %s %s (%d spells): %s",
    source == "engine" and "SpellSmasher Engine" or "Method",
    role == "st" and "ST" or "AoE",
    #seq.steps,
    table.concat(preview, " > ")
  )
  if seq.talentLoadout and #tostring(seq.talentLoadout) >= 40 then
    if seq.talentLoadoutProxy or seq.talentLoadoutSource == "method-proxy" then
      msg = msg .. "  -  Method gold restored (shared Class|Spec; proxy)"
    elseif seq.talentLoadoutHeroExact then
      msg = msg .. "  -  Method CUk restored (hero-exact)"
    else
      msg = msg .. "  -  Method talent string restored"
    end
  end
  return true, msg, false
end

--- Re-stamp Classic Method packs to spells this toon has learned.
--- Call on ding, trainer learn, or SPELLS_CHANGED. Returns packs whose list changed.
function Rebuild.RefreshClassicKnownPacks()
  if not SpellSmasherNS or not SpellSmasherNS.IsClassicFlavor then
    return 0
  end
  local db = SpellSmasherNS.db
  if not db or type(db.sequences) ~= "table" then
    return 0
  end
  local function fingerprint(seq)
    if not seq or type(seq.steps) ~= "table" then
      return ""
    end
    local t = {}
    for i, st in ipairs(seq.steps) do
      t[#t + 1] = tostring((st and st.label) or "")
    end
    return table.concat(t, "\0")
  end
  local n = 0
  for _, seq in ipairs(db.sequences) do
    if type(seq) == "table" then
      local src = string.lower(tostring(seq.packSource or "method"))
      if src == "method" or src == "classic" then
        local before = fingerprint(seq)
        local ok = Rebuild.RebuildInPlace(seq, db.sequences, { role = seq.role })
        if ok and fingerprint(seq) ~= before then
          n = n + 1
        end
      end
    end
  end
  return n
end

--- List-aware talent package (theme "best for my spells" practical port).
--- Uses Method recommended names + curated CUk loadout - does NOT run a full talent-tree sim.
--- @param seq table pack with className, spec, steps
--- @return ok boolean, msg string, info table|nil
function Rebuild.RecommendTalentsForList(seq)
  if not seq then
  return false, "No pack selected."
  end
  local className = seq.className or ""
  local spec = seq.spec or ""
  if className == "" or spec == "" then
  return false, "Pack needs class and spec."
  end

  local hero = Trim(seq.heroTalent)
  local order = GetMethodOrder(className, spec, hero)
  local recommended = {}
  if order and order.talents and order.talents.recommended then
  for _, n in ipairs(order.talents.recommended) do
  recommended[#recommended + 1] = tostring(n)
  end
  end

  -- Spell names on the current list
  local onList = {}
  local onListKeys = {}
  for _, st in ipairs(seq.steps or {}) do
  local lab = Trim(st.label or st.name or "")
  if lab ~= "" then
  onList[#onList + 1] = lab
  onListKeys[NormKey(lab)] = lab
  end
  end
  if #onList == 0 then
  return false, "Add spells first, then match talents to the list."
  end

  -- Intersection: Method recommended talents that appear (or relate) on the spell list
  local matched = {}
  local missingFromList = {}
  for _, rec in ipairs(recommended) do
  local k = NormKey(rec)
  if onListKeys[k] then
  matched[#matched + 1] = onListKeys[k]
  else
  -- soft match: list spell contains rec or rec contains list spell
  local soft = false
  for lk, lab in pairs(onListKeys) do
  if k ~= "" and (lk:find(k, 1, true) or k:find(lk, 1, true)) then
  matched[#matched + 1] = lab
  soft = true
  break
  end
  end
  if not soft then
  missingFromList[#missingFromList + 1] = rec
  end
  end
  end

  -- Always attach curated Method CUk when available (pairs with Method/Engine packs).
  -- Classify via GetResult/Stamp - never guess proxy only because hero ~= "".
  local loadout = ""
  local loadoutProxy = false
  local loadoutExact = false
  if SpellSmasherNS and SpellSmasherNS.AttachMethodLoadout then
    local okA = SpellSmasherNS.AttachMethodLoadout(seq, hero)
    if okA then
      loadout = Trim(seq.talentLoadout or "")
      loadoutProxy = seq.talentLoadoutProxy and true or false
      loadoutExact = seq.talentLoadoutHeroExact and true or false
    end
  end
  if #loadout < 40 and order and order.talents and order.talents.loadout and #tostring(order.talents.loadout) >= 40 then
    loadout = tostring(order.talents.loadout)
    seq.talentLoadout = loadout
    seq.talents = loadout
    if SpellSmasherMethodLoadouts and SpellSmasherMethodLoadouts.ClassifyLoadout then
      local c = SpellSmasherMethodLoadouts.ClassifyLoadout(className, spec, hero, loadout)
      seq.talentLoadoutSource = c.source or "method"
      seq.talentLoadoutProxy = c.proxy and true or false
      seq.talentLoadoutHeroExact = c.heroExact and true or false
      seq.talentLoadoutMatchedKey = c.matchedKey or ""
      loadoutProxy = seq.talentLoadoutProxy
      loadoutExact = seq.talentLoadoutHeroExact
    elseif SpellSmasherNS and SpellSmasherNS.StampMethodLoadoutHonesty then
      SpellSmasherNS.StampMethodLoadoutHonesty(seq, hero)
      loadoutProxy = seq.talentLoadoutProxy and true or false
      loadoutExact = seq.talentLoadoutHeroExact and true or false
    else
      seq.talentLoadoutSource = "method"
    end
  end
  if #loadout < 40 and SpellSmasherNS and SpellSmasherNS.AttachMethodLoadout then
    SpellSmasherNS.AttachMethodLoadout(seq, hero)
    loadout = Trim(seq.talentLoadout or "")
    loadoutProxy = seq.talentLoadoutProxy and true or false
    loadoutExact = seq.talentLoadoutHeroExact and true or false
  end

  if #loadout < 40 and #recommended == 0 then
  return false, "No Method talent package for " .. className .. " / " .. spec .. "."
  end

  if #recommended > 0 then
  seq.talentChecklist = table.concat(recommended, ", ")
  end

  local info = {
  matched = matched,
  missingFromList = missingFromList,
  recommended = recommended,
  hasLoadout = #loadout >= 40,
  variant = order and order.talents and order.talents.variant or nil,
  hero = order and order.talents and order.talents.hero or hero or nil,
  proxy = loadoutProxy,
  heroExact = loadoutExact,
  }

  local msg = "|cff3dd68cBest talents for list|r"
  if info.variant then
  msg = msg .. "  -  " .. tostring(info.variant)
  end
  if info.hasLoadout then
  if loadoutExact then
  msg = msg .. "  -  Method CUk for this hero"
  elseif loadoutProxy then
  msg = msg .. "  -  Method gold (shared Class|Spec; hero tree may match primary)"
  else
  msg = msg .. "  -  Method CUk on pack"
  end
  end
  msg = msg
  .. string.format(
  "\n|cff8b96adList spells in Method package:|r %d  -  |cff8b96adPackage extras (support talents):|r %d",
  #matched,
  #missingFromList
  )
  if #matched > 0 then
  local show = {}
  for i = 1, math.min(6, #matched) do
  show[i] = matched[i]
  end
  msg = msg .. "\n|cff3dd68cOn list:|r " .. table.concat(show, ", ") .. (#matched > 6 and "..." or "")
  end
  msg = msg .. "\n|cffffff00Copy|r  >  Ctrl+C  >  Talents  >  Import to apply the tree in game."

  return true, msg, info
end

SpellSmasherRebuild = Rebuild
return Rebuild
