-- Cadence: Serialize.lua -- share strings and pasted timeline parsing.
-- No external libraries: the payload is a small line format, base64 encoded so it
-- survives Discord, chat and note fields intact.

local ADDON, ns = ...

ns.Serialize = {}
local S = ns.Serialize

--------------------------------------------------------------------------------
-- base64
--------------------------------------------------------------------------------

local B = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

-- Arithmetic rather than bitwise: the game runs Lua 5.1, which has no >> operator.
local floor = math.floor

local function sextet(n, shift)
    local v = floor(n / (2 ^ shift)) % 64
    return B:sub(v + 1, v + 1)
end

local function encode(data)
    local pad = (3 - #data % 3) % 3
    local padded = data .. string.rep("\0", pad)
    local out = {}
    for i = 1, #padded, 3 do
        local a, b, c = padded:byte(i, i + 2)
        local n = a * 65536 + b * 256 + c
        out[#out + 1] = sextet(n, 18)
        out[#out + 1] = sextet(n, 12)
        out[#out + 1] = sextet(n, 6)
        out[#out + 1] = sextet(n, 0)
    end
    local str = table.concat(out)
    if pad > 0 then str = str:sub(1, #str - pad) .. string.rep("=", pad) end
    return str
end

local decodeMap = {}
for i = 1, 64 do decodeMap[B:sub(i, i)] = i - 1 end

local function decode(str)
    str = str:gsub("[^%w%+/=]", "")
    local clean = str:gsub("=", "")
    local out, bits, count = {}, 0, 0
    for i = 1, #clean do
        local v = decodeMap[clean:sub(i, i)]
        if not v then return nil end
        bits = bits * 64 + v
        count = count + 1
        if count == 4 then
            out[#out + 1] = string.char(floor(bits / 65536) % 256, floor(bits / 256) % 256, bits % 256)
            bits, count = 0, 0
        end
    end
    if count == 3 then
        bits = bits * 64
        out[#out + 1] = string.char(floor(bits / 65536) % 256, floor(bits / 256) % 256)
    elseif count == 2 then
        bits = bits * 4096
        out[#out + 1] = string.char(floor(bits / 65536) % 256)
    end
    return table.concat(out)
end

--------------------------------------------------------------------------------
-- Field escaping
--------------------------------------------------------------------------------

local function esc(v)
    v = tostring(v or "")
    return (v:gsub("\\", "\\\\"):gsub("|", "\\p"):gsub("\n", "\\n"))
end

-- One left-to-right pass. Unwinding with three chained gsubs corrupted anything
-- holding a real backslash followed by p or n, which is every other sound path:
-- "Sounds\ping.ogg" escapes to "Sounds\\ping.ogg", and a later gsub("\\p", "|")
-- then ate the second backslash and the p.
local UNESC = { n = "\n", p = "|" }
local function unesc(v)
    return (v:gsub("\\(.)", function(c) return UNESC[c] or c end))
end

local function split(line, sep)
    local out = {}
    for piece in (line .. sep):gmatch("([^" .. sep .. "]*)" .. sep) do out[#out + 1] = piece end
    return out
end

--------------------------------------------------------------------------------
-- Export / Import
--------------------------------------------------------------------------------

-- Export one encounter's reminders.
function S:Export(encounterID, planID)
    local enc = ns.GetEncounter(encounterID)
    local plan = planID and ns.GetPlanByID(encounterID, planID) or ns.GetActivePlan(encounterID)
    if not enc or not plan or #plan.reminders == 0 then
        return nil, "Nothing to export for that plan."
    end

    local lines = { ("E|%d|%s|%s|%s"):format(encounterID, esc(enc.name),
        esc(plan.name), esc(plan.difficultyName)) }
    for _, r in ipairs(plan.reminders) do
        lines[#lines + 1] = table.concat({
            "R",
            r.enabled and "1" or "0",
            r.trigger or "PULL",
            tostring(r.at or 0),
            tostring(r.offset or 0),
            esc(r.match),
            tostring(r.occurrence or 1),
            tostring(r.lead or ""),
            tostring(r.spellID or ""),
            esc(r.label),
            esc(r.note),
            esc(r.sound),
            r.mute and "1" or "0",
            r.blip and "1" or "0",
        }, "|")
    end

    local payload = table.concat(lines, "\n")
    local sum = 0
    for i = 1, #payload do sum = (sum + payload:byte(i) * i) % 65536 end
    return ("CDNC1!%04x!%s"):format(sum, encode(payload))
end

-- Import a share string. mode is "replace" or "append".
function S:Import(str, mode)
    if type(str) ~= "string" then return nil, "Nothing pasted." end
    str = str:gsub("%s", "")
    local sum, body = str:match("^CDNC1!(%x%x%x%x)!(.+)$")
    if not body then return nil, "That is not a Cadence string. Use Paste a timeline for Lorrgs or note text." end

    local payload = decode(body)
    if not payload then return nil, "That string is damaged." end

    local check = 0
    for i = 1, #payload do check = (check + payload:byte(i) * i) % 65536 end
    if ("%04x"):format(check) ~= sum then return nil, "That string is damaged or was truncated." end

    local encounterID, encName, planName, difficulty, reminders = nil, nil, nil, nil, {}
    for line in payload:gmatch("[^\n]+") do
        local parts = split(line, "|")
        if parts[1] == "E" then
            encounterID = tonumber(parts[2])
            encName = unesc(parts[3] or "")
            planName = unesc(parts[4] or "")
            difficulty = unesc(parts[5] or "")
        elseif parts[1] == "R" then
            reminders[#reminders + 1] = {
                id         = ns.NewID(),
                enabled    = parts[2] == "1",
                trigger    = parts[3] == "BOSSMOD" and "BOSSMOD" or "PULL",
                at         = tonumber(parts[4]) or 0,
                offset     = tonumber(parts[5]) or 0,
                match      = unesc(parts[6] or ""),
                occurrence = tonumber(parts[7]) or 1,
                lead       = tonumber(parts[8]),
                spellID    = tonumber(parts[9]),
                label      = unesc(parts[10] or ""),
                note       = unesc(parts[11] or ""),
                sound      = unesc(parts[12] or ""),
                mute       = parts[13] == "1",
                blip       = parts[14] == "1",
            }
        end
    end

    if not encounterID then return nil, "That string has no boss in it." end

    local enc = ns.GetEncounter(encounterID, true)
    if encName ~= "" then enc.name = encName end

    local plan, created = S:ResolvePlan(encounterID, planName, difficulty, mode)
    if mode == "replace" then wipe(plan.reminders) end
    for _, r in ipairs(reminders) do plan.reminders[#plan.reminders + 1] = r end
    S:Sort(plan.reminders)

    return encounterID, ("Imported %d reminders into %s%s for %s."):format(
        #reminders, plan.name, created and " (new plan)" or "", enc.name)
end

-- Where an imported string should land. A string naming a plan that does not
-- exist creates it, so importing several strings for one boss keeps them apart
-- instead of overwriting each other.
function S:ResolvePlan(encounterID, planName, difficulty, mode)
    if planName and planName ~= "" then
        local existing = ns.GetPlanByName(encounterID, planName)
        if existing then
            ns.SetActivePlan(encounterID, existing.id)
            if difficulty and difficulty ~= "" then existing.difficultyName = difficulty end
            return existing, false
        end

        -- A boss created by this very import has one empty placeholder plan.
        -- Claim it instead of leaving it stranded next to the real one.
        local plans = ns.GetPlans(encounterID)
        if #plans == 1 and #plans[1].reminders == 0 then
            plans[1].name = planName
            if difficulty and difficulty ~= "" then plans[1].difficultyName = difficulty end
            ns.SetActivePlan(encounterID, plans[1].id)
            return plans[1], false
        end

        local plan = ns.AddPlan(encounterID, planName)
        if difficulty and difficulty ~= "" then plan.difficultyName = difficulty end
        return plan, true
    end

    -- Unnamed string: append to the active plan, but do not silently overwrite a
    -- plan that already has content unless replace was asked for.
    local active = ns.GetActivePlan(encounterID, true)
    if mode ~= "replace" and #active.reminders > 0 then
        local plan = ns.AddPlan(encounterID, "Imported")
        if difficulty and difficulty ~= "" then plan.difficultyName = difficulty end
        return plan, true
    end
    if difficulty and difficulty ~= "" then active.difficultyName = difficulty end
    return active, false
end

function S:Sort(reminders)
    table.sort(reminders, function(a, b)
        local at = a.trigger == "PULL" and (a.at or 0) or math.huge
        local bt = b.trigger == "PULL" and (b.at or 0) or math.huge
        if at ~= bt then return at < bt end
        return (a.label or "") < (b.label or "")
    end)
end

--------------------------------------------------------------------------------
-- Structured plan exports (key:value rows)
--------------------------------------------------------------------------------
--
-- EncounterID:3421;Difficulty:Heroic;Name:The Twin Fangs;
-- ph:1;time:3.0;spellid:370537;
--
-- The header carries the encounter ID, so this format does not need you to have
-- pulled the boss first. Times are seconds from the pull, decimals included.

local function kvpairs(line)
    local t = {}
    for chunk in line:gmatch("[^;]+") do
        local k, v = chunk:match("^%s*([%w_]+)%s*:%s*(.-)%s*$")
        if k then t[k:lower()] = v end
    end
    return t
end

-- One timestamp, in any of the shapes these tools write it: 105, 105.5, 1:45,
-- 01:45, 1:45.3.
local function parseClock(spec)
    spec = tostring(spec or ""):match("^%s*(.-)%s*$")
    local m, s = spec:match("^(%d+):(%d+%.?%d*)$")
    if m then return tonumber(m) * 60 + tonumber(s) end
    local n = spec:match("^(%d+%.?%d*)$")
    if n then return tonumber(n) end
    return nil
end

function S:LooksStructured(text)
    if type(text) ~= "string" then return false end
    local lower = text:lower()
    return lower:find("spellid%s*:") ~= nil or lower:find("encounterid%s*:") ~= nil
end

-- Returns a list of reminders plus a header table { encounterID, name, difficulty }.
function S:ParseStructured(text)
    local out, header, skipped = {}, {}, 0
    if type(text) ~= "string" then return out, header, 0 end

    for line in text:gmatch("[^\r\n]+") do
        if line:match("%S") then
            local kv = kvpairs(line)

            if kv.encounterid then
                header.encounterID = tonumber(kv.encounterid)
                header.name = kv.name
                header.difficulty = kv.difficulty
            end

            -- parseClock, not tonumber: a row written time:1:45 used to come back
            -- nil and get dropped without a word, because the "unreadable row"
            -- counter below only fires when there is no time key at all.
            local at = parseClock(kv.time)
            local sid = tonumber(kv.spellid or kv.spell or "")
            if at and (sid or kv.name) and not kv.encounterid then
                local label = (sid and ns.SpellLabel(sid)) or kv.name or ("Spell " .. tostring(sid))
                out[#out + 1] = {
                    id      = ns.NewID(),
                    enabled = true,
                    trigger = "PULL",
                    at      = at,
                    spellID = sid,
                    -- Keep the raw ID visible when the client cannot name it, so a
                    -- renumbered or unknown spell is obvious rather than blank.
                    label   = label,
                    note    = (kv.ph and kv.ph ~= "1") and ("phase " .. kv.ph) or "",
                }
            elseif not kv.encounterid and line:find(":") then
                skipped = skipped + 1
            end
        end
    end

    S:Sort(out)
    return out, header, skipped
end

--------------------------------------------------------------------------------
-- One entry point for anything pasted into the dialog
--------------------------------------------------------------------------------

function S:ImportAny(text, encounterID, mode)
    if type(text) ~= "string" or not text:match("%S") then return nil, "Nothing pasted." end

    if text:find("^%s*CDNC1!") then
        return S:Import(text, mode)
    end

    if S:LooksStructured(text) then
        local entries, header = S:ParseStructured(text)
        local target = header.encounterID or encounterID
        if not target then return nil, "That plan has no encounter ID, so pick a boss first." end
        if #entries == 0 then return nil, "No timed rows found in that plan." end

        local enc = ns.GetEncounter(target, true)
        if header.name and header.name ~= "" then enc.name = header.name end

        local plan, created = S:ResolvePlan(target, header.difficulty, header.difficulty, mode)
        if mode == "replace" then wipe(plan.reminders) end
        for _, r in ipairs(entries) do plan.reminders[#plan.reminders + 1] = r end
        S:Sort(plan.reminders)

        local unnamed = 0
        for _, r in ipairs(entries) do
            if r.spellID and not ns.HasSpellName(r.spellID) then unnamed = unnamed + 1 end
        end

        local msg = ("Imported %d reminders into %s%s for %s."):format(
            #entries, plan.name, created and " (new plan)" or "", enc.name)
        if unnamed > 0 then msg = msg .. (" %d spell IDs did not resolve on this client."):format(unnamed) end
        return target, msg
    end

    if not encounterID then return nil, "Pick a boss first — a pasted timeline has no boss in it." end
    local count, msg = S:ImportPaste(text, encounterID, mode)
    if not count then return nil, msg end
    return encounterID, msg
end

-- Strips WoW escape sequences, icon links and list bullets.
local function cleanLine(line)
    line = line:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    line = line:gsub("|T.-|t", " ")
    line = line:gsub("^%s*[%-%*•]%s*", "")
    return line
end

-- Tidies whatever is left of a line once the times and tokens are out of it.
local function tidy(s)
    s = s:gsub("[%[%]{}()]", " ")
    s = s:gsub("^[%s%-–—:,;>|]+", ""):gsub("[%s%-–—:,;>|]+$", "")
    s = s:gsub("%s%s+", " ")
    return s
end

-- One timestamp, in any of the shapes these tools write it: 105, 105.5, 1:45,
-- 01:45, 1:45.3.
local function parseClock(spec)
    spec = tostring(spec or ""):match("^%s*(.-)%s*$")
    local m, s = spec:match("^(%d+):(%d+%.?%d*)$")
    if m then return tonumber(m) * 60 + tonumber(s) end
    local n = spec:match("^(%d+%.?%d*)$")
    if n then return tonumber(n) end
    return nil
end

-- MRT writes {spell:740} or {spell:740:Name}; a link pasted out of chat arrives
-- as |Hspell:740|h[Tranquility]|h. Either way the ID is the most reliable thing
-- on the line, so it is taken out before anything tries to read the label -- and
-- taken out is the point: left in place it used to end up IN the label, which is
-- how a cue came to be called "spell:740 Tranquility - Ishnu".
local function extractSpellID(line)
    local id = nil

    line = line:gsub("{spell:(%d+)[^}]*}", function(n)
        id = id or tonumber(n)
        return " "
    end)
    -- Keep the visible half of a hyperlink: that is the spell's name.
    line = line:gsub("|Hspell:(%d+)[^|]*|h(.-)|h", function(n, shown)
        id = id or tonumber(n)
        return shown
    end)
    -- Any other hyperlink is not a spell, but its markup still has to go.
    line = line:gsub("|H[^|]*|h(.-)|h", "%1")

    return id, line
end

-- Pulls every timestamp out of a line. Handles 1:45, 01:45, 1:45.3, {time:1:45},
-- {time:105}, [1:45] and 105s. A bare number on its own is deliberately NOT a
-- time: "Spirit Link Totem 105" is far more often a spell ID or a stray figure
-- than a timestamp, and guessing wrong puts a cue at the wrong minute.
local function extractTimes(line)
    local times, stripped = {}, line

    -- The body of a {time:...} token may carry extra arguments after a comma --
    -- MRT hangs a cast trigger there. Consume the whole token either way so none
    -- of it survives into the label.
    stripped = stripped:gsub("{time:([^}]*)}", function(body)
        local t = parseClock(body:match("^([^,]*)"))
        if t then times[#times + 1] = t end
        return " "
    end)

    stripped = stripped:gsub("(%d+):(%d%d%.?%d*)", function(m, s)
        times[#times + 1] = tonumber(m) * 60 + tonumber(s)
        return " "
    end)

    stripped = stripped:gsub("(%d+%.?%d*)s%f[%A]", function(s)
        times[#times + 1] = tonumber(s)
        return " "
    end)

    return times, stripped
end

-- Finds the spell named at the start of a piece of text, and hands back what was
-- left over. "Tranquility - Ishnu" resolves to Tranquility with "- Ishnu" spare,
-- which is how a caster's name stops being mistaken for the cooldown.
local function resolveSpell(text)
    if not text or text == "" then return nil, "", "" end

    local id = ns.FindSpellID(text)
    if id then return id, text, "" end

    local words = {}
    for w in text:gmatch("%S+") do words[#words + 1] = w end
    for n = #words, 1, -1 do
        local candidate = table.concat(words, " ", 1, n)
        local found = ns.FindSpellID(candidate)
        if found then
            return found, candidate, table.concat(words, " ", n + 1)
        end
    end
    return nil, "", text
end

-- The label to show for a resolved ID, or nil when this client cannot name it.
local function labelFor(id)
    if id and ns.HasSpellName(id) then return ns.SpellLabel(id) end
    return nil
end

-- Is this bare text plausibly a spell heading rather than prose? Lorrgs prints the
-- spell on its own line and the cast times underneath it.
local function looksLikeHeading(text)
    if ns.FindSpellID(text) then return true end
    if #text > 40 then return false end
    local words = 0
    for _ in text:gmatch("%S+") do words = words + 1 end
    return words > 0 and words <= 5
end

-- Returns a list of reminder tables parsed from arbitrary pasted text.
-- Any line with at least one timestamp and some text becomes one reminder per
-- timestamp. Lines without a timestamp become the heading for the rows below.
function S:ParsePaste(text)
    local out, skipped = {}, 0
    if type(text) ~= "string" then return out, 0 end

    -- A heading is a line carrying no time, and it holds until the next one. It
    -- is what makes the Lorrgs shape work: the cooldown is named once, and every
    -- row underneath names a time and a player rather than the ability.
    local headingLabel, headingID = nil, nil
    -- The ability from the previous timed row, used only for a row that is
    -- nothing but a time and has no heading above it.
    local lastLabel, lastID = nil, nil

    for rawLine in (text .. "\n"):gmatch("([^\r\n]*)[\r\n]") do
        local line = cleanLine(rawLine)
        if line:match("%S") then
            local tokenID
            tokenID, line = extractSpellID(line)

            local times, rest = extractTimes(line)
            rest = tidy(rest)
            -- Lorrgs rows often trail with a percentage of the fight.
            rest = rest:gsub("%s+%d+%%$", "")

            if #times > 0 then
                local restID, restName, restOver = resolveSpell(rest)
                local label, spellID, note = nil, nil, ""

                if tokenID then
                    -- An explicit token beats anything read out of prose.
                    spellID = tokenID
                    label   = labelFor(tokenID) or (restID == tokenID and restName) or rest
                    note    = (restID == tokenID) and restOver or ""
                elseif restID then
                    spellID = restID
                    label   = labelFor(restID) or restName
                    note    = restOver
                elseif headingID then
                    -- The leftover is not a spell but the heading above is, so the
                    -- leftover is a caster, not a cooldown.
                    spellID, label, note = headingID, headingLabel, rest
                elseif rest ~= "" then
                    label = rest
                elseif headingLabel then
                    spellID, label = headingID, headingLabel
                elseif lastLabel then
                    spellID, label = lastID, lastLabel
                end

                if label and label ~= "" then
                    note = tidy(note or "")
                    for _, t in ipairs(times) do
                        out[#out + 1] = {
                            id      = ns.NewID(),
                            enabled = true,
                            trigger = "PULL",
                            at      = t,
                            spellID = spellID,
                            label   = label,
                            note    = note,
                        }
                    end
                    lastLabel, lastID = label, spellID
                else
                    skipped = skipped + 1
                end

            elseif tokenID or line:match("%a") then
                local heading = tidy(line)
                if tokenID then
                    -- A token is unambiguous, so it makes a heading even when the
                    -- line carries no readable text of its own.
                    headingID = tokenID
                    headingLabel = labelFor(tokenID) or (heading ~= "" and heading)
                        or ("Spell " .. tokenID)
                elseif looksLikeHeading(heading) then
                    local hid, hname = resolveSpell(heading)
                    headingID = hid
                    headingLabel = labelFor(hid) or (hid and hname) or heading
                else
                    skipped = skipped + 1
                end
            end
        end
    end

    S:Sort(out)
    return out, skipped
end

-- Convenience: parse and write straight into an encounter.
function S:ImportPaste(text, encounterID, mode)
    local parsed, skipped = S:ParsePaste(text)
    if #parsed == 0 then return nil, "No timestamps found in that text." end

    local enc = ns.GetEncounter(encounterID, true)
    local plan = ns.GetActivePlan(encounterID, true)
    if mode == "replace" then wipe(plan.reminders) end
    for _, r in ipairs(parsed) do plan.reminders[#plan.reminders + 1] = r end
    S:Sort(plan.reminders)

    local msg = ("Added %d reminders to %s (%s)."):format(#parsed, enc.name, plan.name)
    if skipped > 0 then msg = msg .. (" %d lines had no timestamp and were ignored."):format(skipped) end
    return #parsed, msg
end
