--[[
  CastGuard - only castable (/cast) or usable (/use) abilities on mash lists.

  Product: if it cannot be cast/used, it is not a sequence step.
  Pure talent passives never invent a fake /cast.

  Layers:
  1) Exact English name blocklist (talent passives that look like real names)
  2) Pattern list (Mastery:, Improved, Design, etc.)
  3) Live client: C_Spell.IsSpellPassive(spellId) when ID is known

  Mirrors theme assets/cast-guard.js (keep patterns in sync).
]]

local NS = SpellSmasherNS
local CG = {}
NS.CastGuard = CG

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

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

--- Exact-name passives (English). Never sequence steps. Expand as needed.
local EXACT_PASSIVE_NAMES = {
  ["feed the demon"] = true,
  ["fiery demise"] = true,
  ["soul rending"] = true,
  ["aldrachi design"] = true,
  ["incisive blade"] = true,
  ["keen engagement"] = true,
  ["charred warblades"] = true,
  ["master of the glaive"] = true,
  ["chase the prey"] = true,
  ["rush of chaos"] = true,
  ["quickened sigils"] = true,
  ["flames of fury"] = true,
  ["internal struggle"] = true,
  ["collective anguish"] = true,
  ["erratic felheart"] = true,
  ["illidari knowledge"] = true,
  ["precise sigils"] = true,
  ["disrupting fury"] = true,
  ["unhindered assault"] = true,
  ["lost in darkness"] = true,
  ["infernal armor"] = true,
  ["void reaver"] = true,
  ["calcified spikes"] = true,
  ["ascending flame"] = true,
  ["soul furnace"] = true,
  ["illuminated sigils"] = true,
  ["cycle of binding"] = true,
  ["stoke the flames"] = true,
  ["focused cleave"] = true,
  ["agonizing flames"] = true,
  ["roaring fire"] = true,
  ["animosity"] = true,
  ["font of magic"] = true,
  ["student of suffering"] = true,
  ["enduring torment"] = true,
  ["set fire to the pain"] = true,
  ["focused hatred"] = true,
  ["thrill of the fight"] = true,
  ["long night"] = true,
  ["pitch black"] = true,
  ["first of the illidari"] = true,
  ["fallout"] = true,
  ["charred flesh"] = true,
  ["burning blood"] = true,
  ["fleet footed"] = true,
  ["deadened nerves"] = true,
  ["nightstalker"] = true,
  ["subterfuge"] = true,
  ["elusiveness"] = true,
  ["thick hide"] = true,
  ["primal fury"] = true,
  ["fluid form"] = true,
  ["ursine vigor"] = true,
  ["well-honed instincts"] = true,
  ["airborne agility"] = true,
}

-- Passive talent / non-button name patterns (lowercase match on raw or norm).
local PASSIVE_PATTERNS = {
  "^improved ",
  "^mastery ",
  "^mastery:",
  -- Narrow (Part 8): bare substrings false-positive risk on future castables.
  -- Exact EN passives still covered by EXACT_PASSIVE_NAMES (e.g. illidari knowledge).
  " knowledge",
  "knowledge$",
  " teachings",
  "teachings$",
  "proliferat",
  "overflowing",
  "energized",
  "passive",
  "percent",
  "%%",
  "^fleet footed",
  "^deadened nerves",
  "^nightstalker$",
  "^subterfuge$",
  "^vigor$",
  "^cheat death",
  "^elusiveness$",
  "^thick hide",
  "^oakskin$",
  "^forestwalk",
  "^primal fury$",
  "^fluid form",
  "^ursine vigor",
  "^well%-honed",
  "^lycara",
  "^aessina",
  "^incisive blade",
  "^keen engagement",
  "^student of suffering",
  "^enduring torment",
  "^set fire to the pain",
  "^focused hatred",
  "^thrill of the fight",
  "^charred warblades",
  "^master of the glaive",
  "^chase the prey",
  "^rush of chaos",
  "^quickened sigils",
  "^flames of fury",
  "^internal struggle",
  "^collective anguish",
  "^erratic felheart",
  "^long night$",
  "^pitch black",
  "^first of the illidari",
  "^illidari knowledge",
  "^precise sigils",
  "^disrupting fury",
  "^unhindered assault",
  "^lost in darkness",
  "^soul rending",
  "^infernal armor",
  "^aldrachi design",
  "^feed the demon",
  "^fallout$",
  "^fiery demise",
  "^charred flesh",
  "^void reaver",
  "^calcified",
  "^ascending flame",
  "^burning blood",
  "^soul furnace",
  "^illuminated sigils",
  "^cycle of binding",
  "^stoke the flames",
  "^focused cleave",
  "^agonizing flames",
  "^roaring fire",
  "^animosity$",
  "^font of magic",
  "^aura of ",
  "^will of ",
  "^tome of ",
  "^flow of ",
  " design$",
}

