--[[
  SpellSmasher secure executor - (c) Tracy Howard / SpellSmasher.

  OOC: CompileFlatSteps  >  bake spelllist into SecureHandler  >  WrapScript OnClick
  Combat: set macrotext for current step, ALWAYS advance step index.

  ALWAYS-ADVANCE (product, intentional - Part 10):
  - One secure press = arm THIS step's macrotext, then advance the step pointer.
  - Advance does NOT wait for a successful cast (range/CD/GCD/miss).
  - Why: restricted secure env cannot safely gate on cast success; stalling on dead
    clicks is worse for mash than "used the press and moved on".

  Once mode = once per combat engagement (fired-on-click, not on successful cast):
  - When an Once slot is selected on press, it is marked used (secure attributes).
  - Used Once slots are skipped for the rest of the engagement.
  - Reset on leave combat (always). OOC target change also resets openers.
  - Mid-fight retarget does NOT reset Once (insecure cannot SetAttribute in combat).
]]

local NS = SpellSmasherNS
local Secure = {}
NS.Secure = Secure

local BUTTON_NAME = NS.BUTTON_NAME
local RELAY_NAME = NS.RELAY_NAME

Secure.seqButton = nil
-- Live icon: spell name/id for current NEXT step (updated after each mash)
Secure.iconState = { spell = nil, step = 1, tex = nil }

local QUESTION_ICON = 134400 -- INV_Misc_QuestionMark

local function FlatMacro(entry)
  if NS.Compile and NS.Compile.FlatStepMacrotext then
  return NS.Compile.FlatStepMacrotext(entry)
  end
  if type(entry) == "table" then
  return tostring(entry.macrotext or "")
  end
  return tostring(entry or "")
end

local function FlatIsOnce(entry)
  if NS.Compile and NS.Compile.FlatStepIsOnce then
  return NS.Compile.FlatStepIsOnce(entry)
  end
  return type(entry) == "table" and entry.once == true
end

-- Always advance after arming a step (Part 10: click model, not cast-success model).
-- Once: mark used on the press that arms it; skip later. Leave combat clears.
-- NOTE: Restricted secure env has NO UnitGUID / unit APIs - do not call them here.
local SECURE_ONCLICK_WRAP = [[
    local shiftmode = self:GetAttribute("shiftmode") or "ignore"
    if IsShiftKeyDown() and shiftmode == "pause" then
        self:SetAttribute("type", "macro")
        self:SetAttribute("macro", nil)
        self:SetAttribute("unit", nil)
        self:SetAttribute("macrotext", "")
        return
    end
    local list, maxn, stepAttr, firedPrefix
    if IsShiftKeyDown() and shiftmode == "aoe" then
        list = spelllist_aoe
        maxn = max_aoe
        stepAttr = "aoe_step"
        firedPrefix = "once_aoe_"
    else
        list = spelllist_st
        maxn = max_st
        stepAttr = "step"
        firedPrefix = "once_st_"
    end
    maxn = tonumber(maxn) or 0
    if not list or maxn < 1 then
        self:SetAttribute("type", "macro")
        self:SetAttribute("macro", nil)
        self:SetAttribute("unit", nil)
        self:SetAttribute("macrotext", "/startattack")
        return
    end

    local step = self:GetAttribute(stepAttr)
    step = tonumber(step)
    if not step or step < 1 then
        step = 1
    end
    if step > maxn then
        step = 1
    end

    -- Skip Once slots already fired this combat
    local tries = 0
    while tries < maxn do
        local e = list[step]
        local fired = self:GetAttribute(firedPrefix .. step)
        if e and e["once"] == "1" and fired == 1 then
            if step < maxn then
                step = step + 1
            else
                step = 1
            end
            tries = tries + 1
        else
            break
        end
    end
    -- All Once used and nothing else to cast: no-op until leave combat resets
    if tries >= maxn then
        local e0 = list[step]
        local f0 = self:GetAttribute(firedPrefix .. step)
        if e0 and e0["once"] == "1" and f0 == 1 then
            self:SetAttribute("type", "macro")
            self:SetAttribute("macro", nil)
            self:SetAttribute("unit", nil)
            self:SetAttribute("macrotext", "")
            return
        end
    end

    local entry = list[step]
    if entry then
        for k, v in pairs(entry) do
            if k ~= "once" then
                if k == "macrotext" then
                    self:SetAttribute("macro", nil)
                    self:SetAttribute("unit", nil)
                elseif k == "macro" then
                    self:SetAttribute("macrotext", nil)
                    self:SetAttribute("unit", nil)
                end
                self:SetAttribute(k, v)
            end
        end
        -- Once: mark used on this click (arm step), not on spell success.
        if entry["once"] == "1" then
            self:SetAttribute(firedPrefix .. step, 1)
        end
    else
        self:SetAttribute("type", "macro")
        self:SetAttribute("macro", nil)
        self:SetAttribute("unit", nil)
        self:SetAttribute("macrotext", "/startattack")
    end

    -- Always advance; skip already-used Once for the NEXT press
    if step < maxn then
        step = step + 1
    else
        step = 1
    end
    tries = 0
    while tries < maxn do
        local e2 = list[step]
        local f2 = self:GetAttribute(firedPrefix .. step)
        if e2 and e2["once"] == "1" and f2 == 1 then
            if step < maxn then
                step = step + 1
            else
                step = 1
            end
            tries = tries + 1
        else
            break
        end
    end
    self:SetAttribute(stepAttr, step)
]]

local function LongBracket(s)
  s = tostring(s or ""):gsub("[{}]", "")
  local n = 7
  while true do
  local close = "]" .. string.rep("=", n) .. "]"
  if not string.find(s, close, 1, true) then
  return "[" .. string.rep("=", n) .. "[" .. s .. close
  end
  n = n + 1
  if n > 40 then
  s = s:gsub("%]=+%]", "]")
  return "[=======[" .. s .. "]=======]"
  end
  end
end

