--[[
  SpellSmasher pack codec - !SpellSmasher!1!<base64(utf8 JSON)>
  Must match site assets/ssm-pack.js field contract.
]]

local NS = SpellSmasherNS
local Pack = {}
NS.Pack = Pack

local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64lookup = {}
for i = 1, #b64chars do
  b64lookup[b64chars:sub(i, i)] = i - 1
end
b64lookup["-"] = b64lookup["+"]
b64lookup["_"] = b64lookup["/"]

local function Base64Decode(data)
  data = tostring(data or ""):gsub("%s+", "")
  data = data:gsub("-", "+"):gsub("_", "/")
  local pad = #data % 4
  if pad == 2 then
  data = data .. "=="
  elseif pad == 3 then
  data = data .. "="
  end
  local out = {}
  local buf, bits = 0, 0
  for i = 1, #data do
  local ch = data:sub(i, i)
  if ch ~= "=" then
  local v = b64lookup[ch]
  if v then
  buf = buf * 64 + v
  bits = bits + 6
  if bits >= 8 then
  bits = bits - 8
  out[#out + 1] = string.char(math.floor(buf / (2 ^ bits)) % 256)
  buf = buf % (2 ^ bits)
  end
  end
  end
  end
  return table.concat(out)
end

local function Base64Encode(data)
  data = tostring(data or "")
  local out = {}
  local len = #data
  local i = 1
  while i <= len do
  local a = data:byte(i) or 0
  local b = data:byte(i + 1)
  local c = data:byte(i + 2)
  local n = a * 65536 + (b or 0) * 256 + (c or 0)
  local c1 = math.floor(n / 262144) % 64
  local c2 = math.floor(n / 4096) % 64
  local c3 = math.floor(n / 64) % 64
  local c4 = n % 64
  out[#out + 1] = b64chars:sub(c1 + 1, c1 + 1)
  out[#out + 1] = b64chars:sub(c2 + 1, c2 + 1)
  if not b then
  out[#out + 1] = "="
  out[#out + 1] = "="
  elseif not c then
  out[#out + 1] = b64chars:sub(c3 + 1, c3 + 1)
  out[#out + 1] = "="
  else
  out[#out + 1] = b64chars:sub(c3 + 1, c3 + 1)
  out[#out + 1] = b64chars:sub(c4 + 1, c4 + 1)
  end
  i = i + 3
  end
  return table.concat(out)
end

local function JSONDecode(str)
  local s = tostring(str or "")
  local i, n = 1, #s
  local function peek()
  return s:sub(i, i)
  end
  local function nextChar()
  local c = s:sub(i, i)
  i = i + 1
  return c
  end
  local function skipWS()
  while i <= n and s:sub(i, i):match("%s") do
  i = i + 1
  end
  end
  local parseValue
  local function parseString()
  if nextChar() ~= '"' then
  error("JSON string expected")
  end
  local parts = {}
  while i <= n do
  local c = nextChar()
  if c == '"' then
  break
  elseif c == "\\" then
  local e = nextChar()
  if e == '"' or e == "\\" or e == "/" then
  parts[#parts + 1] = e
  elseif e == "n" then
  parts[#parts + 1] = "\n"
  elseif e == "r" then
  parts[#parts + 1] = "\r"
  elseif e == "t" then
  parts[#parts + 1] = "\t"
  elseif e == "u" then
  local hex = s:sub(i, i + 3)
  i = i + 4
  local code = tonumber(hex, 16) or 32
  if code < 128 then
  parts[#parts + 1] = string.char(code)
  elseif code < 0x800 then
  parts[#parts + 1] = string.char(0xC0 + math.floor(code / 64), 0x80 + (code % 64))
  else
  parts[#parts + 1] = string.char(
  0xE0 + math.floor(code / 4096),
  0x80 + math.floor((code % 4096) / 64),
  0x80 + (code % 64)
  )
  end
  else
  parts[#parts + 1] = e
  end
  else
  parts[#parts + 1] = c
  end
  end
  return table.concat(parts)
  end
  local function parseNumber()
  local start = i
  if peek() == "-" then
  i = i + 1
  end
  while i <= n and s:sub(i, i):match("%d") do
  i = i + 1
  end
  if peek() == "." then
  i = i + 1
  while i <= n and s:sub(i, i):match("%d") do
  i = i + 1
  end
  end
  return tonumber(s:sub(start, i - 1))
  end
  local function parseArray()
  nextChar()
  local arr = {}
  skipWS()
  if peek() == "]" then
  nextChar()
  return arr
  end
  while true do
  skipWS()
  arr[#arr + 1] = parseValue()
  skipWS()
  local c = peek()
  if c == "]" then
  nextChar()
  break
  elseif c == "," then
  nextChar()
  else
  error("JSON array")
  end
  end
  return arr
  end
  local function parseObject()
  nextChar()
  local obj = {}
  skipWS()
  if peek() == "}" then
  nextChar()
  return obj
  end
  while true do
  skipWS()
  local key = parseString()
  skipWS()
  if nextChar() ~= ":" then
  error("JSON :")
  end
  skipWS()
  obj[key] = parseValue()
  skipWS()
  local c = peek()
  if c == "}" then
  nextChar()
  break
  elseif c == "," then
  nextChar()
  else
  error("JSON object")
  end
  end
  return obj
  end
  parseValue = function()
  skipWS()
  local c = peek()
  if c == '"' then
  return parseString()
  elseif c == "{" then
  return parseObject()
  elseif c == "[" then
  return parseArray()
  elseif c == "t" and s:sub(i, i + 3) == "true" then
  i = i + 4
  return true
  elseif c == "f" and s:sub(i, i + 4) == "false" then
  i = i + 5
  return false
  elseif c == "n" and s:sub(i, i + 3) == "null" then
  i = i + 4
  return nil
  elseif c == "-" or c:match("%d") then
  return parseNumber()
  end
  error("JSON at " .. i)
  end
  skipWS()
  return parseValue()
end

local function JSONEscape(str)
  str = tostring(str or "")
  str = str:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
  return str
end

local function JSONEncode(val)
  local t = type(val)
  if t == "nil" then
  return "null"
  elseif t == "boolean" then
  return val and "true" or "false"
  elseif t == "number" then
  if val ~= val or val == math.huge or val == -math.huge then
  return "null"
  end
  return tostring(val)
  elseif t == "string" then
  return '"' .. JSONEscape(val) .. '"'
  elseif t == "table" then
  local isArray = true
  local maxn = 0
  for k, _ in pairs(val) do
  if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
  isArray = false
  break
  end
  if k > maxn then
  maxn = k
  end
  end
  if isArray and maxn == #val then
  local parts = {}
  for i = 1, #val do
  parts[i] = JSONEncode(val[i])
  end
  return "[" .. table.concat(parts, ",") .. "]"
  end
  local parts = {}
  for k, v in pairs(val) do
  if type(k) == "string" then
  parts[#parts + 1] = '"' .. JSONEscape(k) .. '":' .. JSONEncode(v)
  end
  end
  return "{" .. table.concat(parts, ",") .. "}"
  end
  return "null"
end

local function SanitizePackPaste(raw)
  raw = tostring(raw or "")
  raw = raw:gsub("^\239\187\191", ""):gsub("\239\187\191", "")
  raw = raw:gsub("\r\n", "\n"):gsub("\r", "\n")
  return raw
end

function Pack.ExtractToken(raw)
  raw = SanitizePackPaste(raw)
  local compact = raw:gsub("%s+", "")
  local idx = compact:lower():find("!spellsmasher!", 1, true)
  if not idx then
  return nil
  end
  local chunk = compact:sub(idx)
  local parts = {}
  for part in chunk:gmatch("[^!]+") do
  parts[#parts + 1] = part
  end
  if #parts < 3 or parts[1]:lower() ~= "spellsmasher" then
  return nil
  end
  local ver = parts[2]
  if not ver:match("^%d+$") then
  return nil
  end
  local b64 = table.concat(parts, "!", 3)
  b64 = b64:match("^([A-Za-z0-9%+/=_-]+)") or ""
  if #b64 < 4 then
  return nil
  end
  b64 = b64:gsub("-", "+"):gsub("_", "/")
  return "!SpellSmasher!" .. ver .. "!" .. b64
end

local function IsHelperLine(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

local function PackStep(st, className, spec)
  if type(st) ~= "table" then
  return nil
  end
  local mode = NS.NormMode(st.mode)
  local label = NS.Trim(st.label)
  local macro = NS.Trim(st.macro)
  macro = macro:gsub("\r\n", "\n"):gsub("\r", "\n")
  if macro ~= "" then
  local combat = {}
  for line in (macro .. "\n"):gmatch("(.-)\n") do
  line = NS.Trim(line)
  if line ~= "" and not IsHelperLine(line) then
  combat[#combat + 1] = line
  end
  end
  macro = table.concat(combat, "\n")
  end
  local sid = tonumber(st.spellId or st.spellID)
  if macro == "" and label ~= "" and label ~= "Step" then
  -- Never invent /cast for passive talent names
  if NS.CastGuard and NS.CastGuard.CombatMacroFor then
  macro = NS.CastGuard.CombatMacroFor(label, "") or ""
  else
  macro = "/cast [nochanneling] " .. label
  end
  end
  if NS.CastGuard and NS.CastGuard.IsSequenceable then
  if not NS.CastGuard.IsSequenceable(label, macro, sid, className, spec) then
  return nil
  end
  end
  if label == "" and macro == "" then
  return nil
  end
  local interval = tonumber(st.interval) or 3
  if interval <= 0 then
  interval = 3
  end
  -- Prefer castable map ID for export stamp
  if (not sid or (NS.CastGuard and NS.CastGuard.IsPassiveSpellId and NS.CastGuard.IsPassiveSpellId(sid)))
  and NS.SpellIDMap
  and NS.SpellIDMap.GetSpellID
  then
  sid = NS.SpellIDMap.GetSpellID(label, className, spec)
  end
  if sid and NS.CastGuard and NS.CastGuard.IsPassiveSpellId and NS.CastGuard.IsPassiveSpellId(sid) then
  sid = nil
  end
  local out = {
  label = label ~= "" and label or "Step",
  macro = macro,
  mode = mode,
  interval = math.floor(interval),
  looped = mode ~= "once",
  }
  if sid then
  out.spellId = sid
  end
  local lr = tonumber(st.loopRepeat)
  if lr and lr >= 1 then
  out.loopRepeat = math.floor(lr)
  end
  return out
end

local function PackSequence(s)
  if type(s) ~= "table" then
  return nil
  end
  local className = NS.Trim(s.className)
  local spec = NS.Trim(s.spec)
  local steps = {}
  if type(s.steps) == "table" then
  for _, st in ipairs(s.steps) do
  local ps = PackStep(st, className, spec)
  if ps then
  steps[#steps + 1] = ps
  end
  end
  end
  if #steps == 0 then
  return nil
  end
  local helpers = s.helpers
  if type(helpers) ~= "table" then
  helpers = {
  autoAttack = NS.db and NS.db.helperAutoAttack and true or false,
  retarget = NS.db and NS.db.helperRetarget and true or false,
  }
  end
  local heroTalent = NS.Trim(s.heroTalent or s.hero or "")
  local seq = {
  name = NS.Trim(s.name) ~= "" and NS.Trim(s.name) or "Sequence",
  className = NS.Trim(s.className),
  spec = NS.Trim(s.spec),
  heroTalent = heroTalent,
  role = string.lower(NS.Trim(s.role or "st")),
  notes = NS.Trim(s.notes),
  characterLevel = tonumber(s.characterLevel) or 90,
  steps = steps,
  helpers = {
  autoAttack = helpers.autoAttack and true or false,
  retarget = helpers.retarget and true or false,
  },
  sequentialRepeat = math.max(1, math.floor(tonumber(s.sequentialRepeat) or 1)),
  priorityRepeat = math.max(1, math.floor(tonumber(s.priorityRepeat) or 1)),
  talentChecklist = NS.Trim(s.talentChecklist or ""),
  talentLoadout = NS.Trim(s.talentLoadout or s.talents or ""),
  talents = NS.Trim(s.talentLoadout or s.talents or ""),
  packSource = NS.Trim(s.packSource or ""),
  packId = s.packId,
  packMateRole = s.packMateRole,
  variables = type(s.variables) == "table" and s.variables or {},
  }
  if seq.role ~= "aoe" and seq.role ~= "st" and seq.role ~= "hybrid" and seq.role ~= "general" then
  seq.role = "st"
  end
  -- Preserve source tag when present (method|engine|import)
  if seq.packSource == "" and s.talentLoadoutSource then
  seq.packSource = NS.Trim(s.talentLoadoutSource)
  end
  seq.talentLoadoutSource = NS.Trim(s.talentLoadoutSource or seq.talentLoadoutSource or "")
  -- Prefer full Method loadout if still empty (export + import normalize)
  if #seq.talentLoadout < 40 and SpellSmasherMethodLoadouts then
  local r = SpellSmasherMethodLoadouts.GetResult
  and SpellSmasherMethodLoadouts.GetResult(seq.className, seq.spec, seq.heroTalent)
  or nil
  local lo = (r and r.loadout) or SpellSmasherMethodLoadouts.Get(seq.className, seq.spec, seq.heroTalent)
  if lo and #lo >= 40 then
  seq.talentLoadout = lo
  seq.talents = lo
  if seq.talentLoadoutSource == "" then
  seq.talentLoadoutSource = (r and r.source) or "method"
  end
  end
  end
  -- Drop empty heroTalent so older sites ignore the field cleanly
  if seq.heroTalent == "" then
  seq.heroTalent = nil
  end
  return seq
end

--- Soft validation for smooth import/export (returns ok, warnings[], hardError|nil)
function Pack.ValidateSequences(sequences)
  local warnings = {}
  if type(sequences) ~= "table" or #sequences == 0 then
  return false, warnings, "Pack has no sequences."
  end
  for si, s in ipairs(sequences) do
  local name = (s and s.name) or ("Pack " .. si)
  if type(s) ~= "table" or type(s.steps) ~= "table" or #s.steps == 0 then
  return false, warnings, "'" .. name .. "' has no steps."
  end
  local modes = { once = 0, sequential = 0, priority = 0, reverse_priority = 0 }
  for i, st in ipairs(s.steps) do
  local mode = NS.NormMode(st.mode)
  modes[mode] = (modes[mode] or 0) + 1
  local lab = NS.Trim(st.label)
  local mac = NS.Trim(st.macro)
  if lab == "" and mac == "" then
  return false, warnings, "'" .. name .. "' step " .. i .. " has no label/macro."
  end
  if mac ~= "" then
  local hasCombat = false
  for line in (mac .. "\n"):gmatch("(.-)\n") do
  line = NS.Trim(line)
  if line ~= "" and not IsHelperLine(line) then
  hasCombat = true
  break
  end
  end
  if not hasCombat and lab == "" then
  return false, warnings, "'" .. name .. "' step " .. i .. " is helper-only."
  end
  end
  local lr = tonumber(st.loopRepeat)
  if lr and (lr < 1 or lr > 99) then
  warnings[#warnings + 1] = name .. " step " .. i .. ": loopRepeat clamped later"
  end
  end
  local lo = NS.Trim(s.talentLoadout or s.talents or "")
  if #lo > 0 and #lo < 40 then
  warnings[#warnings + 1] = name .. ": talent string looks short (" .. #lo .. " chars)"
  end
  if (s.className == nil or s.className == "") and (s.spec == nil or s.spec == "") then
  warnings[#warnings + 1] = name .. ": no class/spec (OK, but Method rebuild needs them)"
  end
  end
  return true, warnings, nil
end

--- Encode packs to !SpellSmasher!1!...
--- opts.snapshotLive (default true): for each source seq matching the current
--- character class|spec, put live Blizzard CUk into the *export string only*.
--- Never mutates the caller's packs / SavedVariables (Method CUk stays on the pack).
--- Returns packString, snappedCount
function Pack.Encode(sequences, appVersion, opts)
  opts = opts or {}
  local snapshotLive = opts.snapshotLive
  if snapshotLive == nil then
  snapshotLive = true
  end

  local snapped = 0
  local outSeqs = {}
  for _, s in ipairs(sequences or {}) do
  if type(s) ~= "table" then
  -- skip
  else
  local src = s
  -- Live snapshot for export: shallow clone so talent fields can be overridden
  -- without writing back into SpellSmasherDB (Method packages stay intact).
  if snapshotLive and NS.ApplyLiveTalentLoadout then
  local work = {}
  for k, v in pairs(s) do
  work[k] = v
  end
  if NS.ApplyLiveTalentLoadout(work) then
  snapped = snapped + 1
  src = work
  end
  end
  local ps = PackSequence(src)
  if ps then
  outSeqs[#outSeqs + 1] = ps
  end
  end
  end
  local okV, _, hard = Pack.ValidateSequences(outSeqs)
  if not okV then
  return nil, 0 -- UI checks for nil pack
  end
  local obj = {
  format = "SpellSmasher",
  v = 1,
  appVersion = tostring(appVersion or NS.VERSION),
  exportedAt = date and date("!%Y-%m-%dT%H:%M:%SZ") or "",
  sequences = outSeqs,
  }
  local json = JSONEncode(obj)
  if not json or #json < 4 then
  return nil, snapped
  end
  return "!SpellSmasher!1!" .. Base64Encode(json), snapped
end

function Pack.Decode(raw)
  local token = Pack.ExtractToken(raw)
  if not token then
  return nil, "Not a SpellSmasher pack (need !SpellSmasher!1!...)."
  end
  local parts = {}
  for part in token:gmatch("[^!]+") do
  parts[#parts + 1] = part
  end
  -- "", parts may start at SpellSmasher because leading ! is split away
  local b64
  if #parts >= 3 and parts[1]:lower() == "spellsmasher" then
  b64 = table.concat(parts, "!", 3)
  else
  return nil, "Bad pack token shape."
  end
  local jsonStr = Base64Decode(b64)
  if not jsonStr or #jsonStr < 2 then
  return nil, "Base64 decode failed."
  end
  local ok, obj = pcall(JSONDecode, jsonStr)
  if not ok or type(obj) ~= "table" then
  return nil, "JSON decode failed: " .. tostring(obj)
  end
  if tostring(obj.format or "") ~= "SpellSmasher" and type(obj.sequences) ~= "table" then
  return nil, "Not a SpellSmasher JSON pack."
  end
  local sequences = {}
  if type(obj.sequences) == "table" then
  for _, s in ipairs(obj.sequences) do
  local ps = PackSequence(s)
  if ps then
  sequences[#sequences + 1] = ps
  end
  end
  end
  if #sequences == 0 then
  return nil, "Pack has no sequences with steps."
  end
  -- Classic: refuse Midnight-only classes; strip hero + CUk (Match does not exist here).
  -- Midnight: this block does not run. Site pack contract unchanged.
  if NS.IsClassicFlavor then
    local kept = {}
    local skipped = 0
    for _, ps in ipairs(sequences) do
      local cn = NS.Trim(ps.className or "")
      if cn == "Demon Hunter" or cn == "Evoker" then
        skipped = skipped + 1
      else
        ps.heroTalent = nil
        local lo = NS.Trim(ps.talentLoadout or ps.talents or "")
        if #lo >= 40 and (lo:sub(1, 2) == "CU" or lo:sub(1, 2) == "Cu") then
          ps.talentLoadout = ""
          ps.talents = ""
          ps.talentLoadoutSource = ""
        end
        kept[#kept + 1] = ps
      end
    end
    if #kept == 0 then
      return nil, "This pack is Midnight-only (Demon Hunter / Evoker). Classic Import needs a class this client has."
    end
    sequences = kept
  end
  local okV, warnings, hard = Pack.ValidateSequences(sequences)
  if not okV then
  return nil, hard or "Pack validation failed."
  end
  return {
  sequences = sequences,
  appVersion = obj.appVersion,
  v = obj.v,
  warnings = warnings,
  }, nil
end

function Pack.ApplyHelpersFromPack(seq)
  if type(seq) ~= "table" or type(seq.helpers) ~= "table" or not NS.db then
  return
  end
  -- Only apply from the sequence being imported as active later; UI may call per-seq.
  if seq.helpers.autoAttack ~= nil then
  NS.db.helperAutoAttack = seq.helpers.autoAttack and true or false
  end
  if seq.helpers.retarget ~= nil then
  NS.db.helperRetarget = seq.helpers.retarget and true or false
  end
end