local function isCustomLabel(label)
  return string.find(string.lower(tostring(label or "")), "custom%s*cast") ~= nil
end

local function isGearUseLabel(label)
  local s = tostring(label or "")
  if string.find(s, "^[Uu]se%s+[Tt]op%s+[Tt]rinket") then return true end
  if string.find(s, "^[Uu]se%s+[Bb]ottom%s+[Tt]rinket") then return true end
  if string.find(s, "^[Uu]se%s+[Bb]oots") then return true end
  if string.find(s, "^[Uu]se%s+[Bb]elt") then return true end
  if string.find(s, "^[Uu]se%s+[Gg]loves") then return true end
  if string.find(s, "^[Uu]se%s+[Cc]loak") then return true end
  if string.find(s, "^[Uu]se%s+[Mm]ain%s+[Hh]and") then return true end
  if string.find(s, "^[Uu]se%s+[Oo]ff%s+[Hh]and") then return true end
  if string.find(s, "^[Uu]se%s+[Hh]ealthstone") then return true end
  return false
end

--- True if this spell ID is a pure passive (client API). Safe when offline/unknown.
function CG.IsPassiveSpellId(spellId)
  local id = tonumber(spellId)
  if not id or id <= 0 then
    return false
  end
  if C_Spell and C_Spell.IsSpellPassive then
    local ok, result = pcall(C_Spell.IsSpellPassive, id)
    if ok and result == true then
      return true
    end
  end
  if IsPassiveSpell then
    local ok, result = pcall(IsPassiveSpell, id)
    if ok and result == true then
      return true
    end
  end
  return false
end

function CG.IsPassiveSpellName(label)
  local s = Trim(label)
  if s == "" then
    return true
  end
  if isCustomLabel(s) or isGearUseLabel(s) then
    return false
  end
  -- Combo "A + B"
  if string.find(s, "+", 1, true) then
    local allPassive = true
    local any = false
    for part in string.gmatch(s, "[^%+]+") do
      part = Trim(part)
      if part ~= "" then
        any = true
        if not CG.IsPassiveSpellName(part) then
          allPassive = false
          break
        end
      end
    end
    return any and allPassive
  end
  local low = string.lower(s)
  local nk = Norm(s)
  if EXACT_PASSIVE_NAMES[low] or EXACT_PASSIVE_NAMES[nk] then
    return true
  end
  for _, pat in ipairs(PASSIVE_PATTERNS) do
    if string.find(low, pat) or string.find(nk, pat) then
      return true
    end
  end
  return false
end

--- Prefer castable map ID; stamped ID only when it is not a pure passive.
--- @return castableId|nil, stampedPassiveId|nil
local function resolveCastableSpellId(label, spellId, className, spec)
  local stamped = tonumber(spellId)
  if stamped and stamped <= 0 then
    stamped = nil
  end
  local mapId = nil
  if NS.SpellIDMap and NS.SpellIDMap.GetSpellID then
    mapId = NS.SpellIDMap.GetSpellID(label, className, spec)
  end
  if mapId and CG.IsPassiveSpellId(mapId) then
    mapId = nil
  end
  if mapId then
    return mapId, (stamped and CG.IsPassiveSpellId(stamped)) and stamped or nil
  end
  if stamped and not CG.IsPassiveSpellId(stamped) then
    return stamped, nil
  end
  if stamped and CG.IsPassiveSpellId(stamped) then
    return nil, stamped
  end
  return nil, nil
end

function CG.MacroHasCombatLine(macro)
  local raw = tostring(macro or "")
  if Trim(raw) == "" then
    return false
  end
  for line in (raw .. "\n"):gmatch("(.-)\n") do
    line = Trim(line)
    if line ~= "" and not string.find(line, "^#") then
      local low = string.lower(line)
      if string.find(low, "^/castsequence") then
        -- Same token rules as /cast: need a sequence body
        local rest = line:match("^/%S+%s*(.*)$") or ""
        rest = Trim(rest)
        if rest ~= "" then
          local afterBrackets = rest:match("^%[[^%]]*%]%s*(.*)$")
          if afterBrackets ~= nil then
            if Trim(afterBrackets) ~= "" then
              return true
            end
          else
            return true
          end
        end
      elseif string.find(low, "^/cast") or string.find(low, "^/use") then
        -- Need something after the verb (spell name or [conds] + name)
        local rest = line:match("^/%S+%s*(.*)$") or ""
        rest = Trim(rest)
        if rest ~= "" then
          -- Reject bare /cast [nochanneling] with no spell token
          local afterBrackets = rest:match("^%[[^%]]*%]%s*(.*)$")
          if afterBrackets ~= nil then
            if Trim(afterBrackets) ~= "" then
              return true
            end
          else
            return true
          end
        end
      end
    end
  end
  return false
end