local function BakeOneList(btn, listName, maxName, macros)
  macros = macros or {}
  local CHUNK = 20
  btn:Execute(listName .. " = newtable()\n" .. maxName .. " = 0\n")
  local i = 1
  local n = #macros
  while i <= n do
  local parts = {}
  local last = math.min(n, i + CHUNK - 1)
  for j = i, last do
  local mt = FlatMacro(macros[j])
  local isOnce = FlatIsOnce(macros[j])
  parts[#parts + 1] = "tinsert(" .. listName .. ", newtable())\n"
  parts[#parts + 1] = "local i = #" .. listName .. "\n"
  parts[#parts + 1] = listName .. '[i]["type"] = ' .. LongBracket("macro") .. "\n"
  parts[#parts + 1] = listName .. '[i]["macrotext"] = ' .. LongBracket(mt) .. "\n"
  if isOnce then
  parts[#parts + 1] = listName .. '[i]["once"] = "1"\n'
  end
  end
  parts[#parts + 1] = maxName .. " = #" .. listName .. "\n"
  btn:Execute(table.concat(parts))
  i = last + 1
  end
end

--- Clear Once engagement flags (must be OOC for full clear, or call from secure).
function Secure.ResetOnceEngagement(reason)
  local btn = Secure.seqButton
  if not btn then
  return
  end
  if InCombatLockdown() then
  NS.pendingOnceReset = true
  return
  end
  NS.pendingOnceReset = false
  local maxn = 0
  if NS.lastBakeInfo and NS.lastBakeInfo.st then
  maxn = math.max(maxn, tonumber(NS.lastBakeInfo.st) or 0)
  end
  if NS.lastBakeInfo and NS.lastBakeInfo.aoe then
  maxn = math.max(maxn, tonumber(NS.lastBakeInfo.aoe) or 0)
  end
  maxn = math.max(maxn, 64)
  for i = 1, maxn do
  btn:SetAttribute("once_st_" .. i, nil)
  btn:SetAttribute("once_aoe_" .. i, nil)
  end
  btn:SetAttribute("once_target", nil)
  btn:SetAttribute("step", 1)
  btn:SetAttribute("aoe_step", 1)
  if Secure.UpdateIcon then
  Secure.UpdateIcon(btn)
  end
end

function Secure.BakeSpellLists(btn, stSteps, aoeSteps)
  if not btn or type(btn.Execute) ~= "function" then
  NS.lastBakeInfo.ok = false
  NS.lastBakeInfo.err = "no Execute"
  return false
  end
  local fallback = { { macrotext = "/startattack", once = false } }
  stSteps = stSteps or fallback
  aoeSteps = aoeSteps or stSteps
  if #stSteps == 0 then
  stSteps = fallback
  end
  if #aoeSteps == 0 then
  aoeSteps = { stSteps[1] }
  end
  local ok, err = pcall(function()
  BakeOneList(btn, "spelllist_st", "max_st", stSteps)
  BakeOneList(btn, "spelllist_aoe", "max_aoe", aoeSteps)
  end)
  NS.lastBakeInfo.st = #stSteps
  NS.lastBakeInfo.aoe = #aoeSteps
  NS.lastBakeInfo.ok = ok and true or false
  NS.lastBakeInfo.err = ok and nil or tostring(err)
  NS.lastBakeInfo.stPreview = FlatMacro(stSteps[1])
  NS.lastBakeInfo.stSteps = stSteps
  NS.lastBakeInfo.aoeSteps = aoeSteps
  return ok
end

function Secure.InstallOnClickWrap(btn)
  if not btn or type(btn.WrapScript) ~= "function" then
  NS.lastBakeInfo.err = "no WrapScript"
  return false
  end
  if btn._ssmSecureWrapped and type(SecureHandlerUnwrapScript) == "function" then
  local uok = pcall(SecureHandlerUnwrapScript, btn, "OnClick")
  if uok then
  btn._ssmSecureWrapped = false
  else
  NS.lastBakeInfo.err = "UnwrapScript failed - /reload"
  return btn._ssmSecureWrapped and true or false
  end
  end
  if btn._ssmSecureWrapped then
  return true
  end
  local ok, err = pcall(function()
  btn:WrapScript(btn, "OnClick", SECURE_ONCLICK_WRAP)
  end)
  if not ok then
  NS.lastBakeInfo.err = "WrapScript: " .. tostring(err)
  return false
  end
  btn._ssmSecureWrapped = true
  return true
end

function Secure.EnsureKeyDownRelay()
  if InCombatLockdown() then
  return BUTTON_NAME
  end
  local useDown = C_CVar and C_CVar.GetCVar and C_CVar.GetCVar("ActionButtonUseKeyDown") == "1"
  if not useDown then
  return BUTTON_NAME
  end
  local relay = _G[RELAY_NAME]
  if not relay then
  relay = CreateFrame("Button", RELAY_NAME, nil, "SecureActionButtonTemplate")
  relay.ssmKeyDownRelay = true
  relay:RegisterForClicks("AnyDown")
  relay:SetAttribute("useOnKeyDown", true)
  end
  if relay.ssmKeyDownRelay and Secure.seqButton then
  relay:SetAttribute("type", "click")
  relay:SetAttribute("clickbutton", Secure.seqButton)
  relay:SetAttribute("useOnKeyDown", true)
  end
  return RELAY_NAME
end

function Secure.GetBindingCommand()
  return "CLICK " .. Secure.EnsureKeyDownRelay() .. ":LeftButton"
end

-- Action-bar style number keys (main bar 1-0) for one-click bind UI
Secure.ACTION_BAR_KEYS = { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }
Secure.MACRO_NAME = "SpellSmasher"

function Secure.GetBoundKey()
  for _, cmd in ipairs({
  "CLICK " .. BUTTON_NAME .. ":LeftButton",
  "CLICK " .. RELAY_NAME .. ":LeftButton",
  Secure.GetBindingCommand(),
  }) do
  local k = GetBindingKey and GetBindingKey(cmd)
  if k and k ~= "" then
  return k
  end
  end
  return nil
end

--- Main bar: key "1" > slot 1 ... key "0" > slot 10
function Secure.KeyToActionSlot(key)
  key = tostring(key or "")
  if key == "0" then
  return 10
  end
  local n = tonumber(key)
  if n and n >= 1 and n <= 9 then
  return n
  end
  return nil
end

local function ClearMashBindings()
  for _, cmd in ipairs({
  "CLICK " .. BUTTON_NAME .. ":LeftButton",
  "CLICK " .. RELAY_NAME .. ":LeftButton",
  }) do
  while true do
  local k = GetBindingKey and GetBindingKey(cmd)
  if not k or k == "" then
  break
  end
  SetBinding(k, nil)
  end
  end
end

--- QUARANTINED (Part 3 / Midnight): character mash macro factory.
--- Old path created a "SpellSmasher" macro for PlaceAction on bars.
--- That taints MultiBar SetShown/SetAttribute. Mash is keybind-only now
--- (CLICK SSMSeqButton_KD). Never CreateMacro/EditMacro from this path.
function Secure.EnsureMashMacro()
  return 0
end

--- True when the player has something on the cursor (spell/item/macro/...).
--- Used only by ClearMashFromSlot so we never steal a mid-drag PlaceAction.
local function CursorBusy()
  if GetCursorInfo then
    local t = GetCursorInfo()
    if t ~= nil then
      return true
    end
  end
  if CursorHasSpell and CursorHasSpell() then
    return true
  end
  if CursorHasItem and CursorHasItem() then
    return true
  end
  if CursorHasMacro and CursorHasMacro() then
    return true
  end
  return false
end

--- QUARANTINED (Part 3 / Midnight): never PlaceAction mash macro on bars.
--- Kept as a hard no-op so /run or old docs cannot re-enable the footgun.
--- Use Mash key 1-0 (BindMashToKey) instead.
function Secure.PlaceMashMacroOnSlot(_slot)
  return false, "Bar placement disabled (Midnight). Use Mash key 1-0 (keybind only)."
end

--- Legacy cleanup only: if an older build left our macro on a saved bar slot,
--- pick it off OOC. Does not create or place macros.
function Secure.ClearMashFromSlot(slot)
  slot = tonumber(slot) or (NS.db and NS.db.barSlot)
  if not slot or InCombatLockdown() then
    return
  end
  if CursorBusy() then
    return
  end
  local aType, aID = GetActionInfo(slot)
  if aType == "macro" and aID then
    local mname = GetMacroInfo(aID)
    if mname == Secure.MACRO_NAME then
      PickupAction(slot)
      ClearCursor()
    end
  end
end

--- Walk known action buttons (retail bar names).
local function ForEachActionButton(fn)
  local prefixes = {
  "ActionButton",
  "MultiBarBottomLeftButton",
  "MultiBarBottomRightButton",
  "MultiBarLeftButton",
  "MultiBarRightButton",
  "MultiBar5Button",
  "MultiBar6Button",
  "MultiBar7Button",
  }
  for _, prefix in ipairs(prefixes) do
  for i = 1, 12 do
  local b = _G[prefix .. i]
  if b then
  fn(b)
  end
  end
  end
end

function Secure.ActionButtonIsMash(btn)
  if not btn then
  return false
  end
  local action = btn.action
  if (not action or action == 0) and btn.GetAttribute then
  action = btn:GetAttribute("action")
  end
  if not action or action == 0 then
  return false
  end
  local aType, aID = GetActionInfo(action)
  if aType ~= "macro" then
  return false
  end
  local mname = aID and GetMacroInfo(aID)
  return mname == Secure.MACRO_NAME
end

--- Bar live-icon markers: fully disabled. Do not walk Blizzard action buttons,
--- do not CreateTexture on them, do not EditMacro just to "refresh" art.
--- (Walking + parenting caused MultiBar*:SetShown blocked spam.)
Secure._mashMarkers = Secure._mashMarkers or {}

local function HideAllMashMarkers()
  for btn, m in pairs(Secure._mashMarkers) do
    if m then
      pcall(function()
        m:Hide()
        m:SetParent(nil)
      end)
    end
    Secure._mashMarkers[btn] = nil
  end
end

function Secure.PaintMashActionButtons()
  -- no-op (never touch action bar frames)
end

function Secure.WarmMashOverlays()
  HideAllMashMarkers()
end

function Secure.SchedulePaintMashActionButtons()
  -- no-op
end

function Secure.SyncFloatingVisibility()
  local db = NS.db
  local btn = Secure.seqButton
  if not db or not btn then
  return
  end
  -- Prefer action bar: hide floating when barSlot set and hideFloatingWhenBar
  local hideForBar = db.hideFloatingWhenBar ~= false and db.barSlot ~= nil
  if hideForBar then
  db.hidden = true
  end
  -- ApplyLayout respects db.hidden
  if not InCombatLockdown() then
  Secure.ApplyLayout()
  end
end

--- Bind mash to key 1-0 via CLICK only.
--- NEVER PlaceAction / PickupMacro onto the action bar - that taints MultiBar
--- SetShown/SetAttribute on Midnight (chat flood in screenshots).
--- Floating SSM button shows the next spell; key fires the secure runner.
--- @return boolean ok, string message
function Secure.BindMashToKey(key)
  key = tostring(key or "")
  if key == "" then
    return false, "No key."
  end
  if InCombatLockdown() then
    return false, "Can't change keybinds in combat."
  end
  if not SetBinding then
    return false, "Binding API unavailable."
  end
  if not Secure.KeyToActionSlot(key) then
    return false, "Only keys 1-0."
  end

  -- If an older build put our macro on a bar slot, clear it once (OOC).
  if NS.db and NS.db.barSlot then
    Secure.ClearMashFromSlot(NS.db.barSlot)
  end

  local conflict = GetBindingAction and GetBindingAction(key) or ""
  ClearMashBindings()

  local target = Secure.EnsureKeyDownRelay()
  local cmd = "CLICK " .. target .. ":LeftButton"
  local ok = SetBinding(key, cmd)
  if not ok then
    return false, "Could not bind key " .. key
  end

  local bindSet = GetCurrentBindingSet and GetCurrentBindingSet() or 1
  if SaveBindings then
    SaveBindings(bindSet)
  end

  if NS.db then
    NS.db.barSlot = nil -- not on action bar (taint-safe)
    NS.db.barKey = key
    NS.db.hidden = false -- keep floating button for next-spell icon
    NS.db.hideFloatingWhenBar = false
  end
  Secure.SyncFloatingVisibility()
  if Secure.seqButton then
    Secure.UpdateIcon(Secure.seqButton)
  end

  local msg = "Mash key |cff3dd68c"
    .. key
    .. "|r bound (no bar macro - avoids Midnight bar taint). Floating SSM button shows list opener icon (static - not live next-step)."
  if conflict and conflict ~= "" and conflict ~= cmd then
    msg = msg .. "  |cfff0b429(key was " .. conflict .. ")|r"
  end
  return true, msg
end

function Secure.UnbindMashKey()
  if InCombatLockdown() then
    return false, "Can't change keybinds in combat."
  end
  local had = Secure.GetBoundKey()
  local slot = NS.db and NS.db.barSlot
  ClearMashBindings()
  if slot then
    Secure.ClearMashFromSlot(slot)
  end
  local bindSet = GetCurrentBindingSet and GetCurrentBindingSet() or 1
  if SaveBindings then
    SaveBindings(bindSet)
  end
  if NS.db then
    NS.db.barSlot = nil
    NS.db.barKey = nil
    NS.db.hidden = false
  end
  Secure.SyncFloatingVisibility()
  if had or slot then
    return true, "Cleared mash key" .. (had and (" (key " .. had .. ")") or "") .. "."
  end
  return true, "No mash keybind was set."
end

-- -- Live spell icon (show the NEXT mash spell) ----------

--- Pull spell/item display name from a baked macrotext line.
function Secure.SpellNameFromMacro(macrotext)
  macrotext = tostring(macrotext or "")
  -- Prefer last non-helper combat line
  local lastCast
  for line in (macrotext .. "\n"):gmatch("(.-)\n") do
  line = NS.Trim(line)
  local low = line:lower()
  if line ~= ""
  and not low:find("/startattack", 1, true)
  and not low:find("targetenemy", 1, true)
  and not low:find("/stopmacro", 1, true)
  then
  if low:find("^/cast") or low:find("^/use") or low:find("^/castsequence") then
  lastCast = line
  end
  end
  end
  if not lastCast then
  return nil
  end
  -- /cast [conds] Spell Name  or  /cast Spell Name
  local name = lastCast:match("%]%s*(.+)$") or lastCast:match("^/%w+%s+(.+)$")
  if not name then
  return nil
  end
  name = NS.Trim(name)
  -- castsequence is "reset=... spell, spell" - take first spell token
  if lastCast:lower():find("castsequence", 1, true) then
  name = name:match("([^,;]+)") or name
  name = name:gsub("^reset=%S+%s*", "")
  name = NS.Trim(name)
  end
  if name == "" then
  return nil
  end
  return name
end

--- Resolve display icon fileID for a spell name or id.
function Secure.GetSpellIcon(spellName)
  local icon = Secure.ResolveSpell(spellName)
  return icon and icon.iconID or nil
end

--- Full resolve: icon + spellID + display name (for list icons + GameTooltip:SetSpellByID).
--- spellIdent: number spellId, or string name (localized or English if client knows it).
--- opts: optional { className=, spec= } for dual-name map lookups (Kill Shot, etc.).
function Secure.ResolveSpell(spellIdent, opts)
  if spellIdent == nil or spellIdent == "" then
  return nil
  end
  opts = opts or {}
  local className = opts.className
  local spec = opts.spec
  local idNum = tonumber(spellIdent)
  -- Map English pack labels -> castable spellID first (name lookup is flaky cross-class)
  if not idNum and type(spellIdent) == "string" and NS.SpellIDMap and NS.SpellIDMap.GetSpellID then
  idNum = NS.SpellIDMap.GetSpellID(spellIdent, className, spec)
  end
  local key = idNum or spellIdent

  local out = { iconID = nil, spellID = idNum, name = type(spellIdent) == "string" and spellIdent or nil }

  if C_Spell then
  if C_Spell.GetSpellInfo then
  -- pcall: secret/forbidden tables under taint throw on Midnight
  local okInfo, info = pcall(C_Spell.GetSpellInfo, key)
  if okInfo and type(info) == "table" then
  out.iconID = info.iconID or info.originalIconID or out.iconID
  out.spellID = info.spellID or out.spellID
  out.name = info.name or out.name
  out.castTime = info.castTime
  out.minRange = info.minRange
  out.maxRange = info.maxRange
  end
  end
  if not out.iconID and C_Spell.GetSpellTexture then
  -- Returns fileID(s); first non-nil wins
  local okTex, a, b, c = pcall(C_Spell.GetSpellTexture, key)
  if okTex then
  out.iconID = a or b or c
  end
  end
  if not out.spellID and C_Spell.GetSpellIDForSpellIdentifier then
  local okSid, sid = pcall(C_Spell.GetSpellIDForSpellIdentifier, key)
  if okSid and sid then
  out.spellID = sid
  end
  end
  end

  if not out.iconID and GetSpellTexture then
  local okG, tex = pcall(GetSpellTexture, key)
  if okG then
  out.iconID = tex
  end
  end

  -- Catalog texture path fallback (Interface\Icons\slug) - class|spec aware
  if not out.iconID and type(spellIdent) == "string" and NS.SpellIDMap and NS.SpellIDMap.GetTexture then
  local tex, mid = NS.SpellIDMap.GetTexture(spellIdent, className, spec)
  if tex then
  out.iconID = tex
  out.spellID = out.spellID or mid
  end
  end

  -- Item fallback (rare /use lines)
  if not out.iconID and C_Item and C_Item.GetItemIconByID and idNum then
  out.iconID = C_Item.GetItemIconByID(idNum)
  end

  if not out.iconID and not out.spellID and not out.name then
  return nil
  end
  return out
end

--- Resolve step label + macro into icon/spell for UI rows.
--- Prefer stamped spellId, then SpellIDMap (name->id, class/spec-aware), then live C_Spell.
--- opts: optional { className=, spec= } from the pack (needed for dual IDs like Kill Shot).
function Secure.ResolveStepSpell(step, opts)
  if type(step) ~= "table" then
  return nil
  end
  opts = opts or {}
  local className = opts.className or step.className
  local spec = opts.spec or step.spec
  local nameGuess = NS.Trim(step.label or step.name or "")
  if nameGuess:find("/", 1, true) then
  nameGuess = NS.Trim(nameGuess:match("^([^/]+)") or nameGuess)
  end
  if nameGuess:find("+", 1, true) then
  nameGuess = NS.Trim(nameGuess:match("^([^+]+)") or nameGuess)
  end

  local r
  local mapOpts = { className = className, spec = spec }
  -- Spec-aware catalog ID wins over a stamped generic collision (e.g. Kill Shot SV vs BM/MM)
  local mapIdPreferred = nil
  if nameGuess ~= "" and NS.SpellIDMap and NS.SpellIDMap.GetSpellID then
  mapIdPreferred = NS.SpellIDMap.GetSpellID(nameGuess, className, spec)
  end
  local stampedRaw = tonumber(step.spellId or step.spellID)
  -- Skip passive stamps when a castable map ID exists
  if stampedRaw and NS.CastGuard and NS.CastGuard.IsPassiveSpellId and NS.CastGuard.IsPassiveSpellId(stampedRaw) then
  stampedRaw = nil
  end
  local stamped = mapIdPreferred or stampedRaw
  if stamped then
  r = Secure.ResolveSpell(stamped, mapOpts)
  end

  -- Catalog map (English pack labels) - reliable icons for other-class packs
  if (not r or not r.iconID) and nameGuess ~= "" and NS.SpellIDMap then
  local mapId = mapIdPreferred or (NS.SpellIDMap.GetSpellID and NS.SpellIDMap.GetSpellID(nameGuess, className, spec))
  if mapId then
  r = Secure.ResolveSpell(mapId, mapOpts) or r
  if r then
  r.spellID = r.spellID or mapId
  end
  end
  if (not r or not r.iconID) and NS.SpellIDMap.GetTexture then
  local tex, mid = NS.SpellIDMap.GetTexture(nameGuess, className, spec)
  if tex then
  r = r or {}
  r.iconID = tex
  r.spellID = r.spellID or mid
  r.name = r.name or nameGuess
  end
  end
  end

  if (not r or not r.iconID) and nameGuess ~= "" then
  r = Secure.ResolveSpell(nameGuess, mapOpts) or r
  end
  if (not r or not r.iconID) and step.macro and step.macro ~= "" then
  local fromMac = Secure.SpellNameFromMacro(step.macro)
  if fromMac then
  if NS.SpellIDMap and NS.SpellIDMap.GetSpellID then
  local mid = NS.SpellIDMap.GetSpellID(fromMac, className, spec)
  if mid then
  r = Secure.ResolveSpell(mid, mapOpts) or r
  end
  end
  if not r or not r.iconID then
  local r2 = Secure.ResolveSpell(fromMac, mapOpts)
  if r2 then
  r = r2
  end
  end
  end
  end
  return r
end

--- True if value is a Midnight "secret number" (cannot pass to Cooldown:SetCooldown while tainted).
local function IsSecretValue(v)
  if v == nil then
  return false
  end
  if issecretvalue then
  local ok, r = pcall(issecretvalue, v)
  if ok and r then
  return true
  end
  end
  return false
end

--- Safe numeric cooldown times only (never return secrets). Used for thresholds.
--- All live CD queries pcall'd - secret tables error under taint on Midnight.
function Secure.GetSpellCooldownTimes(spellName)
  if not spellName or spellName == "" then
    return 0, 0
  end
  local function pick(info)
    if type(info) ~= "table" then
      return nil, nil
    end
    local start = info.startTime or info.start or 0
    local dur = info.duration or 0
    if IsSecretValue(start) or IsSecretValue(dur) then
      return nil, nil
    end
    if start and dur and dur > 0 then
      return start, dur
    end
    return nil, nil
  end
  if C_Spell and C_Spell.GetSpellCooldown then
    local ok, info = pcall(C_Spell.GetSpellCooldown, spellName)
    if ok then
      local s, d = pick(info)
      if s then
        return s, d
      end
    end
  end
  if C_Spell and C_Spell.GetSpellCooldownInfo then
    local ok, info = pcall(C_Spell.GetSpellCooldownInfo, spellName)
    if ok then
      local s, d = pick(info)
      if s then
        return s, d
      end
    end
  end
  if GetSpellCooldown then
    local ok, start, dur = pcall(GetSpellCooldown, spellName)
    if ok and not IsSecretValue(start) and not IsSecretValue(dur) and start and dur and dur > 0 then
      return start, dur
    end
  end
  return 0, 0
end

--- Cooldown swipe on the floating mash button.
--- Midnight: DISABLED. Calling GetSpellCooldownDuration / SetCooldownFromDurationObject
--- from addon code during combat/cast has been attributed as SpellSmasher taint on
--- OverlayPlayerCastingBarFrame (secret barType). Icon texture only until Blizzard
--- gives a fully isolated path. Call Clear so the frame never shows a stale swipe.
function Secure.ApplySpellCooldown(cd, spellName)
  if not cd then
    return
  end
  if cd.Clear then
    pcall(cd.Clear, cd)
  end
  if cd.Hide then
    pcall(cd.Hide, cd)
  end
end

--- Floating button icon = STATIC opener art only (4.3.33+).
--- Never live-track mash step / next cast while playing - continuous poll +
--- GetAttribute/C_Spell during world casts was attributed as SpellSmasher taint
--- on OverlayPlayerCastingBarFrame (GetTypeInfo / secret barType), including
--- ~1 min into casting after a clean Apply.
--- Paint only from safe OOC call sites: recompile, leave combat / Once reset,
--- bind/show, CreateButton, ResumeIconPoll after talent Apply.
--- No HookScript on the secure mash button. No cooldown APIs. No Blizzard bars.
function Secure.PauseIconPoll(reason)
  Secure._iconPollPaused = true
  Secure._iconPollPauseReason = tostring(reason or "pause")
  -- Kill any legacy ticker from older sessions (pre-4.3.33).
  if Secure._iconTicker then
    pcall(function()
      Secure._iconTicker:Cancel()
    end)
    Secure._iconTicker = nil
  end
end

function Secure.ResumeIconPoll()
  Secure._iconPollPaused = false
  Secure._iconPollPauseReason = nil
  if not (InCombatLockdown and InCombatLockdown()) and Secure.seqButton then
    Secure.UpdateIcon(Secure.seqButton)
  end
end

function Secure.IsIconPollPaused()
  return Secure._iconPollPaused and true or false
end

function Secure.UpdateIcon(btn)
  btn = btn or Secure.seqButton
  if not btn or not btn.icon then
    return
  end
  if Secure._iconPollPaused then
    return
  end
  -- Never paint in combat (lockdown or not - stay off cast-bar stacks).
  if InCombatLockdown and InCombatLockdown() then
    return
  end
  -- Skip while player is casting/channeling (pcall - secrets can error under taint).
  local busy = false
  pcall(function()
    if UnitCastingInfo and select(1, UnitCastingInfo("player")) ~= nil then
      busy = true
    end
    if not busy and UnitChannelInfo and select(1, UnitChannelInfo("player")) ~= nil then
      busy = true
    end
  end)
  if busy then
    return
  end
  local ok, err = pcall(Secure._UpdateIconInner, btn)
  if not ok and NS.db and NS.db.debugTaint then
    NS.Print("|cffff6b7aUpdateIcon:|r " .. tostring(err))
  end
end

--- Static opener: first baked ST step only. No GetAttribute on the secure button,
--- no live step / Shift-AoE tracking (those needed a poll that taints cast UI).
function Secure._UpdateIconInner(btn)
  local bi = NS.lastBakeInfo
  local steps = bi and bi.stSteps
  local maxn = steps and #steps or 0
  local step = 1

  local macro = ""
  if maxn > 0 and steps[step] then
    macro = FlatMacro(steps[step])
  end

  local spell = Secure.SpellNameFromMacro(macro)
  local tex = nil
  if spell then
    local okTex, t = pcall(Secure.GetSpellIcon, spell)
    if okTex then
      tex = t
    end
  end
  if not tex then
    tex = QUESTION_ICON
  end

  -- Only our textures/fonts - never Blizzard action buttons or cast bar.
  btn.icon:SetTexture(tex)
  if btn.icon:GetNumPoints() == 0 then
    btn.icon:SetAllPoints()
  end
  btn.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  btn.icon:Show()

  if btn.spellLabel then
    if spell and spell ~= "" then
      local short = spell
      if #short > 11 then
        short = short:sub(1, 10) .. "..."
      end
      btn.spellLabel:SetText(short)
      btn.spellLabel:Show()
    else
      btn.spellLabel:SetText("SSM")
      btn.spellLabel:Show()
    end
  end

  if btn.label then
    if spell and tex and tex ~= QUESTION_ICON then
      btn.label:Hide()
    else
      btn.label:Show()
    end
  end

  -- Never call cooldown APIs (ApplySpellCooldown is a no-op clear).

  Secure.iconState.spell = spell
  Secure.iconState.step = step
  Secure.iconState.tex = tex
  Secure.iconState.total = maxn
  Secure.iconState.aoe = false
  Secure.iconState.static = true
end

function Secure.ScheduleUpdateIcon(btn)
  -- Legacy no-op: live pending paints removed with the icon ticker (4.3.33).
  Secure._pendingIconBtn = nil
end

--- Ensure no continuous icon poll. One OOC paint of static opener if allowed.
function Secure.InstallIconHooks(btn)
  if not btn then
    return
  end
  btn._ssmIconHooked = true

  -- ZERO HookScripts on the secure mash button.
  -- ZERO continuous ticker (removed 4.3.33 - cast-bar / interface-action taint).
  HideAllMashMarkers()
  Secure._markersCleared = true

  if Secure._iconTicker then
    pcall(function()
      Secure._iconTicker:Cancel()
    end)
    Secure._iconTicker = nil
  end

  if not (InCombatLockdown and InCombatLockdown()) then
    Secure.UpdateIcon(btn)
  end
end

function Secure.Recompile()
  local btn = Secure.seqButton
  local db = NS.db
  if not btn or not db then
  return
  end
  if InCombatLockdown() then
  NS.pendingRecompile = true
  return
  end
  NS.pendingRecompile = false

  local seqs = db.sequences or {}
  local st = seqs[db.activeIndex or 1] or seqs[1]
  -- One-button product: same compiled list for mash + optional Shift list.
  -- No separate ST/AoE packs. aoeIndex ignored unless user sets a second sequence later.
  local aoe = st
  if db.aoeIndex and seqs[db.aoeIndex] and db.aoeIndex ~= (db.activeIndex or 1) then
  aoe = seqs[db.aoeIndex]
  end

  -- Defensive helper auto-off if that spell is already a sequence step
  if db.helperDefensive and st then
  local className = NS.Trim(st.className or "") ~= "" and NS.Trim(st.className) or NS.ActiveClassName()
  local base = className and NS.DEFENSIVE_BY_CLASS[className]
  if base and NS.Compile.SequenceHasSpellLabel(st, base) then
  db.helperDefensive = false
  NS.Print("|cfff0b429Defensive helper Off|r - " .. base .. " is already on the sequence.")
  end
  end

  local stSteps, stStats = NS.Compile.CompileFlatSteps(st)
  local aoeSteps, aoeStats = NS.Compile.CompileFlatSteps(aoe)
  local fallback = { { macrotext = "/startattack", once = false } }
  stSteps = stSteps or fallback
  aoeSteps = aoeSteps or stSteps
  if #stSteps == 0 then
  stSteps = fallback
  end
  if #aoeSteps == 0 then
  aoeSteps = stSteps
  end
  NS.lastBakeInfo.compile = stStats
  NS.lastBakeInfo.order = stStats and stStats.order or {}
  NS.lastBakeInfo.groupSummary = stStats and stStats.groupSummary or {}

  local baked = Secure.BakeSpellLists(btn, stSteps, aoeSteps)
  local wrapped = Secure.InstallOnClickWrap(btn)
  Secure.EnsureKeyDownRelay()
  -- Mash is keybind-only. EnsureMashMacro / PlaceMashMacroOnSlot are quarantined no-ops.

  local first = FlatMacro(stSteps[1])
  if first == "" then
  first = "/startattack"
  end
  btn:SetAttribute("type", "macro")
  btn:SetAttribute("macro", nil)
  btn:SetAttribute("unit", nil)
  btn:SetAttribute("macrotext", first)
  btn:SetAttribute("step", 1)
  btn:SetAttribute("aoe_step", 1)
  -- Fresh compile = new Once engagement
  Secure.ResetOnceEngagement("recompile")
  btn:SetAttribute("useOnKeyDown", false)
  local sm = tostring(db.shiftMode or "ignore"):lower()
  if sm ~= "aoe" and sm ~= "ignore" and sm ~= "pause" then
  sm = "ignore"
  end
  btn:SetAttribute("shiftmode", sm)
  btn:RegisterForClicks("AnyUp")

  local name = st and st.name or "empty"
  if btn.label then
  local n = (st and st.steps and #st.steps) or 0
  local w = #stSteps
  btn.label:SetText("SSM\n" .. tostring(name):sub(1, 12) .. (n > 0 and ("\n" .. n .. " > " .. w) or ""))
  end

  Secure.InstallIconHooks(btn)
  if not (InCombatLockdown and InCombatLockdown()) then
    Secure.UpdateIcon(btn)
  end
  -- Do NOT place macros or repaint Blizzard bars on recompile (taint).
  if not InCombatLockdown() then
    Secure.SyncFloatingVisibility()
  end

  -- Quiet by default: leave-combat / AA toggles used to spam chat with
  -- "Compiled ..." and "Next cast ..." on every recompile. Only announce when
  -- Secure.verboseCompile is set (e.g. /ssm recompile). Limit warnings still
  -- come from Compile.ReportStatus (debounced).
  if not baked or not wrapped then
    NS.Print("|cffff6b7aSecure bake failed:|r " .. tostring(NS.lastBakeInfo.err or "unknown"))
  elseif Secure.verboseCompile and stStats and stStats.total and stStats.total > 0 then
    NS.Print(
      "Compiled |cff3dd68c"
        .. tostring(stStats.total)
        .. "|r clicks  -  once="
        .. tostring(stStats.once)
        .. " seq="
        .. tostring(stStats.seq)
        .. " pri="
        .. tostring(stStats.pri)
        .. "  -  Shift="
        .. sm
    )
    if stStats.truncated then
      NS.Print("|cffff6b7a  capped|r at " .. tostring(NS.MAX_FLAT_STEPS or 500) .. " flat clicks")
    end
    local hd = tonumber(stStats.helpersDropped) or 0
    if hd > 0 then
      NS.Print("|cfff0b429  helpers skipped|r on " .. tostring(hd) .. " line(s) (255-char room)")
    end
    if stStats.castClipped then
      NS.Print("|cffff6b7a  cast line over 255 chars|r on at least one click")
    end
    local sp = Secure.iconState.spell
    if sp then
      NS.Print("Next cast: |cff3dd68c" .. sp .. "|r")
    end
    -- Force re-print of limit status under verbose (ignore debounce)
    if NS.Compile and NS.Compile.ReportStatus then
      NS.Compile.ReportStatus(stStats, true)
    end
  end
  Secure.verboseCompile = false
end

function Secure.ApplyLayout()
  local btn = Secure.seqButton
  local host = Secure.seqHost
  local grip = Secure.seqGrip
  local db = NS.db
  if not btn or not db then
    return
  end
  if InCombatLockdown() then
    NS.pendingLayout = true
    return
  end
  NS.pendingLayout = false
  local anchor = host or btn
  anchor:ClearAllPoints()
  anchor:SetPoint(db.point or "CENTER", UIParent, db.relPoint or "CENTER", db.x or 0, db.y or 200)
  anchor:SetScale(db.scale or 1)
  if db.hidden then
    if host then
      host:Hide()
    end
    btn:Hide()
  else
    if host then
      host:Show()
    end
    btn:Show()
  end
  -- Drag handle stays usable whenever unlocked (not on the secure button).
  local locked = db.locked and true or false
  if host then
    host:SetMovable(not locked)
    host:EnableMouse(false) -- clicks go to grip / secure btn; host only moves
  end
  if grip then
    grip:EnableMouse(not locked)
    if locked then
      grip:RegisterForDrag()
      if grip.label then
        grip.label:SetText("locked")
      end
    else
      grip:RegisterForDrag("LeftButton")
      if grip.label then
        grip.label:SetText("| drag")
      end
    end
  end
  btn:EnableMouse(true)
  btn:SetMovable(false)
  btn:RegisterForDrag()
end

function Secure.CreateButton()
  if Secure.seqButton then
    return Secure.seqButton
  end
  local C = NS.C

  -- Non-secure host + visible drag tab. Secure child = mash only (no drag scripts).
  local host = CreateFrame("Frame", "SSMSeqButtonHost", UIParent)
  Secure.seqHost = host
  host:SetSize(58, 76)
  host:SetFrameStrata("HIGH")
  host:SetMovable(true)
  host:EnableMouse(false)
  host:SetClampedToScreen(true)

  -- Visible drag tab (easy to grab). Left-drag moves the whole host.
  local grip = CreateFrame("Button", nil, host, "BackdropTemplate")
  grip:SetPoint("TOPLEFT", 0, 0)
  grip:SetPoint("TOPRIGHT", 0, 0)
  grip:SetHeight(18)
  grip:EnableMouse(true)
  grip:RegisterForDrag("LeftButton")
  grip:SetFrameLevel(host:GetFrameLevel() + 10)
  if grip.SetBackdrop then
    grip:SetBackdrop({
      bgFile = "Interface\\Buttons\\WHITE8X8",
      edgeFile = "Interface\\Buttons\\WHITE8X8",
      edgeSize = 1,
      insets = { left = 0, right = 0, top = 0, bottom = 0 },
    })
    grip:SetBackdropColor(C.green[1], C.green[2], C.green[3], 0.85)
    grip:SetBackdropBorderColor(0, 0, 0, 0.6)
  end
  local gripLab = grip:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  gripLab:SetPoint("CENTER", 0, 0)
  gripLab:SetText("| drag")
  if NS.SetFS then
    NS.SetFS(gripLab, C.greenDark or { 0.02, 0.14, 0.09, 1 })
  else
    gripLab:SetTextColor(0.02, 0.14, 0.09, 1)
  end
  grip.label = gripLab
  Secure.seqGrip = grip

  local btn = CreateFrame("Button", BUTTON_NAME, host, "SecureActionButtonTemplate,SecureHandlerBaseTemplate")
  Secure.seqButton = btn
  btn:SetPoint("TOPLEFT", host, "TOPLEFT", 0, -18)
  btn:SetPoint("BOTTOMRIGHT", host, "BOTTOMRIGHT", 0, 0)
  btn:RegisterForClicks("AnyUp")
  btn:SetAttribute("useOnKeyDown", false)
  btn:SetAttribute("type", "macro")
  btn:SetAttribute("macrotext", "/startattack")
  btn:SetAttribute("step", 1)
  btn:SetAttribute("aoe_step", 1)
  btn:SetAttribute("name", BUTTON_NAME)
  btn:SetMovable(false)
  btn:EnableMouse(true)
  -- No SetScript drag/enter on secure button (taint-safe).

  local icon = btn:CreateTexture(nil, "ARTWORK")
  icon:SetPoint("TOPLEFT", 3, -3)
  icon:SetPoint("BOTTOMRIGHT", -3, 3)
  icon:SetTexture(QUESTION_ICON)
  icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  btn.icon = icon
  btn.cooldown = nil

  local chrome = CreateFrame("Frame", nil, btn, "BackdropTemplate")
  chrome:SetAllPoints()
  chrome:SetFrameLevel(btn:GetFrameLevel() + 2)
  chrome:EnableMouse(false)
  NS.HexBackdrop(chrome, { 0, 0, 0, 0.01 }, C.green, 12, "panel")
  btn.chrome = chrome
  if chrome.SetBackdropColor then
    chrome:SetBackdropColor(0, 0, 0, 0)
  end

  local nameBg = chrome:CreateTexture(nil, "ARTWORK")
  nameBg:SetPoint("BOTTOMLEFT", 2, 2)
  nameBg:SetPoint("BOTTOMRIGHT", -2, 2)
  nameBg:SetHeight(14)
  nameBg:SetColorTexture(0, 0, 0, 0.72)
  btn.nameBg = nameBg

  local spellLabel = chrome:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  spellLabel:SetPoint("BOTTOM", 0, 4)
  spellLabel:SetJustifyH("CENTER")
  spellLabel:SetWidth(52)
  spellLabel:SetText("SSM")
  NS.SetFS(spellLabel, C.text)
  btn.spellLabel = spellLabel

  local label = chrome:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
  label:SetPoint("CENTER", 0, 4)
  NS.SetFS(label, C.text)
  label:SetText("SSM")
  label:Hide()
  btn.label = label

  local function startDrag()
    if InCombatLockdown and InCombatLockdown() then
      return
    end
    if NS.db and NS.db.locked then
      NS.Print("Button locked - |cff3dd68c/ssm unlock|r then drag the green |cff3dd68c| drag|r tab.")
      return
    end
    host:StartMoving()
  end
  local function stopDrag()
    host:StopMovingOrSizing()
    if not NS.db then
      return
    end
    local p, _, rp, x, y = host:GetPoint(1)
    NS.db.point, NS.db.relPoint, NS.db.x, NS.db.y = p, rp, x, y
  end
  grip:SetScript("OnDragStart", startDrag)
  grip:SetScript("OnDragStop", stopDrag)
  grip:SetScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_TOP")
    GameTooltip:AddLine("Drag to move", C.green[1], C.green[2], C.green[3])
    GameTooltip:AddLine("Mash = click the icon below (or your bound key)", 1, 1, 1, true)
    if NS.db and NS.db.locked then
      GameTooltip:AddLine("/ssm unlock to move", 1, 0.42, 0.48)
    end
    GameTooltip:Show()
  end)
  grip:SetScript("OnLeave", GameTooltip_Hide)

  if not InCombatLockdown() then
    Secure.BakeSpellLists(btn, { "/startattack" }, { "/startattack" })
    Secure.InstallOnClickWrap(btn)
    Secure.EnsureKeyDownRelay()
    Secure.InstallIconHooks(btn)
    Secure.UpdateIcon(btn)
  end

  return btn
end

function Secure.DumpSample(maxLines)
  maxLines = maxLines or 12
  local bi = NS.lastBakeInfo
  if not bi or not bi.stSteps then
  NS.Print("No bake yet - import/create a pack and recompile.")
  return
  end
  NS.Print("=== /ssm secure (sample ST macros) v" .. NS.VERSION .. " ===")
  NS.Print(
  "steps="
  .. tostring(bi.st)
  .. " ok="
  .. tostring(bi.ok)
  .. " mode="
  .. tostring(bi.compile and bi.compile.priorityMode or "?")
  )
  for i = 1, math.min(maxLines, #bi.stSteps) do
  local entry = bi.stSteps[i]
  local mt = FlatMacro(entry):gsub("\n", " | ")
  local tag = FlatIsOnce(entry) and " [once]" or ""
  NS.Print(string.format("%02d  %s%s", i, mt, tag))
  end
  if #bi.stSteps > maxLines then
  NS.Print("... " .. (#bi.stSteps - maxLines) .. " more")
  end
end
