--[[
  SpellSmasher compile - (c) Tracy Howard / SpellSmasher.

  OOC only:
  once steps  >  one cast each per combat engagement (skipped after fire until
  leave-combat / retarget; secure runtime enforces this)
  sequential groups  >  in order (x sequentialRepeat)
  priority groups  >  priority triangle (1 cast per click; top more often)
  reverse_priority  >  reverse triangle (1 21 321...; same slot weights, different rhythm)

  MakeStepMacrotext:
  optional Easy helpers first (only if On AND fit in 255)
  optional step-local helpers from step.macro (startattack / retarget)
  then ONE combat cast from the sequence
  NEVER multi-GCD Priority windows; NEVER add spells not on the list
]]

local NS = SpellSmasherNS
local Compile = {}
NS.Compile = Compile

local MACROTEXT_MAX = NS.MACROTEXT_MAX
local MAX_FLAT = NS.MAX_FLAT_STEPS
local Trim = NS.Trim
local NormMode = NS.NormMode

--- Locale-safe spell name for /cast macros (Part 9 pilot).
--- Macros cannot cast by spell ID (`/cast spell:123` is not valid).
--- OOC: resolve castable ID > C_Spell.GetSpellName (client locale) > bake into macrotext.
--- EN clients get the same English name; DE/FR/etc. get localized book names.
--- Falls back to the EN label/map key when the client has no name for the ID.
--- @param nameOrId string|number  English label or spell ID
--- @param className string|nil
--- @param spec string|nil
--- @return string
function Compile.LocaleSpellName(nameOrId, className, spec)
  local fallback = Trim(tostring(nameOrId or ""))
  if fallback == "" then
    return ""
  end
  local id = tonumber(nameOrId)
  if not id or id <= 0 then
    if NS.SpellIDMap and NS.SpellIDMap.GetSpellID then
      id = NS.SpellIDMap.GetSpellID(fallback, className, spec)
    end
  end
  if not id or id <= 0 then
    return fallback
  end
  if C_Spell and C_Spell.GetSpellName then
    local ok, n = pcall(C_Spell.GetSpellName, id)
    if ok and type(n) == "string" and Trim(n) ~= "" then
      return Trim(n)
    end
  end
  if C_Spell and C_Spell.GetSpellInfo then
    local ok, info = pcall(C_Spell.GetSpellInfo, id)
    if ok and type(info) == "table" and type(info.name) == "string" and Trim(info.name) ~= "" then
      return Trim(info.name)
    end
  end
  -- Classic Era / TBC: no C_Spell table. Midnight never hits this (C_Spell exists).
  if GetSpellInfo then
    local ok, name = pcall(GetSpellInfo, id)
    if ok and type(name) == "string" and Trim(name) ~= "" then
      return Trim(name)
    end
  end
  return fallback
end

-- Also on NS for CastGuard / UI without Compile load order issues after compile module loads.
NS.LocaleSpellName = Compile.LocaleSpellName

local function IsHelperMacroLine(line)
  local low = string.lower(line or "")
  return low:find("/startattack", 1, true)
  or low:find("targetenemy", 1, true)
  or low:find("/stopmacro", 1, true)
end