--- First castable part of "A + B" / "A / B" dual labels (for /cast invent).
local function primaryCastLabel(label)
  local s = Trim(label)
  if s == "" then
    return s
  end
  if string.find(s, "+", 1, true) then
    local part = Trim(s:match("^([^+]+)") or s)
    if part ~= "" then
      return part
    end
  end
  if string.find(s, "/", 1, true) then
    local part = Trim(s:match("^([^/]+)") or s)
    if part ~= "" then
      return part
    end
  end
  return s
end

--- Invent combat macro for a label. Optional className/spec: Part 9 locale name pilot.
function CG.CombatMacroFor(label, existingMacro, className, spec)
  local s = Trim(label)
  if s == "" then
    return nil
  end
  if isCustomLabel(s) then
    local m = Trim(existingMacro or "")
    if m ~= "" then
      return m
    end
    return "/cast [nochanneling] SPELL"
  end
  if CG.IsPassiveSpellName(s) then
    return nil
  end
  local existing = Trim(existingMacro or "")
  if existing ~= "" and CG.MacroHasCombatLine(existing) then
    return existing
  end
  if isGearUseLabel(s) then
    if existing ~= "" and CG.MacroHasCombatLine(existing) then
      return existing
    end
    return nil
  end
  -- Dual labels: invent /cast for the first castable part only
  local castName = primaryCastLabel(s)
  if castName ~= s and CG.IsPassiveSpellName(castName) then
    return nil
  end
  -- Locale-safe book name (cannot /cast by spell ID in macros).
  local locName = castName
  if NS.LocaleSpellName then
    locName = NS.LocaleSpellName(castName, className, spec)
  elseif NS.Compile and NS.Compile.LocaleSpellName then
    locName = NS.Compile.LocaleSpellName(castName, className, spec)
  end
  if string.find(castName, "[Ss]igil of ") or string.find(castName, "[Dd]ecree") then
    return "/cast [nochanneling,@player] " .. locName
  end
  if string.lower(castName) == "infernal strike" then
    return "/cast [nochanneling,@player] " .. locName
  end
  return "/cast [nochanneling] " .. locName
end

--- className/spec optional - used to resolve spell IDs for passive API checks.
function CG.IsSequenceable(label, macro, spellId, className, spec)
  local s = Trim(label)
  if s == "" then
    return false
  end
  if isCustomLabel(s) then
    return true
  end
  if CG.IsPassiveSpellName(s) then
    return false
  end
  local castableId, passiveStamp = resolveCastableSpellId(s, spellId, className, spec)
  local m = tostring(macro or "")
  -- Passive-only stamp with no castable map ID: drop unless user authored a combat macro
  if passiveStamp and not castableId then
    if m ~= "" and CG.MacroHasCombatLine(m) then
      return true
    end
    return false
  end
  if m ~= "" and CG.MacroHasCombatLine(m) then
    return true
  end
  return CG.CombatMacroFor(s, "", className, spec) ~= nil
end

--- Filter steps (returns new list). Never mutates caller's table.
--- Drops pure passives; clears/repairs passive spellId stamps; fills missing macros.
--- @return steps, droppedLabels[]
function CG.FilterSteps(steps, className, spec)
  local kept = {}
  local dropped = {}
  if type(steps) ~= "table" then
    return kept, dropped
  end
  for _, st in ipairs(steps) do
    if type(st) == "table" then
      local lab = Trim(st.label or st.name)
      local mac = tostring(st.macro or "")
      local sid = tonumber(st.spellId or st.spellID)
      if CG.IsSequenceable(lab, mac, sid, className, spec) then
        local copy = nil
        local function ensureCopy()
          if not copy then
            copy = {}
            for k, v in pairs(st) do
              copy[k] = v
            end
          end
          return copy
        end
        -- Prefer castable ID stamp (map/class|spec); strip passive-only stamps
        local castableId = select(1, resolveCastableSpellId(lab, sid, className, spec))
        if castableId then
          if not sid or sid ~= castableId or CG.IsPassiveSpellId(sid) then
            ensureCopy()
            copy.spellId = castableId
            copy.spellID = nil
          end
        elseif sid and CG.IsPassiveSpellId(sid) then
          ensureCopy()
          copy.spellId = nil
          copy.spellID = nil
        end
        if not CG.MacroHasCombatLine(mac) and not isCustomLabel(lab) then
          local fixed = CG.CombatMacroFor(lab, mac, className, spec)
          if fixed then
            ensureCopy()
            copy.macro = fixed
            kept[#kept + 1] = copy
          else
            dropped[#dropped + 1] = lab ~= "" and lab or "(unnamed)"
          end
        else
          kept[#kept + 1] = copy or st
        end
      else
        dropped[#dropped + 1] = lab ~= "" and lab or "(passive/non-cast)"
      end
    end
  end
  return kept, dropped
end