--- Split macro text into helper lines vs combat lines (no normalization).
function Compile.ClassifyMacroLines(text)
  local helpers, combat = {}, {}
  text = tostring(text or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
  for line in (text .. "\n"):gmatch("(.-)\n") do
  line = Trim(line)
  if line ~= "" then
  if IsHelperMacroLine(line) then
  helpers[#helpers + 1] = line
  else
  combat[#combat + 1] = line
  end
  end
  end
  return helpers, combat
end

--- Pull unit/mod conditions from a cast line (for preserve-on-rename).
--- @return string|nil e.g. "@player" or "mod:shift,@cursor"
function Compile.ExtractCastConditions(line)
  line = tostring(line or "")
  local brackets = line:match("^/%w+%s*%[(.-)%]")
  if not brackets then
  return nil
  end
  local parts = {}
  for piece in (brackets .. ","):gmatch("([^,]+),") do
  piece = Trim(piece)
  local pl = piece:lower()
  if pl ~= "" and pl ~= "nochanneling" then
  parts[#parts + 1] = piece
  end
  end
  if #parts == 0 then
  return nil
  end
  return table.concat(parts, ",")
end

--- Build /cast line with nochanneling + optional unit/mod conditions.
--- className/spec: optional - localize spell token for non-EN clients (Part 9).
function Compile.BuildCastLine(spellName, conditions, className, spec)
  spellName = Trim(tostring(spellName or ""))
  if spellName == "" then
    return ""
  end
  -- Label typos: "Infernal Strike @player"  >  unit + clean name
  local unitFromName = spellName:match("%s+(@%w+)%s*$")
  if unitFromName then
    spellName = Trim(spellName:gsub("%s+@%w+%s*$", ""))
    if not conditions or conditions == "" then
      conditions = unitFromName
    elseif not conditions:lower():find(unitFromName:lower(), 1, true) then
      conditions = conditions .. "," .. unitFromName
    end
  end
  spellName = Compile.LocaleSpellName(spellName, className, spec)
  conditions = Trim(tostring(conditions or ""))
  if spellName:lower() == "infernal strike" and not conditions:lower():find("@player", 1, true) then
    if conditions == "" then
      conditions = "@player"
    else
      conditions = conditions .. ",@player"
    end
  end
  local inner = "nochanneling"
  if conditions ~= "" then
    local c = conditions
    if not c:lower():find("nochanneling", 1, true) then
      inner = "nochanneling," .. c
    else
      inner = c
      if not inner:lower():find("nochanneling", 1, true) then
        inner = "nochanneling," .. inner
      end
    end
  end
  local line = "/cast [" .. inner .. "] " .. spellName
  if #line > NS.MACRO_LINE_MAX then
    line = line:sub(1, NS.MACRO_LINE_MAX)
  end
  return line
end

--- Ensure combat cast has [nochanneling] without stripping mod:/@ unit.
--- Optional className/spec rewrites the spell token to client locale (Part 9 pilot).
function Compile.NormalizeCombatCastLine(line, className, spec)
  line = Trim(tostring(line or ""))
  if line == "" then
    return ""
  end
  line = line:gsub("[{}]", "")
  local low = line:lower()

  if not (low:find("^/cast") or low:find("^/use") or low:find("^/castsequence")) then
    if line:sub(1, 1) ~= "/" then
      return Compile.BuildCastLine(line, nil, className, spec)
    end
  end

  if low:find("^/castsequence") then
    if not low:find("nochanneling", 1, true) then
      local prefix, brackets, rest = line:match("^(/[Cc]astsequence)%s*%[(.-)%]%s*(.+)$")
      if prefix and brackets and rest then
        return prefix .. " [nochanneling," .. brackets .. "] " .. rest
      end
    end
    return line
  end

  if not low:find("^/cast") and not low:find("^/use") then
    return line
  end

  local cmd = low:find("^/use") and "use" or "cast"
  local prefix = line:match("^(/[Cc]ast)") or line:match("^(/[Uu]se)") or ("/" .. cmd)

  local brackets, spell = line:match("^/%w+%s*%[(.-)%]%s*(.+)$")
  if brackets and spell then
    spell = Trim(spell)
    local trail = spell:match("%s+(@%w+)%s*$")
    if trail then
      spell = Trim(spell:gsub("%s+@%w+%s*$", ""))
      if not brackets:lower():find(trail:lower(), 1, true) then
        brackets = brackets .. "," .. trail
      end
    end
    if not brackets:lower():find("nochanneling", 1, true) then
      brackets = "nochanneling," .. brackets
    end
    if spell:lower() == "infernal strike" and not brackets:lower():find("@player", 1, true) then
      brackets = brackets .. ",@player"
    end
    brackets = brackets:gsub("%s*,%s*", ","):gsub(",+", ","):gsub("^,", ""):gsub(",$", "")
    -- Part 9 pilot: /cast uses locale spellbook name (not spell ID - macros cannot cast by ID).
    if cmd == "cast" then
      spell = Compile.LocaleSpellName(spell, className, spec)
    end
    local out = prefix .. " [" .. brackets .. "] " .. spell
    if #out > NS.MACRO_LINE_MAX then
      out = out:sub(1, NS.MACRO_LINE_MAX)
    end
    return out
  end

  local rest = line:match("^/%w+%s+(.+)$")
  if rest and not rest:match("^%[") then
    rest = Trim(rest)
    local trail = rest:match("%s+(@%w+)%s*$")
    local spellTok = rest
    local cond = nil
    if trail then
      spellTok = Trim(rest:gsub("%s+@%w+%s*$", ""))
      cond = trail
    end
    return Compile.BuildCastLine(spellTok, cond, className, spec)
  end

  if #line > NS.MACRO_LINE_MAX then
    line = line:sub(1, NS.MACRO_LINE_MAX)
  end
  return line
end

function Compile.SpellNameFromCastLine(line)
  line = tostring(line or "")
  -- Prefer last combat line if multi-line (helpers + cast)
  local lastCombat = nil
  for sub in (line .. "\n"):gmatch("(.-)\n") do
  sub = Trim(sub)
  if sub ~= "" and not IsHelperMacroLine(sub) then
  lastCombat = sub
  end
  end
  if lastCombat then
  line = lastCombat
  end
  local name = line:match("%]%s*(.+)$") or line:match("^/cast%s+(.+)$") or line
  name = Trim(name:gsub("\n.*", ""))
  return name
end

--- Step  >  macro body for compile: step helpers (if any) + ONE combat cast.
--- Extra combat lines after the first are dropped (no multi-GCD window).
--- className/spec: optional locale rewrite of EN labels (Part 9).
function Compile.StepToCastLine(step, className, spec)
  if type(step) ~= "table" then
    return nil
  end
  local helpers, combat = Compile.ClassifyMacroLines(step.macro or "")
  local castLine = nil
  if #combat > 0 then
    castLine = Compile.NormalizeCombatCastLine(combat[1], className, spec)
  else
    local label = Trim(step.label or step.name or "")
    if label == "" then
      return nil
    end
    label = label:gsub("[\r\n;]", " ")
    if label:find("+", 1, true) then
      label = Trim(label:match("^([^+]+)") or label)
    end
    -- Prefer stamped castable ID > locale name when inventing from label only.
    local sid = tonumber(step.spellId or step.spellID)
    if sid and sid > 0 then
      local loc = Compile.LocaleSpellName(sid, className, spec)
      if loc ~= "" then
        castLine = Compile.BuildCastLine(loc, nil, className, spec)
      end
    end
    if not castLine or castLine == "" then
      castLine = Compile.NormalizeCombatCastLine("/cast " .. label, className, spec)
    end
  end
  if not castLine or castLine == "" then
    return nil
  end
  if #helpers == 0 then
    return castLine
  end
  local parts = {}
  for _, h in ipairs(helpers) do
    parts[#parts + 1] = h
  end
  parts[#parts + 1] = castLine
  return table.concat(parts, "\n")
end

--- Contiguous mode groups (same as site / prior SSM).
function Compile.GetGroups(seq)
  local steps = seq and seq.steps
  if type(steps) ~= "table" then
  return {}
  end
  -- Walk numeric keys in order. Do not use #steps (holes drop the tail).
  local keys = {}
  for k, st in pairs(steps) do
    if type(k) == "number" and type(st) == "table" then
      keys[#keys + 1] = k
    end
  end
  table.sort(keys)
  if #keys == 0 then
    return {}
  end
  local groups = {}
  local i = 1
  while i <= #keys do
  local idx = keys[i]
  local mode = NormMode(steps[idx].mode)
  local startIndex = idx
  local endIndex = idx
  i = i + 1
  while i <= #keys and NormMode(steps[keys[i]].mode) == mode do
    endIndex = keys[i]
    i = i + 1
  end
  local rep = 1
  if mode == "sequential" then
  rep = math.max(1, math.min(99, math.floor(tonumber(seq.sequentialRepeat) or 1)))
  elseif mode == "priority" or mode == "reverse_priority" then
  rep = math.max(1, math.min(99, math.floor(tonumber(seq.priorityRepeat) or 1)))
  end
  groups[#groups + 1] = {
  mode = mode,
  startIndex = startIndex,
  endIndex = endIndex,
  count = endIndex - startIndex + 1,
  rep = rep,
  }
  end
  return groups
end

--- Priority triangle expand:
--- [A,B,C,D]  >  A | A B | A B C | A B C D  as separate clicks (top more often)
function Compile.ExpandPriorityTriangle(actionList)
  local n = #actionList
  if n == 0 then
  return {}
  end
  if n == 1 then
  return { actionList[1] }
  end
  local looplimit = 0
  for x = 1, n do
  looplimit = looplimit + x
  end
  local out = {}
  local limit = 1
  local step = 1
  for _ = 1, looplimit do
  out[#out + 1] = actionList[step]
  if step == limit then
  limit = limit % n + 1
  step = 1
  else
  step = step + 1
  end
  end
  return out
end

--- Reverse Priority expand ("1 21 321 4321"):
--- [A,B,C]  >  A | B A | C B A  as separate clicks (same slot weights as Priority)
function Compile.ExpandReversePriorityTriangle(actionList)
  local n = #actionList
  if n == 0 then
  return {}
  end
  if n == 1 then
  return { actionList[1] }
  end
  local out = {}
  for i = 1, n do
  for j = i, 1, -1 do
  out[#out + 1] = actionList[j]
  end
  end
  return out
end

--- Slot weight for position i (1-based) in an n-slot Priority triangle: appears (n-i+1) times.
function Compile.PrioritySlotWeight(n, i)
  n = tonumber(n) or 0
  i = tonumber(i) or 0
  if n < 1 or i < 1 or i > n then
  return 0
  end
  return n - i + 1
end

--- One mash: try these /cast lines in order. First ready spell fires.
--- lines[1] is the highest weight (4x) and is always kept on the button.
function Compile.PackSkipMacro(lines)
  local cap = MACROTEXT_MAX or 255
  if type(lines) ~= "table" or #lines == 0 then
    return nil
  end
  local function joinLen(t)
    if #t == 0 then
      return 0
    end
    local n = #t - 1
    for i = 1, #t do
      n = n + #t[i]
    end
    return n
  end
  local out = { lines[1] }
  for i = 2, #lines do
    local trial = {}
    for j = 1, #out do
      trial[j] = out[j]
    end
    trial[#trial + 1] = lines[i]
    if joinLen(trial) <= cap then
      out = trial
    end
  end
  return table.concat(out, "\n")
end

function Compile.PriorityTriangleTotal(n)
  n = tonumber(n) or 0
  if n < 1 then
  return 0
  end
  return math.floor(n * (n + 1) / 2)
end

--- OOC preview: stepIndex  >  share of Priority clicks (0-100) for contiguous Priority blocks.
--- Uses the same loopRepeat expansion as CompileFlatSteps.
function Compile.ComputeStepWeightPct(seq)
  local out = {}
  if type(seq) ~= "table" or type(seq.steps) ~= "table" then
  return out
  end
  local steps = seq.steps
  local groups = Compile.GetGroups(seq)
  for _, g in ipairs(groups) do
  if g.mode == "priority" or g.mode == "reverse_priority" then
  local slots = {}
  for si = g.startIndex, g.endIndex do
  local st = steps[si]
  if st then
  local stepRep = math.max(1, math.min(99, math.floor(tonumber(st.loopRepeat) or 1)))
  local lab = Trim(st.label or st.name or "")
  for _ = 1, stepRep do
  slots[#slots + 1] = { index = si, label = lab }
  end
  end
  end
  local n = #slots
  local total = Compile.PriorityTriangleTotal(n)
  if total > 0 then
  local sumByIndex = {}
  for i, slot in ipairs(slots) do
  local w = Compile.PrioritySlotWeight(n, i)
  sumByIndex[slot.index] = (sumByIndex[slot.index] or 0) + w
  end
  for idx, w in pairs(sumByIndex) do
  out[idx] = math.floor((w / total) * 100 + 0.5)
  end
  end
  end
  end
  return out
end

function Compile.SequenceHasSpellLabel(seq, spellName)
  if type(seq) ~= "table" or type(seq.steps) ~= "table" then
  return false
  end
  local want = Trim(spellName):lower()
  if want == "" then
  return false
  end
  for _, st in ipairs(seq.steps) do
  local lab = Trim(st and st.label or ""):lower()
  if lab == want or lab:find(want, 1, true) then
  return true
  end
  local mac = tostring(st and st.macro or ""):lower()
  if mac:find(want, 1, true) then
  return true
  end
  end
  return false
end

function Compile.DefensiveSpellName(seq)
  local className = (seq and Trim(seq.className) ~= "" and Trim(seq.className)) or NS.ActiveClassName()
  local spell = className and NS.DEFENSIVE_BY_CLASS[className]
  if not spell then
  return nil
  end
  if className == "Demon Hunter" then
  spell = "Demon Spikes"
  elseif className == "Warrior" then
  local spec = seq and Trim(seq.spec or "") or ""
  if spec == "Protection" then
  spell = "Shield Block"
  elseif spec == "Fury" or spec == "Arms" then
  spell = "Die by the Sword"
  end
  end
  if Compile.SequenceHasSpellLabel(seq, spell) then
  return nil -- already on list  >  do not also prepend
  end
  return spell
end

--- Easy helpers only (user toggles). No secret pack KeyPress auto-inject.
function Compile.HelperPrefixLines(seq)
  local db = NS.db
  local out = {}
  if not db then
  return out
  end
  if db.helperAutoAttack then
  out[#out + 1] = "/startattack"
  end
  if db.helperRetarget then
  out[#out + 1] = "/targetenemy [noharm][dead]"
  end
  if db.helperDefensive then
  local def = Compile.DefensiveSpellName(seq)
  if def then
  out[#out + 1] = "/cast " .. def
  end
  end
  return out
end

local BACKUP_SCORE = {
  fracture = 50,
  ["soul cleave"] = 40,
  ["spirit bomb"] = 30,
  ["demon spikes"] = 45,
  shear = 50,
  ["chaos strike"] = 50,
  annihilation = 45,
  ["blade dance"] = 40,
  ["death sweep"] = 40,
  consume = 50,
  devour = 40,
}

--- Ready buttons already on this pack. Used as the second /cast on each mash
--- click so a cooldown row still does something.
function Compile.BackupCastLines(seq, exceptName)
  exceptName = string.lower(Trim(exceptName or ""))
  local found = {}
  if type(seq) ~= "table" or type(seq.steps) ~= "table" then
    return found
  end
  for _, st in pairs(seq.steps) do
    if type(st) == "table" then
      local name = Trim(st.label or st.name or "")
      local key = name:lower()
      if key ~= "" and key ~= exceptName and BACKUP_SCORE[key] then
        local raw = Compile.StepToCastLine(st, seq.className, seq.spec)
        if raw and raw ~= "" then
          local _, combat = Compile.ClassifyMacroLines(raw)
          local one = combat and combat[1]
          if one and one ~= "" then
            one = Compile.NormalizeCombatCastLine(one, seq.className, seq.spec)
            local w = (BACKUP_SCORE[key] or 0) + math.max(1, tonumber(st.loopRepeat) or 1) * 5
            found[#found + 1] = { line = one, score = w, key = key }
          end
        end
      end
    end
  end
  table.sort(found, function(a, b)
    return a.score > b.score
  end)
  local out, seen = {}, {}
  for _, it in ipairs(found) do
    if not seen[it.key] then
      seen[it.key] = true
      out[#out + 1] = it.line
    end
  end
  return out
end

--- ONE combat cast + optional helpers (Easy + step-local) if they fit in 255.
--- Combat cast is always reserved first; helpers fill remaining room.
--- Optional stats table: status-only counters (helpersDropped / castClipped). Does not change limits.
function Compile.MakeStepMacrotext(castLine, seq, stats)
  castLine = tostring(castLine or ""):gsub("[{}]", "")
  local className = type(seq) == "table" and seq.className or nil
  local spec = type(seq) == "table" and seq.spec or nil
  local stepHelpers, combatLines = Compile.ClassifyMacroLines(castLine)
  local castBody = ""
  if #combatLines > 0 then
    castBody = Compile.NormalizeCombatCastLine(combatLines[1], className, spec)
  elseif Trim(castLine) ~= "" and not IsHelperMacroLine(castLine) then
    castBody = Compile.NormalizeCombatCastLine(Trim(castLine), className, spec)
  end

  local parts = {}
  local used = 0
  local function tryAdd(line)
    line = Trim(tostring(line or ""))
    if line == "" then
      return true
    end
    local add = (used > 0 and 1 or 0) + #line
    if used + add > MACROTEXT_MAX then
      return false
    end
    parts[#parts + 1] = line
    used = used + add
    return true
  end

  local castLen = #castBody
  local roomForHelpers = MACROTEXT_MAX - castLen
  if castLen > 0 and roomForHelpers > 0 then
    roomForHelpers = roomForHelpers - 1
  end

  local helperUsed = 0
  local function noteHelperDrop()
    if not stats then
      return
    end
    stats.helpersDropped = (stats.helpersDropped or 0) + 1
  end
  local function tryHelper(h)
    h = Trim(tostring(h or ""))
    if h == "" then
      return
    end
    if stats then
      stats.helpersAttempted = (stats.helpersAttempted or 0) + 1
    end
    local add = (helperUsed > 0 and 1 or 0) + #h
    if helperUsed + add > roomForHelpers then
      noteHelperDrop()
      return
    end
    if tryAdd(h) then
      helperUsed = helperUsed + add
    else
      noteHelperDrop()
    end
  end

  -- Easy toggles first, then any helpers authored on the step
  for _, h in ipairs(Compile.HelperPrefixLines(seq)) do
    tryHelper(h)
  end
  for _, h in ipairs(stepHelpers) do
    tryHelper(h)
  end
  if castBody ~= "" then
    if not tryAdd(castBody) then
      if stats then
        stats.castClipped = true
      end
    end
  end

  -- If this row is down, fire a ready spell from the same pack.
  local mainName = string.lower(Compile.SpellNameFromCastLine(castBody) or "")
  for _, extra in ipairs(Compile.BackupCastLines(seq, mainName)) do
    tryAdd(extra)
  end

  if #parts == 0 then
    return "/startattack"
  end
  return table.concat(parts, "\n")
end

--- Chat/status feedback for compile limits (status only - does not change mash).
--- Debounced so leave-combat / toggle recompiles do not flood chat with the same warning.
function Compile.ReportStatus(stats, force)
  if type(stats) ~= "table" then
    return
  end
  local dropped = tonumber(stats.helpersDropped) or 0
  local passives = tonumber(stats.passivesDropped) or 0
  local has = stats.truncated or dropped > 0 or stats.castClipped or passives > 0
  if not has then
    return
  end
  local key = (stats.truncated and "T" or "")
    .. "|"
    .. tostring(dropped)
    .. "|"
    .. (stats.castClipped and "C" or "")
    .. "|"
    .. tostring(passives)
    .. "|"
    .. tostring(stats.total or 0)
  local now = (GetTime and GetTime()) or 0
  if not force
    and Compile._lastStatusKey == key
    and (now - (Compile._lastStatusAt or 0)) < 45
  then
    return
  end
  Compile._lastStatusKey = key
  Compile._lastStatusAt = now

  if stats.truncated then
    NS.Print(
      "|cffff6b7aCompile capped at "
        .. MAX_FLAT
        .. " clicks|r - list would be longer. Mash uses the first "
        .. MAX_FLAT
        .. ". Lower Priority/Seq Repeat."
    )
  end
  if dropped > 0 then
    NS.Print(
      "|cfff0b429Helpers skipped on "
        .. tostring(dropped)
        .. " line(s)|r - not enough room in the "
        .. MACROTEXT_MAX
        .. "-char cast (combat cast kept first). Turn off Easy AA/RT/DEF or shorten step helpers."
    )
  end
  if passives > 0 then
    local sample = ""
    local labs = stats.passivesDroppedLabels
    if type(labs) == "table" and labs[1] then
      local show = {}
      for i = 1, math.min(4, #labs) do
        show[i] = tostring(labs[i])
      end
      sample = " (" .. table.concat(show, ", ") .. (#labs > 4 and "..." or "") .. ")"
    end
    NS.Print(
      "|cfff0b429Dropped "
        .. tostring(passives)
        .. " non-cast step(s)|r - passives/non-castable not on mash"
        .. sample
        .. "."
    )
  end
  if stats.castClipped then
    NS.Print(
      "|cffff6b7aA cast line exceeded "
        .. MACROTEXT_MAX
        .. " chars|r - that click may be incomplete. Shorten the spell macro."
    )
  end
end

--- One-line UI status (no chat). Empty string when nothing to report.
function Compile.StatusLine(stats)
  if type(stats) ~= "table" then
    return ""
  end
  if stats.truncated then
    return "|cffff6b7aCapped at " .. MAX_FLAT .. " clicks.|r Lower Priority/Seq Repeat."
  end
  local dropped = tonumber(stats.helpersDropped) or 0
  if dropped > 0 then
    return "|cfff0b429Helpers skipped ("
      .. tostring(dropped)
      .. "x)|r - "
      .. MACROTEXT_MAX
      .. "-char cast limit."
  end
  local passives = tonumber(stats.passivesDropped) or 0
  if passives > 0 then
    return "|cfff0b429Dropped " .. tostring(passives) .. " non-cast step(s).|r"
  end
  if stats.castClipped then
    return "|cffff6b7aCast line over " .. MACROTEXT_MAX .. " chars.|r"
  end
  return ""
end

--- OOC compile: ONLY spells from seq.steps (+ Easy helpers if On).
function Compile.CompileFlatSteps(seq)
  local empty = { { macrotext = Compile.MakeStepMacrotext("", seq), once = false } }
  if type(seq) ~= "table" or type(seq.steps) ~= "table" or #seq.steps == 0 then
  return empty, {
  once = 0,
  seq = 0,
  pri = 0,
  total = 0,
  priorityMode = "priority-triangle",
  stepWeightPct = {},
  }
  end

  -- Drop passive / non-cast steps for mash only (runtime filter).
  -- Never write filtered steps back onto seq.steps - that mutates SavedVariables
  -- and permanently deletes user/import list rows on recompile.
  local steps = seq.steps
  local droppedPassives = {}
  if NS.CastGuard and NS.CastGuard.FilterSteps then
    -- Pass class|spec so dual IDs (Kill Shot, Ascendance, ...) and passive stamps resolve correctly
    local filtered, dropped = NS.CastGuard.FilterSteps(steps, seq.className, seq.spec)
    if type(dropped) == "table" then
      droppedPassives = dropped
    end
    if type(filtered) == "table" then
      steps = filtered
    end
  end
  local tmpSeq = seq
  if steps ~= seq.steps then
    tmpSeq = {}
    for k, v in pairs(seq) do
      tmpSeq[k] = v
    end
    tmpSeq.steps = steps
  end
  local groups = Compile.GetGroups(tmpSeq)
  local castOrder = {}
  local hasReverse = false
  local hasForward = false
  local stats = {
    once = 0,
    seq = 0,
    pri = 0,
    total = 0,
    labels = {},
    order = {},
    priorityMode = "priority-triangle",
    groupSummary = {},
    truncated = false,
    helpersAttempted = 0,
    helpersDropped = 0,
    castClipped = false,
    passivesDropped = #droppedPassives,
    passivesDroppedLabels = droppedPassives,
    stepWeightPct = Compile.ComputeStepWeightPct(tmpSeq),
  }

  --- Each flat step is { macrotext = string, once = bool } for secure once-per-combat.
  local function pushLine(line, bucket, label)
  if not line then
  return
  end
  if #castOrder >= MAX_FLAT then
  stats.truncated = true
  return
  end
  castOrder[#castOrder + 1] = {
  macrotext = line,
  once = (bucket == "once"),
  }
  if bucket then
  stats[bucket] = (stats[bucket] or 0) + 1
  end
  local key = Trim(label or "")
  if key == "" then
  key = Compile.SpellNameFromCastLine(line) or line
  key = Trim(tostring(key):gsub("\n.*", ""))
  end
  if key ~= "" then
  stats.labels[key] = (stats.labels[key] or 0) + 1
  end
  stats.order[#stats.order + 1] = key ~= "" and key or line
  end

  for _, g in ipairs(groups) do
  stats.groupSummary[#stats.groupSummary + 1] = {
  mode = g.mode,
  count = g.count,
  rep = g.rep,
  from = g.startIndex,
  to = g.endIndex,
  }
  local children = {}
  local childLabels = {}
  for si = g.startIndex, g.endIndex do
  if steps[si] then
  local line = Compile.StepToCastLine(steps[si], seq.className, seq.spec)
  local lab = Trim(steps[si].label or steps[si].name or "")
  local stepRep = math.max(1, math.min(99, math.floor(tonumber(steps[si].loopRepeat) or 1)))
  if line and line ~= "" then
  local times = (g.mode == "once") and 1 or stepRep
  for _ = 1, times do
  children[#children + 1] = line
  childLabels[#childLabels + 1] = lab ~= "" and lab or line
  end
  end
  end
  end

  local rep = math.max(1, math.min(99, math.floor(tonumber(g.rep) or 1)))
  if #children == 0 then
  -- skip
  elseif g.mode == "once" then
  for ci, line in ipairs(children) do
  pushLine(line, "once", childLabels[ci])
  end
  elseif g.mode == "sequential" then
  for _ = 1, rep do
  for ci, line in ipairs(children) do
  pushLine(line, "seq", childLabels[ci])
  end
  end
  elseif g.mode == "reverse_priority" then
  hasReverse = true
  for _ = 1, rep do
  local expanded = Compile.ExpandReversePriorityTriangle(children)
  for _, line in ipairs(expanded) do
  local lab = Compile.SpellNameFromCastLine(line) or line
  pushLine(line, "pri", lab)
  end
  end
  else
  hasForward = true
  for _ = 1, rep do
  local expanded = Compile.ExpandPriorityTriangle(children)
  for _, line in ipairs(expanded) do
  local lab = Compile.SpellNameFromCastLine(line) or line
  pushLine(line, "pri", lab)
  end
  end
  end
  end

  if hasReverse and not hasForward then
  stats.priorityMode = "reverse-priority"
  elseif hasReverse and hasForward then
  stats.priorityMode = "priority+reverse"
  else
  stats.priorityMode = "priority"
  end

  stats.total = #castOrder
  if #castOrder == 0 then
    -- Still report status (e.g. empty after filter) via caller if needed
    return empty, stats
  end

  local macros = {}
  for i = 1, #castOrder do
    local entry = castOrder[i]
    local raw = type(entry) == "table" and entry.macrotext or entry
    if type(entry) == "table" and entry.skipBlock then
      macros[i] = { macrotext = raw, once = false }
    else
      macros[i] = {
        macrotext = Compile.MakeStepMacrotext(raw, seq, stats),
        once = type(entry) == "table" and entry.once and true or false,
      }
    end
  end
  -- Status-only: chat when capped or helpers did not fit (debounced).
  Compile.ReportStatus(stats, false)
  return macros, stats
end

--- Normalize a CompileFlatSteps entry to macrotext string.
function Compile.FlatStepMacrotext(entry)
  if type(entry) == "table" then
  return tostring(entry.macrotext or entry[1] or "")
  end
  return tostring(entry or "")
end

function Compile.FlatStepIsOnce(entry)
  return type(entry) == "table" and entry.once == true
end
