-------------------------------------------------------------------------------
-- Transmission.lua
--
-- !BZA export/import strings, and in-game chat links.
--
-- FOUR INVARIANTS. Read these before changing anything in this file; the
-- promise "old import strings keep working" rests entirely on them.
--
--   1. NEVER DELETE A DECODE BRANCH. The "!BZA:N!" prefix names the encode
--      pipeline. When the pipeline changes, bump N and keep the old branch
--      forever.
--   2. NEVER DELETE A MIGRATION. A tracker payload's `s` table IS
--      stored-profile shape, so an old string is exactly an old profile
--      fragment — and ns.MigrateTrackerSettings (Core.lua) is what upgrades
--      it. Every migration written for profiles upgrades import strings for
--      free; deleting one breaks strings that still exist in the wild.
--   3. NEVER REPURPOSE A STORED KEY. Changing the MEANING of an existing key
--      without renaming it breaks silently: old value, new interpretation, no
--      error. Add a new key and migrate from the old one, always.
--   4. BUMP ns.SETTINGS_SCHEMA WITH EVERY MIGRATION added to
--      ns.MigrateTrackerSettings. It travels as `sv` and is what lets an
--      OLDER addon reading a NEWER string warn precisely rather than guess.
--
-- The asymmetric rule of thumb: a NEWER addon reading an OLDER string must
-- always work (migrations, kept forever); an OLDER addon reading a NEWER
-- string degrades politely (warn + best effort, never a Lua error — every
-- unknown key lands somewhere inert by design).
--
-- WHAT TRAVELS. Never export anything the import side can recompute: no
-- cfgKey-derived caches, no integer `id`, no folder ids, no frame positions
-- that mean nothing remotely. If it is not a stored profile key or an entry
-- field, it does not ship.
-------------------------------------------------------------------------------
local addonName, ns = ...
local HET = ns.addon

local ACD = LibStub("AceConfigDialog-3.0")
local ACR = LibStub("AceConfigRegistry-3.0")
local AceGUI = LibStub("AceGUI-3.0")
local LibSerialize = LibStub("LibSerialize", true)
local LibDeflate = LibStub("LibDeflate", true)
local AceComm = LibStub("AceComm-3.0", true)

-- Set at the bottom of the file, once AceComm has actually embedded. Declared
-- here because the SetItemRef hook closes over it.
local COMM_READY = false

-- Encode-pipeline version. Dispatched BEFORE anything else can be read, which
-- is exactly why it lives in the prefix and not in the envelope.
local ENCODE_VERSION = 1
-- Envelope/payload STRUCTURE version. Bumps only on a restructure, which the
-- flat-key design makes rare on purpose.
local PAYLOAD_FORMAT = 1

local COMM_PREFIX = "BuzzardAuras"   -- 12 chars; the registered limit is 16

local KNOWN_KINDS = {
    aura = true, group = true, custom = true,
    ability = true, abilitygroup = true, item = true, itemgroup = true,
}

-- Profile keys that are NOT tracker settings and must never travel. Note
-- `spatial`'s dbPrefix is the EMPTY STRING, so without this list a Spatial
-- Paradox export would sweep up the entire profile — customTrackers included.
local NEVER_EXPORT = {
    customTrackers          = true,
    ts_perClassSplit        = true,
    pd_voiceOptionsMigrated = true,
}

local function Print(msg)
    print("|cffff8c14Buzzard Auras|r " .. msg)
end

local function AddonVersion()
    local v = C_AddOns and C_AddOns.GetAddOnMetadata
        and C_AddOns.GetAddOnMetadata(addonName, "Version")
    if type(v) ~= "string" then return 0 end
    local a, b, c = v:match("^(%d+)%.(%d+)%.(%d+)")
    if not a then return 0 end
    return tonumber(a) * 10000 + tonumber(b) * 100 + tonumber(c)
end

-------------------------------------------------------------------------------
-- Table helpers
-------------------------------------------------------------------------------
local function DeepCopy(v)
    if type(v) ~= "table" then return v end
    local t = {}
    for k, val in pairs(v) do t[k] = DeepCopy(val) end
    return t
end

-- Recursive, because plenty of stored values are tables (barCircleColor is
-- {r,g,b,a}). A shallow compare would export every colour on every tracker.
local function DeepEquals(a, b)
    if a == b then return true end
    if type(a) ~= "table" or type(b) ~= "table" then return false end
    for k, va in pairs(a) do
        if not DeepEquals(va, b[k]) then return false end
    end
    for k in pairs(b) do
        if a[k] == nil then return false end
    end
    return true
end

-------------------------------------------------------------------------------
-- Key ownership
--
-- A stored key belongs to the tracker with the LONGEST registered dbPrefix
-- that is a prefix of it. Stating it that way (rather than "starts with my
-- prefix") is what makes the empty-prefix tracker safe, and it stays correct
-- if a future prefix ever nests inside another.
-------------------------------------------------------------------------------
local function OwnsKey(cfg, key)
    if type(key) ~= "string" or NEVER_EXPORT[key] then return false end
    local pfx = cfg.dbPrefix
    if key:sub(1, #pfx) ~= pfx then return false end
    -- A "cu<N>_" key belongs to custom tracker N even when N is NOT REGISTERED
    -- in this profile. Without this special case an empty-prefix (Spatial)
    -- overlay would own every such key by the longest-prefix rule, and a
    -- hostile payload could pre-seed the slot that the next new tracker hands out
    -- — the new tracker would silently come up wearing the attacker's
    -- elements and spell IDs.
    local cuID = key:match("^cu(%d+)_")
    if cuID then
        return pfx == ("cu" .. cuID .. "_")
    end
    for i = 1, #ns.trackerOrder do
        local other = ns.trackerConfigs[ns.trackerOrder[i]]
        local op = other and other.dbPrefix
        if op and #op > #pfx and key:sub(1, #op) == op then
            return false
        end
    end
    return true
end

-- Every stored key of this tracker that DIFFERS from its registered default.
--
-- The default-diff is mandatory, not an optimisation. AceDB's copyDefaults
-- materialises registered defaults INTO the live profile table (they are
-- stripped back out at logout), so pairs(profile) sees a value for every
-- default of every key. A naive dump would export `enabled = true` and
-- `barCircleColor = {1,1,1,1}` for every tracker.
--
-- It also has a semantic job: a key ABSENT from a payload means "whatever the
-- default is now", which is the correct meaning — the sender never touched it
-- — so an imported tracker still looks right on a future version whose
-- defaults have shifted.
local function CollectSettings(cfgKey)
    local p = HET.db.profile
    local cfg = ns.trackerConfigs[cfgKey]
    if not cfg then return nil end
    local pfx = cfg.dbPrefix
    local defaults = cfg.defaults or {}
    local s = {}
    for k, v in pairs(p) do
        if OwnsKey(cfg, k) then
            local short = k:sub(#pfx + 1)
            if short ~= "" and not DeepEquals(v, defaults[short]) then
                s[short] = DeepCopy(v)
            end
        end
    end
    return s
end

-- Wipe every stored key of a tracker's identity slot. Used before writing
-- imported settings so an "Update Existing" cannot leave stale keys behind
-- from settings the sender has since removed.
local function WipeTrackerKeys(cfg)
    local p = HET.db.profile
    local doomed = {}
    for k in pairs(p) do
        if OwnsKey(cfg, k) then doomed[#doomed + 1] = k end
    end
    for i = 1, #doomed do p[doomed[i]] = nil end
end

-- Write a payload's settings onto a tracker, then bring them up to the
-- current schema. Step three is the whole backward-compatibility story.
local function ApplySettings(cfg, s, entry, fromSchema)
    local p = HET.db.profile
    WipeTrackerKeys(cfg)
    if type(s) == "table" then
        for short, v in pairs(s) do
            if type(short) == "string" then
                local key = cfg.dbPrefix .. short
                -- A malformed or hostile payload must not be able to reach a
                -- key this tracker does not own (an empty-prefix overlay could
                -- otherwise name customTrackers, or seed an unallocated
                -- cu<N>_ slot).
                if OwnsKey(cfg, key) then p[key] = DeepCopy(v) end
            end
        end
    end
    -- RE-MATERIALISE THE DEFAULTS. AceDB's copyDefaults writes registered
    -- defaults INTO the live profile table, and the wipe above removed them
    -- along with everything else; the payload deliberately does not carry them
    -- (that is the whole point of the default-diff). Nothing else puts them
    -- back — AceDB only re-copies on the next RegisterDefaults — so without
    -- this an imported tracker comes up with `enabled` nil and is silently
    -- invisible, and a built-in overlay import turns that built-in OFF.
    local defaults = cfg.defaults
    if type(defaults) == "table" then
        for k, v in pairs(defaults) do
            local key = cfg.dbPrefix .. k
            if p[key] == nil then p[key] = DeepCopy(v) end
        end
    end
    if ns.MigrateTrackerSettings then
        ns.MigrateTrackerSettings(p, cfg, entry, fromSchema or 0)
    end
end

-------------------------------------------------------------------------------
-- Payload construction
--
-- Single-letter keys throughout the envelope and payloads. Settings keys
-- inside `s` keep their real names — they must round-trip into profile keys
-- verbatim, and deflate flattens their repetition cost anyway.
--
-- There is no separate "tracker group" export: a group-KIND tracker is ONE
-- tracker (one entry, one cu<id>_ prefix, its ID list just another stored
-- key), so it exports as a single tracker payload with nothing extra to do.
-------------------------------------------------------------------------------
local function EntryForCustomID(id)
    local list = HET.db.profile.customTrackers
    if type(list) ~= "table" then return nil end
    for i = 1, #list do
        if list[i].id == id then return list[i] end
    end
    return nil
end

-- The `d` half of a payload, for one tracker. Returns nil when the tracker
-- cannot be resolved.
local function TrackerData(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if not cfg then return nil end
    local s = CollectSettings(cfgKey)
    if not cfg.custom then
        -- BUILT-IN: a SETTINGS OVERLAY. The tracker itself ships with the
        -- addon, so only its configured settings travel, keyed by the cfgKey
        -- — which IS the stable identity for built-ins (fixed string, never
        -- recycled, identical in every profile).
        return { k = cfgKey, s = s }
    end
    local entry = EntryForCustomID(cfg.customID)
    if not entry then return nil end
    local e = DeepCopy(entry)
    -- The integer id is LOCAL: recycled here, meaningless there.
    e.id = nil
    return { e = e, s = s }
end

local function Envelope(t, d)
    return {
        f  = PAYLOAD_FORMAT,
        sv = ns.SETTINGS_SCHEMA or 0,
        av = AddonVersion(),
        t  = t,
        d  = d,
    }
end

function ns.PayloadForTracker(cfgKey)
    local d = TrackerData(cfgKey)
    if not d then
        Print("Could not read that tracker's settings.")
        return nil
    end
    return Envelope("t", d)
end

function ns.PayloadForFolder(folderID)
    local folder = ns.FolderByID and ns.FolderByID(folderID)
    if not folder then return nil end
    local members = {}
    local skipped = 0
    local g = HET.db.global
    local list = (type(g.folderMembers) == "table") and g.folderMembers[folderID] or nil
    if type(list) == "table" then
        for i = 1, #list do
            local cfgKey = ns.CFGKeyForUID and ns.CFGKeyForUID(list[i])
            local d = cfgKey and TrackerData(cfgKey)
            if d then
                members[#members + 1] = d
            else
                -- A uid with no live tracker in THIS profile (it may be valid
                -- in another). Reported, never exported half-formed.
                skipped = skipped + 1
            end
        end
    end
    if skipped > 0 then
        Print(("%d member%s of \"%s\" could not be resolved in this profile and %s skipped.")
            :format(skipped, skipped == 1 and "" or "s", folder.name,
                skipped == 1 and "was" or "were"))
    end
    return Envelope("f", { n = folder.name, m = members })
end

-------------------------------------------------------------------------------
-- Encoding
--
--   table -> LibSerialize:Serialize
--         -> LibDeflate:CompressDeflate{level=9}
--         -> EncodeForPrint            (strings and chat-safe text)
--         or EncodeForWoWAddonChannel  (comm, below)
--   prefix: "!BZA:<encodeVersion>!"
--
-- LibSerialize, not AceSerializer: AceSerializer's human-readable output is
-- roughly twice the size before compression even starts.
-------------------------------------------------------------------------------
local function Encode(payload, forComm)
    if not (LibSerialize and LibDeflate) then return nil, "libraries missing" end
    local ok, ser = pcall(function() return LibSerialize:Serialize(payload) end)
    if not ok or type(ser) ~= "string" then return nil, "could not serialize" end
    local comp = LibDeflate:CompressDeflate(ser, { level = 9 })
    if not comp then return nil, "could not compress" end
    local body = forComm and LibDeflate:EncodeForWoWAddonChannel(comp)
        or LibDeflate:EncodeForPrint(comp)
    if not body then return nil, "could not encode" end
    return "!BZA:" .. ENCODE_VERSION .. "!" .. body
end

function ns.PayloadToString(payload)
    -- Both returns, deliberately: the caller's error message is the only thing
    -- that distinguishes "libraries missing" from "could not compress".
    return Encode(payload, false)
end

-- Shape validation. Anything that fails here is reported and dropped rather
-- than half-applied.
-- One tracker `d` half. Called for standalone payloads AND for every folder
-- member — a member reaches exactly the same import code, so it has to clear
-- exactly the same bar. (A payload can arrive over the addon channel from a
-- player whose chat link you clicked, so "malformed" here includes
-- "deliberately hostile".)
local function ValidateTrackerData(d)
    if type(d) ~= "table" then return nil, "That payload contains a malformed tracker." end
    local isOverlay = type(d.k) == "string"
    local isCustom  = type(d.e) == "table"
    if not (isCustom or isOverlay) then
        return nil, "That tracker payload is malformed."
    end
    if d.s ~= nil and type(d.s) ~= "table" then
        return nil, "That tracker payload has malformed settings."
    end
    if isCustom then
        if not KNOWN_KINDS[d.e.kind] then
            return nil, "That payload uses a tracker kind this version does not have."
        end
        -- `name` is fed straight to UniqueCustomName, which calls :lower() on
        -- it; `uid` is used as a table key and compared as a string.
        if d.e.name ~= nil and type(d.e.name) ~= "string" then
            return nil, "That tracker payload has a malformed name."
        end
        if d.e.uid ~= nil and type(d.e.uid) ~= "string" then
            return nil, "That tracker payload has a malformed id."
        end
    end
    return d
end

local function ValidatePayload(pl)
    if type(pl) ~= "table" then return nil, "That string does not contain a Buzzard Auras payload." end
    if type(pl.f) ~= "number" then return nil, "That payload has no format version." end
    if pl.f > PAYLOAD_FORMAT then
        return nil, "That string was made with a newer version of Buzzard Auras. Update to import it."
    end
    -- `av` and `sv` are compared numerically in the dialog; a string here
    -- would throw at the comparison rather than at the door.
    if pl.av ~= nil and type(pl.av) ~= "number" then return nil, "That payload is malformed." end
    if pl.sv ~= nil and type(pl.sv) ~= "number" then return nil, "That payload is malformed." end
    if pl.t ~= "t" and pl.t ~= "f" then return nil, "Unknown payload type." end
    if type(pl.d) ~= "table" then return nil, "That payload has no contents." end
    if pl.t == "t" then
        local ok, err = ValidateTrackerData(pl.d)
        if not ok then return nil, err end
    else
        if type(pl.d.m) ~= "table" then return nil, "That folder payload has no members." end
        if pl.d.n ~= nil and type(pl.d.n) ~= "string" then
            return nil, "That folder payload has a malformed name."
        end
        for i = 1, #pl.d.m do
            local ok, err = ValidateTrackerData(pl.d.m[i])
            if not ok then return nil, err end
        end
    end
    return pl
end

function ns.StringToPayload(str)
    if type(str) ~= "string" then return nil, "Nothing to import." end
    str = str:gsub("%s+", "")
    if str == "" then return nil, "Nothing to import." end
    local ver, body = str:match("^!BZA:(%d+)!(.+)$")
    if not ver then
        return nil, "That is not a Buzzard Auras import string (it should start with |cffffd100!BZA:|r)."
    end
    ver = tonumber(ver)
    if ver > ENCODE_VERSION then
        return nil, "That string was made with a newer version of Buzzard Auras. Update to import it."
    end
    if not (LibSerialize and LibDeflate) then return nil, "Import libraries are missing." end
    -- One message per failure mode: they are the difference between "you
    -- pasted half a string" and "that is not a Buzzard Auras string at all".
    local comp = LibDeflate:DecodeForPrint(body)
    if not comp then return nil, "That string is damaged or was cut short when copied." end
    local ser = LibDeflate:DecompressDeflate(comp)
    if not ser then return nil, "That string is damaged or incomplete." end
    local ok, pl = LibSerialize:Deserialize(ser)
    if not ok then return nil, "That string could not be read." end
    return ValidatePayload(pl)
end

-------------------------------------------------------------------------------
-- Import
-------------------------------------------------------------------------------
local function Notify()
    ACR:NotifyChange(addonName)
end

-- Does a local tracker already own this payload's identity?
--   custom  -> the uid resolves to a live tracker in the CURRENT profile
--   overlay -> the cfgKey is a registered built-in
-- Returns the cfgKey, or nil.
local function MatchExisting(d)
    if type(d.k) == "string" then
        local cfg = ns.trackerConfigs[d.k]
        return (cfg and not cfg.custom) and d.k or nil
    end
    local uid = type(d.e) == "table" and d.e.uid
    if type(uid) ~= "string" then return nil end
    local key = ns.CFGKeyForUID and ns.CFGKeyForUID(uid) or nil
    -- Built-ins live in the same uid map as their own cfgKey, so a corrupt
    -- payload whose uid happens to read "innervate" would otherwise light up
    -- "Update Existing" and then do nothing at all. A custom payload can only
    -- ever match a CUSTOM tracker.
    local cfg = key and ns.trackerConfigs[key]
    return (cfg and cfg.custom) and key or nil
end
ns.MatchExisting = MatchExisting

-- Apply a BUILT-IN OVERLAY. "Import as New" and "Update Existing" converge
-- here — there is no second copy of a built-in to make.
local function ImportOverlay(d, fromSchema)
    local cfg = ns.trackerConfigs[d.k]
    if not cfg or cfg.custom then
        -- BUILT-IN LABELS ONLY here. This branch is also how a corrupt payload
        -- lands (d.k naming a custom key), and resolving that against the
        -- receiver's own customs would name an unrelated tracker of theirs.
        Print(("Skipped a settings overlay for |cffffd100%s|r — this version has no such built-in tracker.")
            :format((ns.BuiltinTrackerLabel and ns.BuiltinTrackerLabel(d.k))
                or tostring(d.k)))
        return nil
    end
    ApplySettings(cfg, d.s, nil, fromSchema)
    if ns.ExemptFromSpecFilter then ns.ExemptFromSpecFilter(cfg.dbPrefix) end
    -- An overlay creates nothing, so it is never "new" for folder-membership
    -- purposes — see ApplyPayload.
    return d.k, false
end

-- Create a NEW custom tracker from a payload.
local function ImportAsNew(d, fromSchema)
    local p = HET.db.profile
    if type(p.customTrackers) ~= "table" then p.customTrackers = {} end
    local list = p.customTrackers
    local newId = ns.AllocateCustomID(list)

    local entry = DeepCopy(d.e)
    entry.id = newId
    -- KEEP the payload's uid when it is free locally; mint a fresh one only on
    -- collision. Keeping it is what lets a re-sent, updated string find this
    -- import later and offer "Update Existing" — the whole matching system
    -- rests on it. (This is the stamping pass's dedupe rule, applied at the
    -- door.)
    if type(entry.uid) ~= "string" or entry.uid == ""
        or (ns.CFGKeyForUID and ns.CFGKeyForUID(entry.uid)) then
        entry.uid = ns.GenerateUID()
    end
    entry.name = ns.UniqueCustomName(entry.name or "Imported Tracker", entry)

    list[#list + 1] = entry
    ns.BumpCustomEntryGen()
    ns.RegisterCustomConfig(newId)

    local cfgKey = "custom" .. newId
    local cfg = ns.trackerConfigs[cfgKey]
    ApplySettings(cfg, d.s, entry, fromSchema)
    -- An imported tracker arrives fully configured, conditions included, so it
    -- can easily be outside the current Spec Filter and land invisible. Exempt
    -- it until the user changes View or Filter, or closes the panel — the same
    -- treatment a tracker they had just created by hand gets.
    if ns.ExemptFromSpecFilter and cfg then
        ns.ExemptFromSpecFilter(cfg.dbPrefix)
    end
    return cfgKey, true
end

-- Overwrite an EXISTING custom tracker's entry and settings.
local function ImportUpdate(d, cfgKey, fromSchema)
    local cfg = ns.trackerConfigs[cfgKey]
    if not cfg or not cfg.custom then return nil end
    local entry = EntryForCustomID(cfg.customID)
    if not entry then return nil end
    local keepID, keepUID = entry.id, entry.uid
    for k in pairs(entry) do entry[k] = nil end
    for k, v in pairs(d.e) do entry[k] = DeepCopy(v) end
    -- `id` stays local; `uid` is identical by construction (it is what
    -- matched), but restate it rather than trust the payload.
    entry.id, entry.uid = keepID, keepUID
    entry.name = ns.UniqueCustomName(entry.name or "Imported Tracker", entry)
    ns.BumpCustomEntryGen()
    ApplySettings(cfg, d.s, entry, fromSchema)
    -- Same reasoning as ImportAsNew: an update can move a tracker out of the
    -- current filter's scope, and it should not disappear as it lands.
    if ns.ExemptFromSpecFilter then ns.ExemptFromSpecFilter(cfg.dbPrefix) end
    -- Folder membership is deliberately NOT touched: which folder a tracker
    -- sits in is the RECEIVER'S organisation, not the sender's.
    return cfgKey, false
end

-- One tracker payload -> a cfgKey, or nil when it was skipped.
--   mode = "new"    always create (overlays still just apply)
--   mode = "update" update the match when there is one, else create
-- Returns (cfgKey, createdNew), or nil when the payload was skipped.
local function ImportTrackerData(d, mode, fromSchema)
    if type(d.k) == "string" then return ImportOverlay(d, fromSchema) end
    if mode == "update" then
        local match = MatchExisting(d)
        if match then
            local key, isNew = ImportUpdate(d, match, fromSchema)
            -- ImportUpdate only fails when the match is not actually a custom
            -- tracker; fall through to creating one rather than silently
            -- doing nothing.
            if key then return key, isNew end
        end
    end
    return ImportAsNew(d, fromSchema)
end

-- Open the options panel on a node, whether or not it is already open.
--
-- Routed through HET:OpenOptions rather than ACD:Open: the chat-link path
-- typically runs with the panel never opened this session, and OpenOptions is
-- where the hook refresh and the LSM font priming live. On an already-open
-- panel it lands on the same ACD:Open re-feed as before.
local function OpenPanelOn(selectFn)
    C_Timer.After(0, function()
        if selectFn then selectFn() end
        if HET.OpenOptions then HET:OpenOptions() else ACD:Open(addonName) end
    end)
end

-- The full apply for a validated envelope. Returns a short summary string.
local function ApplyPayload(pl, mode)
    local summary
    local fromSchema = tonumber(pl.sv) or 0
    if pl.t == "t" then
        -- A standalone import joins no folder: it lands in Ungrouped under
        -- By Folder, and under its kind's node in By Type.
        local cfgKey = ImportTrackerData(pl.d, mode, fromSchema)
        if not cfgKey then return nil end
        HET:RefreshTracking()
        Notify()
        -- OPEN the panel, don't just re-feed an already-open one. An import
        -- from a chat link typically happens with the panel shut, and landing
        -- on the imported tracker's page is the whole point — otherwise the
        -- user is told "Imported 1 tracker" and has to go and find it.
        OpenPanelOn(function()
            if ns.SelectTracker then ns.SelectTracker(cfgKey) end
        end)
        summary = "Imported 1 tracker."
    else
        local folderID = ns.AddFolder(pl.d.n or "Imported Folder")
        if not folderID then return nil end
        local members = pl.d.m
        local added, updated, skipped = 0, 0, 0
        for i = 1, #members do
            local d = members[i]
            -- `createdNew` comes from the importer itself, NOT from a
            -- pre-check: a built-in OVERLAY creates nothing even in "new"
            -- mode, and moving it would rip the receiver's own built-in out
            -- of the folder they had put it in.
            local cfgKey, createdNew = ImportTrackerData(d, mode, fromSchema)
            if not cfgKey then
                skipped = skipped + 1
            elseif createdNew then
                added = added + 1
                local uid = ns.UIDOf and ns.UIDOf(cfgKey)
                if uid then ns.MoveTracker(uid, folderID) end
            else
                -- Updated members (and applied overlays) keep whatever folder
                -- they already sit in: membership is the receiver's
                -- organisation, not the sender's.
                updated = updated + 1
            end
        end
        local folder = ns.FolderByID(folderID)
        local folderName = folder and folder.name or (pl.d.n or "Imported Folder")
        HET:RefreshTracking()
        if added == 0 then
            -- Every member matched something local, so nothing joined the new
            -- folder (membership is the receiver's organisation, not the
            -- sender's). Leaving an empty folder behind would be litter.
            ns.DeleteFolder(folderID)
            Notify()
            summary = ("Updated %d tracker%s from folder \"%s\"; %d skipped."
                .. " No new folder was needed.")
                :format(updated, updated == 1 and "" or "s", pl.d.n or "?", skipped)
        else
            -- SWITCH TO BY FOLDER. The whole point of a folder import is the
            -- folder, and the By Type view has no node for it — selecting one
            -- there would write a uniquevalue the tree does not contain, so
            -- ACD would silently drop the user on its first row instead.
            if ns.ViewMode and ns.ViewMode() == "type" and ns.SetViewMode then
                ns.SetViewMode("folder")
            end
            Notify()
            -- Same as the single-tracker case: land the user on what they just
            -- imported, opening the panel if it was shut.
            OpenPanelOn(function()
                ACD:SelectGroup(addonName, "folder" .. folderID)
            end)
            summary = ("Imported folder \"%s\": %d new, %d updated, %d skipped.")
                :format(folderName, added, updated, skipped)
        end
    end
    return summary
end

-- COMBAT. Import mutates customTrackers and drives the refresh pipeline, and
-- the options panel is combat-locked anyway. One pending slot is enough — a
-- second import in combat replaces it, with a note.
local pendingImport = nil

function ns.ImportPayload(pl, mode)
    if type(pl) ~= "table" then return end
    if InCombatLockdown() then
        if pendingImport then
            Print("Replacing the import that was waiting for combat to end.")
        end
        pendingImport = { pl = pl, mode = mode }
        Print("In combat — this import will be applied when you leave combat.")
        return
    end
    local summary = ApplyPayload(pl, mode)
    if summary then Print(summary) end
end

local combatWatcher = CreateFrame("Frame")
combatWatcher:RegisterEvent("PLAYER_REGEN_ENABLED")
combatWatcher:SetScript("OnEvent", function()
    if not pendingImport then return end
    local job = pendingImport
    pendingImport = nil
    local summary = ApplyPayload(job.pl, job.mode)
    if summary then Print(summary) end
end)

-------------------------------------------------------------------------------
-- Standalone dialogs
--
-- Deliberately NOT AceConfigDialog: these must work with the options panel
-- CLOSED (a chat link clicked mid-raid is the whole point of E2).
-------------------------------------------------------------------------------
local exportFrame, importFrame

-- Vertical space the Frame widget spends on its own furniture, from
-- AceGUIContainer-Frame's content anchors: 27 above for the title bar, and
-- below it either the 40px status/Close strip or, when that strip is stripped
-- out, plain 17px padding.
local TOP_INSET           = 27
local BOTTOM_INSET_STATUS = 40
local BOTTOM_INSET_BARE   = 17

local function AnchorContent(f, bottomInset)
    if not (f.content and f.frame) then return end
    f.content:ClearAllPoints()
    f.content:SetPoint("TOPLEFT",     f.frame, "TOPLEFT",       17, -TOP_INSET)
    f.content:SetPoint("BOTTOMRIGHT", f.frame, "BOTTOMRIGHT",  -17,  bottomInset)
end

-- The two bottom-strip children, found by identity rather than by index: the
-- Frame widget does not publish them, but it does set `.obj` on both, and the
-- Close button is the one carrying the CLOSE caption.
--
-- THE BUTTON TEST IS LOAD-BEARING. `content` also carries `.obj = widget`
-- (AceGUI:RegisterAsContainer sets it), and it is a Frame — without the type
-- check it matches as "the strip" and hiding it blanks the whole window.
local function BottomFurniture(f)
    local frame = f.frame
    if not frame then return nil, nil end
    local close, strip
    for i = 1, select("#", frame:GetChildren()) do
        local c = select(i, frame:GetChildren())
        if type(c) == "table" and c.obj == f
            and c.GetObjectType and c:GetObjectType() == "Button" then
            if c.GetText and c:GetText() == CLOSE then close = c else strip = c end
        end
    end
    return close, strip
end

-- Drop the grey status strip, the native Close button and the resize grips,
-- and pull the content region down into the space they were holding.
--
-- POOLED, so this MUST be undone before the widget is released — see
-- ns.RestoreDialogChrome. AceGUI's OnAcquire re-shows neither of these two
-- (it only re-enables the sizers), so a frame released with them hidden hands
-- the next addon a window with no Close button.
function ns.StripDialogChrome(f)
    local close, strip = BottomFurniture(f)
    -- NAMED FIELDS, not an array. `{ close, strip }` with a nil `close` has a
    -- length of 0 in Lua, so an ipairs-style restore would silently skip the
    -- strip and leave it hidden on a pooled frame.
    f._bzaStripped = { close = close, strip = strip }
    if close then close:Hide() end
    if strip then strip:Hide() end
    f:EnableResize(false)
    AnchorContent(f, BOTTOM_INSET_BARE)
end

function ns.RestoreDialogChrome(f)
    local stripped = f._bzaStripped
    if not stripped then return end
    f._bzaStripped = nil
    if stripped.close then stripped.close:Show() end
    if stripped.strip then stripped.strip:Show() end
    f:EnableResize(true)
    AnchorContent(f, BOTTOM_INSET_STATUS)
end

-- The Frame widget's parchment backdrop is see-through enough that a small
-- dialog floating over the options panel reads as a smear of whatever is
-- behind it. Lay an opaque backing UNDER the backdrop art (BACKGROUND, lowest
-- sublevel) so the parchment still shows but stops transmitting.
--
-- Inset by the 8px the widget's own FrameBackdrop reserves, so the dialog's
-- border art is not painted over.
local BACKING_ALPHA = 0.85

local function EnsureBacking(f)
    local frame = f.frame
    if not frame then return end
    local tex = frame._bzaBacking
    if not tex then
        tex = frame:CreateTexture(nil, "BACKGROUND", nil, -8)
        tex:SetPoint("TOPLEFT",     frame, "TOPLEFT",      8, -8)
        tex:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -8,  8)
        tex:SetColorTexture(0, 0, 0, BACKING_ALPHA)
        frame._bzaBacking = tex
        -- POOLED. The backing must not follow this frame into another addon's
        -- window; every close path goes through OnHide, so that is where it
        -- goes away. Our own next open re-shows it below.
        frame:HookScript("OnHide", function(self)
            if self._bzaBacking then self._bzaBacking:Hide() end
        end)
    end
    tex:Show()
end

-- ASSERT A USABLE FRAME. Everything below is about the AceGUI Frame pool: the
-- widget we just acquired may carry state its previous owner left on it, and
-- OnAcquire restores almost none of it. A dialog that renders as a bare title
-- bar with nothing under it is this, every time — some region the window needs
-- is still hidden from the last consumer.
--
-- The known offender is our own options panel's MINIMIZE, which hides content,
-- the status strip and the resize grips together and is restored only by BA's
-- own close path. Rather than enumerate causes, assert the end state.
local function AssertUsableFrame(f)
    local frame = f.frame
    if not frame then return end
    if frame._hetMinimized then
        if frame._hetRestore then pcall(frame._hetRestore) end
        frame._hetMinimized = nil
    end
    if f.content then f.content:Show() end
    -- Layout is PAUSED for the whole of AceGUI:Release and resumed on acquire;
    -- a widget that took an error in between stays paused, and every AddChild
    -- below would then silently no-op — children with no anchors, blank window.
    if f.ResumeLayout then f:ResumeLayout() end
    -- The status strip and Close button may have been hidden by the minimize
    -- above, or by a previous stripped dialog that never got to restore them.
    -- Show them unconditionally; StripDialogChrome re-hides them for the
    -- windows that do not want them, and records what it hid.
    f._bzaStripped = nil
    local close, strip = BottomFurniture(f)
    if close then close:Show() end
    if strip then strip:Show() end
    f:EnableResize(true)
end

local function MakeFrame(title, width, height)
    local f = AceGUI:Create("Frame")
    AssertUsableFrame(f)
    -- AceGUI Frames are POOLED across every Ace3 addon, so the one we just
    -- acquired may be the very frame the options panel used last — still
    -- carrying the panel's chrome (logo, X, minimize, preview eye) and with
    -- its native Close button hidden. Neither OnAcquire nor OnRelease undoes
    -- that, so ask the panel's own ownership toggle to re-evaluate: this frame
    -- is not the open options panel, so it hides our chrome and restores the
    -- native Close. Harmless no-op on a frame the panel never touched.
    if f.frame and f.frame._hetApplyChromeOwnership then
        f.frame._hetApplyChromeOwnership()
    end
    -- Same pooling problem for the content region: its anchors are set ONCE in
    -- the widget constructor and nothing restores them, so an addon that
    -- re-anchored content to fit its own chrome leaks that offset here. Assert
    -- the library's own values, which FitHeight's chrome allowance assumes.
    AnchorContent(f, BOTTOM_INSET_STATUS)
    f:SetTitle(title)
    f:SetStatusText("")
    f:SetLayout("Flow")
    f:SetWidth(width)
    f:SetHeight(height)
    f.frame:SetFrameStrata("FULLSCREEN_DIALOG")
    EnsureBacking(f)
    return f
end

-- Shrink the dialog to exactly what its children need.
--
-- The Frame widget never sizes itself, so a hand-picked height is a guess that
-- goes wrong the moment a label wraps to a different number of lines — which
-- is how the export window ended up with a band of empty space under its
-- controls. The Flow layout already computes the used height and reports it
-- through LayoutFinished; borrow that number for one pass and set the frame
-- from it. Measuring is done OUTSIDE the layout callback (record, then set) so
-- SetHeight's re-layout cannot recurse.
local function FitHeight(f, minH, maxH)
    local measured
    f.LayoutFinished = function(_, _, h) measured = h end
    f:DoLayout()
    f.LayoutFinished = nil
    if type(measured) ~= "number" or measured <= 0 then return end
    -- The allowance follows whichever bottom inset this window is using, so a
    -- stripped-chrome dialog does not reserve 23px for a strip it removed.
    local h = measured + TOP_INSET
        + (f._bzaStripped and BOTTOM_INSET_BARE or BOTTOM_INSET_STATUS)
    if h < minH then h = minH end
    if h > maxH then h = maxH end
    f:SetHeight(math.floor(h + 0.5))
end

-- CLOSE ON THE NEXT FRAME, never from inside a child's own callback.
--
-- f:Hide() runs the widget's OnClose synchronously, which releases the whole
-- container — including the very Button whose OnClick is still on the stack,
-- and the layout state that AceGUI:Release pauses on the way in. Deferring one
-- frame is the same workaround the options panel uses for ACD:Open from a
-- setter, and it costs nothing visible.
local function CloseSoon(f)
    C_Timer.After(0, function()
        if f and f.Hide then f:Hide() end
    end)
end
ns.CloseDialogSoon = CloseSoon

-- Shared with CustomView.lua's New Folder dialog. Both fixes above (pooled
-- chrome, pooled content anchors) are worth having exactly once, so the other
-- standalone window in the addon goes through the same two functions rather
-- than growing its own copy.
ns.MakeDialogFrame = MakeFrame
ns.FitDialogHeight = FitHeight

-- `linkName`/`linkKey`/`isFolder` describe what is being exported so the
-- dialog can register it as linkable; the link itself is made by shift-
-- clicking the row in the options list, not from a button in here.
function ns.ShowExportDialog(payload, linkName, linkKey, isFolder)
    local str, err = ns.PayloadToString(payload)
    if not str then
        Print("Export failed: " .. tostring(err))
        return
    end
    if exportFrame then
        exportFrame:Release()
        exportFrame = nil
    end
    local f = MakeFrame("|cffff8c14Buzzard Auras|r — Export", 560, 320)
    f:SetCallback("OnClose", function(widget)
        AceGUI:Release(widget)
        if exportFrame == widget then exportFrame = nil end
    end)
    exportFrame = f

    local label = AceGUI:Create("Label")
    label:SetFullWidth(true)
    label:SetText("Press |cffffd100Ctrl-C|r to copy."
        .. " |cffaaaaaa(" .. #str .. " characters)|r")
    f:AddChild(label)

    local box = AceGUI:Create("MultiLineEditBox")
    box:SetFullWidth(true)
    box:SetNumLines(9)
    box:SetLabel("")
    box:DisableButton(true)
    box:SetText(str)
    -- Read-only by restoration: any edit snaps straight back and re-highlights,
    -- so Ctrl-C is the only keystroke that does anything.
    box:SetCallback("OnTextChanged", function(widget)
        if widget:GetText() ~= str then
            widget:SetText(str)
        end
        widget.editBox:HighlightText()
    end)
    f:AddChild(box)
    box.editBox:SetFocus()
    box.editBox:HighlightText()

    -- NO "Link to Chat" BUTTON. A button here has no way to know which channel
    -- the user means, so it could only pick one — and the pick was always SAY.
    -- Registering the name is still worth doing while we have it, so a link
    -- made from the list resolves without opening this window first; the
    -- gesture that actually writes into chat is shift-clicking the row, which
    -- inserts into whichever chat box is already open (ns.ShiftLinkRow).
    if linkName then
        ns.RegisterLinkable(linkName, linkKey, isFolder)
    end

    FitHeight(f, 200, 400)
end

-- `prefill` fills the box; `sender` names who sent it (chat-link path);
-- `selfLink` marks the case where the prefill came from clicking your OWN
-- link, where naming the sender would just be naming the user back to
-- themselves.
function ns.ShowImportDialog(prefill, sender, selfLink)
    if importFrame then
        importFrame:Release()
        importFrame = nil
    end
    local f = MakeFrame("|cffff8c14Buzzard Auras|r — Import", 560, 380)
    f:SetCallback("OnClose", function(widget)
        AceGUI:Release(widget)
        if importFrame == widget then importFrame = nil end
    end)
    importFrame = f

    local info = AceGUI:Create("Label")
    info:SetFullWidth(true)
    info:SetText(selfLink
        and "Your own link. Choose how to import it."
        or sender
        and ("From |cffffd100" .. sender .. "|r. Choose how to import it.")
        or "Paste a |cffffd100!BZA|r string, then choose how to import it.")
    f:AddChild(info)

    local box = AceGUI:Create("MultiLineEditBox")
    box:SetFullWidth(true)
    box:SetNumLines(8)
    box:SetLabel("")
    box:DisableButton(true)
    if prefill then box:SetText(prefill) end
    f:AddChild(box)

    local status = AceGUI:Create("Label")
    status:SetFullWidth(true)
    status:SetText("")
    f:AddChild(status)

    local newBtn = AceGUI:Create("Button")
    newBtn:SetText("Import as New")
    newBtn:SetWidth(160)
    f:AddChild(newBtn)

    local updBtn = AceGUI:Create("Button")
    updBtn:SetText("Update Existing")
    updBtn:SetWidth(160)
    updBtn:SetDisabled(true)
    f:AddChild(updBtn)

    -- Re-parse on every keystroke so the buttons always describe what is
    -- actually in the box.
    local parsed
    local function Reparse()
        parsed = nil
        local text = box:GetText()
        if not text or text:gsub("%s+", "") == "" then
            status:SetText("")
            updBtn:SetDisabled(true)
            return
        end
        local pl, err = ns.StringToPayload(text)
        if not pl then
            status:SetText("|cffff4444" .. tostring(err) .. "|r")
            updBtn:SetDisabled(true)
            return
        end
        parsed = pl
        local lines = {}
        if pl.t == "t" then
            local d = pl.d
            if d.k then
                -- `d.k` is the storage key ("extdef"), which means nothing to
                -- anyone reading this box — show the tracker's list label.
                -- Built-in labels ONLY: this describes an incoming payload, so
                -- a key that is not a built-in is shown raw rather than
                -- resolved against the receiver's own custom trackers.
                local label = (ns.BuiltinTrackerLabel and ns.BuiltinTrackerLabel(d.k))
                    or tostring(d.k)
                lines[#lines + 1] = "Built-in tracker settings for |cff66ccff"
                    .. label .. "|r."
                -- Overlays have nothing to duplicate: both buttons mean the
                -- same thing, so only one is offered and it says so.
                newBtn:SetText("Apply Settings")
                lines[#lines + 1] = "|cffff8800This overwrites your current"
                    .. " settings for that built-in tracker.|r"
            else
                newBtn:SetText("Import as New")
                lines[#lines + 1] = "Tracker |cffffd100" .. tostring(d.e.name or "?")
                    .. "|r (" .. tostring(d.e.kind) .. ")."
            end
        else
            newBtn:SetText("Import as New")
            lines[#lines + 1] = "Folder |cffffd100" .. tostring(pl.d.n or "?")
                .. "|r with " .. #pl.d.m .. " tracker"
                .. (#pl.d.m == 1 and "" or "s") .. "."
        end
        -- `av` is INFORMATIONAL ONLY and is never gated on.
        if (pl.av or 0) > AddonVersion() then
            lines[#lines + 1] = "|cffff8800Made with a newer version of Buzzard Auras.|r"
        end
        -- `sv` is the precise version of that warning: unknown keys write as
        -- inert profile keys, and the eventual addon upgrade migrates them in
        -- place — stored-profile shape paying off again.
        if (pl.sv or 0) > (ns.SETTINGS_SCHEMA or 0) then
            lines[#lines + 1] = "|cffff8800Some settings in it are newer than this"
                .. " version understands and may not apply.|r"
        end

        local canUpdate = false
        if pl.t == "t" then
            canUpdate = MatchExisting(pl.d) ~= nil
        else
            for i = 1, #pl.d.m do
                if MatchExisting(pl.d.m[i]) then canUpdate = true break end
            end
        end
        -- Overlays converge: "Update Existing" would do exactly what
        -- "Apply Settings" does, so it stays off.
        if pl.t == "t" and pl.d.k then canUpdate = false end
        updBtn:SetDisabled(not canUpdate)
        status:SetText(table.concat(lines, "\n"))
    end
    -- The status label is the only part of this window whose height moves —
    -- empty before anything is pasted, up to four lines once a payload is
    -- described. Re-fit after every parse rather than sizing for the worst
    -- case, which is what left the dead band under the buttons.
    local function ReparseAndFit()
        Reparse()
        FitHeight(f, 240, 520)
    end
    box:SetCallback("OnTextChanged", ReparseAndFit)

    local function Go(mode)
        if not parsed then return end
        -- Deferred: this runs from a Button's OnClick, and hiding the frame
        -- releases that Button mid-callback (see CloseSoon).
        CloseSoon(f)
        ns.ImportPayload(parsed, mode)
    end
    newBtn:SetCallback("OnClick", function() Go("new") end)
    updBtn:SetCallback("OnClick", function() Go("update") end)

    if prefill then Reparse() end
    FitHeight(f, 240, 520)
    box.editBox:SetFocus()
end

-------------------------------------------------------------------------------
-- Chat links
--
-- The in-game path NEVER puts the payload in chat. The message carries a short
-- token; the data crosses on demand over the hidden addon channel. Both ends
-- need Buzzard Auras — anyone without it sees the plain bracket text, which is
-- why the token spells the addon's name.
-------------------------------------------------------------------------------
local LINK_TTL = 300   -- seconds a link keeps answering requests
-- name -> { key = cfgKey or folderID, folder = bool, at = GetTime() }
--
-- This registry IS the authorisation list for outgoing transfers: a `req` is
-- answered ONLY for something the user linked, this session, within the
-- window. The addon never serves an arbitrary tracker to a remote request.
local linked = {}
-- Who we are currently expecting data from, so unsolicited `data` is dropped.
local awaiting = {}

local LINK_PATTERN = "%[BuzzardAuras: ([^%]]+)%]"

-- The token is delimited by brackets and rendered through an escape sequence,
-- so a LABEL containing "[", "]" or "|" would be captured truncated by
-- LINK_PATTERN and could never match the registry key. Strip those three, and
-- register under the SAME stripped string the token carries. The label is only
-- a label — the payload is built from `key`, so nothing is lost by cleaning it.
local function LinkLabel(name)
    -- Tree rows carry colour escapes (a folder's Folder Colour, the grey wrap on
    -- a disabled tracker) and can carry inline textures. Strip the markup
    -- FIRST: "|" is one of the three characters the token cannot contain, so
    -- leaving a colour code in would otherwise smear "cffff8c14" into the name.
    local clean = tostring(name)
    clean = clean:gsub("|T.-|t", ""):gsub("|A.-|a", "")
    clean = clean:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
    clean = clean:gsub("[%[%]|]", "")
    clean = clean:gsub("^%s+", ""):gsub("%s+$", "")
    if clean == "" then clean = "Tracker" end
    return clean
end

-- Record something as linkable WITHOUT touching chat. This registry is the
-- authorisation list a `req` is answered against, so it has to be written
-- before a token goes out — but writing it is not the same act as posting.
-- Returns the cleaned label the token will use, or nil if the name was unusable.
function ns.RegisterLinkable(name, key, isFolder)
    if type(name) ~= "string" or name == "" then return nil end
    name = LinkLabel(name)
    linked[name] = { key = key, folder = isFolder and true or false, at = GetTime() }
    return name
end

-- Insert the token into a chat box the user ALREADY has open. Deliberately
-- does not open or choose one: firing this from a shift-click while no chat
-- box is up would send the link somewhere the user never picked.
function ns.LinkToChat(name, key, isFolder)
    name = ns.RegisterLinkable(name, key, isFolder)
    if not name then return false end
    local token = "[BuzzardAuras: " .. name .. "]"
    local edit = ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow()
    if not (edit and edit.Insert and edit:IsShown()) then return false end
    edit:Insert(token)
    return true
end

-- Shift-click a row in the options list to drop its link into the chat box
-- being typed in. Returns true when it handled the click, so the caller
-- swallows it and the row is not also selected.
--
-- Every gate below is a reason to fall through to the normal click instead:
-- no chat box open, a row that is not one of ours, or a row that names
-- nothing shareable (the type nodes, Ungrouped, Addon Options, and the
-- per-tracker element rows deeper in the panel).
function ns.ShiftLinkRow(button)
    if type(button) ~= "table" then return false end
    local uv = button.uniquevalue
    if type(uv) ~= "string" or uv == "" then return false end

    local edit = ChatEdit_GetActiveWindow and ChatEdit_GetActiveWindow()
    if not (edit and edit.Insert and edit:IsShown()) then return false end

    -- OUR ROOT TREE ONLY. Row buttons are pooled across every Ace3 addon, so
    -- the wrapper installed on one can fire inside a foreign panel; `button.obj`
    -- names the TreeGroup currently driving it.
    local widget = button.obj
    if type(widget) ~= "table" or type(widget.GetUserDataTable) ~= "function" then
        return false
    end
    local user = widget:GetUserDataTable()
    local path = user and user.path
    if not (user and user.appName == addonName and path and #path == 0) then
        return false
    end

    local leaf = uv:match("([^\001]+)$")
    if not leaf then return false end
    local label = button.text and button.text.GetText and button.text:GetText()
    if type(label) ~= "string" or label == "" then return false end

    -- VALIDATE BEFORE REGISTERING. The registry answers remote requests for
    -- the next five minutes, so a row that names nothing real must not go in
    -- it — otherwise the recipient's click reaches us, finds no data, and they
    -- are told the sender simply never answered.
    local key, isFolder
    if uv:find("\001", 1, true) then
        -- A tracker row. Built-ins and customs both export.
        key = (ns.CFGForNode and ns.CFGForNode(leaf)) or leaf
        isFolder = false
        if not (ns.trackerConfigs and ns.trackerConfigs[key]) then return false end
    else
        local fid = tonumber(leaf:match("^folder(%d+)$") or "")
        if not fid then return false end
        if not (ns.FolderByID and ns.FolderByID(fid)) then return false end
        -- Same emptiness rule the Export Folder button uses: an empty folder
        -- produces a payload that imports to nothing, so it is not linkable.
        local g = HET.db and HET.db.global
        local list = g and type(g.folderMembers) == "table" and g.folderMembers[fid]
        if not (type(list) == "table" and #list > 0) then return false end
        key = fid
        isFolder = true
    end

    return ns.LinkToChat(label, key, isFolder)
end

-- AceComm reports senders through Ambiguate(sender, "none"), which always
-- carries the realm; ordinary CHAT_MSG_* senders drop it for same-realm
-- players. Both sides of the request/answer handshake key off this, so
-- normalise once here — otherwise a same-realm transfer looks unsolicited on
-- arrival and gets dropped, which is precisely the case the design says is
-- supported.
local function FullName(name)
    if type(name) ~= "string" or name == "" then return nil end
    if name:find("-", 1, true) then return name end
    local realm = GetNormalizedRealmName and GetNormalizedRealmName()
    return realm and (name .. "-" .. realm) or name
end

-- Custom |H link types do not render as links in the default UI, so the token
-- piggybacks on a built-in type the client renders but never receives from the
-- server in normal play. The SetItemRef hook below matches ONLY our payload
-- and ignores everything else.
--
-- THE SENDER IS BAKED INTO THE LINK, not looked up by name afterwards. A flat
-- name -> sender map would be last-writer-wins: anyone could type the same
-- plain token in the same channel and silently become the source for every
-- earlier message with that name still on screen. Here each rendered link
-- carries the sender of the message it was rendered from, so clicking a
-- specific message always asks that message's author.
local function LinkFilter(_, event, msg, sender, ...)
    if type(msg) ~= "string" then return false end
    if not msg:find("[BuzzardAuras: ", 1, true) then return false end
    local from = FullName(sender) or ""
    -- Player names cannot contain ":" or "|", but a TRACKER name can — so the
    -- sender field is parsed as [^:]* and the name takes the whole tail.
    local out = msg:gsub(LINK_PATTERN, function(name)
        if name:find("|", 1, true) then return nil end   -- leave it as plain text
        return "|cffff8c14|Hgarrmission:BuzzardAuras:" .. from .. ":" .. name
            .. "|h[BuzzardAuras: " .. name .. "]|h|r"
    end)
    return false, out, sender, ...
end

-- BN whispers are deliberately absent: their sender is a presence/BattleTag
-- name that SendAddonMessage cannot target, so a rendered link there could
-- never complete. Those tokens stay plain text and fall back to
-- "paste the string", which is the documented cross-realm behaviour anyway.
local CHAT_EVENTS = {
    "CHAT_MSG_SAY", "CHAT_MSG_YELL", "CHAT_MSG_PARTY", "CHAT_MSG_PARTY_LEADER",
    "CHAT_MSG_RAID", "CHAT_MSG_RAID_LEADER", "CHAT_MSG_INSTANCE_CHAT",
    "CHAT_MSG_INSTANCE_CHAT_LEADER", "CHAT_MSG_GUILD", "CHAT_MSG_OFFICER",
    "CHAT_MSG_WHISPER",
    "CHAT_MSG_CHANNEL",
}
-- CHAT_MSG_WHISPER_INFORM is deliberately absent too: it is the echo of your
-- OWN outgoing whisper, and its `sender` arg is the RECIPIENT, not the author
-- — baking that in would make your own link request the tracker from the
-- person you just sent it to.
for i = 1, #CHAT_EVENTS do
    ChatFrame_AddMessageEventFilter(CHAT_EVENTS[i], LinkFilter)
end

local function TooltipSay(text)
    ItemRefTooltip:SetOwner(UIParent, "ANCHOR_PRESERVE")
    ItemRefTooltip:ClearLines()
    ItemRefTooltip:AddLine("|cffff8c14Buzzard Auras|r")
    ItemRefTooltip:AddLine(text, 1, 1, 1, true)
    ItemRefTooltip:Show()
end

hooksecurefunc("SetItemRef", function(link, text, button)
    if type(link) ~= "string" then return end
    local sender, name = link:match("^garrmission:BuzzardAuras:([^:]*):(.+)$")
    if not name then return end
    if IsShiftKeyDown() then
        -- NO FORWARDING. Each rendered link carries the sender of the message
        -- it came from, and `linked` is an authorisation list the sender owns
        -- — so a token you re-post resolves to YOU, and you hold no record for
        -- something you never linked. Every recipient would just be told you
        -- are not sharing it. Import it first; then link your own copy, which
        -- registers you as the source properly.
        TooltipSay("Import it first, then link your own copy to pass it on.")
        return
    end
    if not sender or sender == "" then
        TooltipSay("Could not tell who linked this.")
        return
    end
    if sender == FullName(UnitName("player")) then
        -- YOUR OWN LINK. No round trip: the payload is already here, in the
        -- very registry a remote request would have been answered from. Build
        -- it and open the import window exactly as if it had come over the
        -- wire, so clicking your own link behaves like clicking anyone's.
        local rec = linked[name]
        local payload = rec and (rec.folder and ns.PayloadForFolder(rec.key)
            or ns.PayloadForTracker(rec.key))
        local str = payload and ns.PayloadToString(payload)
        if str then
            ns.ShowImportDialog(str, nil, true)
        else
            TooltipSay("That link no longer points at anything you have.")
        end
        return
    end
    if not COMM_READY then
        TooltipSay("Chat transfer is unavailable.")
        return
    end
    local msg = Encode({ m = "req", n = name }, true)
    if not msg then return end
    awaiting[sender] = { name = name, at = GetTime() }
    TooltipSay("Requesting \"" .. name .. "\" from " .. sender .. "…")
    HET:SendCommMessage(COMM_PREFIX, msg, "WHISPER", sender)
    C_Timer.After(5, function()
        local pend = awaiting[sender]
        if pend and pend.name == name then
            awaiting[sender] = nil
            TooltipSay("No answer from " .. sender .. ". Ask them to paste the string instead.")
        end
    end)
end)

-------------------------------------------------------------------------------
-- Comm protocol
--
--   { m = "req",  n = name }        clicker -> sender
--   { m = "data", d = envelope }    sender  -> clicker (BULK)
--   { m = "err",  e = "gone"|"na" } link expired / not authorised
--
-- Scope note for v1: plain addon-message WHISPERs do not cross realms.
-- Same-realm whispers and every grouped channel (party/raid work cross-realm)
-- are covered; a cross-realm whispered link falls back to "paste the string".
-- A cross-realm transport can be added later without touching the string
-- format — it is only a different pipe for the same encoded envelope.
-------------------------------------------------------------------------------
local function Decode(str)
    if not (LibSerialize and LibDeflate) then return nil end
    local ver, body = tostring(str):match("^!BZA:(%d+)!(.+)$")
    if not ver or tonumber(ver) > ENCODE_VERSION then return nil end
    local comp = LibDeflate:DecodeForWoWAddonChannel(body)
    if not comp then return nil end
    local ser = LibDeflate:DecompressDeflate(comp)
    if not ser then return nil end
    local ok, pl = LibSerialize:Deserialize(ser)
    if not ok then return nil end
    return pl
end

local function OnComm(_, message, _, rawSender)
    local pl = Decode(message)
    if type(pl) ~= "table" then return end
    local sender = FullName(rawSender)
    if not sender then return end

    if pl.m == "req" then
        local rec = linked[pl.n]
        if not rec then
            local err = Encode({ m = "err", e = "na" }, true)
            if err then HET:SendCommMessage(COMM_PREFIX, err, "WHISPER", sender) end
            return
        end
        if (GetTime() - (rec.at or 0)) > LINK_TTL then
            linked[pl.n] = nil
            local err = Encode({ m = "err", e = "gone" }, true)
            if err then HET:SendCommMessage(COMM_PREFIX, err, "WHISPER", sender) end
            return
        end
        local payload = rec.folder and ns.PayloadForFolder(rec.key)
            or ns.PayloadForTracker(rec.key)
        -- The link was valid when it was made, but the tracker or folder
        -- behind it can be deleted (or a profile switched) inside the TTL.
        -- Answer, rather than going silent: silence is indistinguishable from
        -- being offline, and the requester would sit through the full timeout
        -- and be told to ask us to paste the string.
        if not payload then
            linked[pl.n] = nil
            local err = Encode({ m = "err", e = "gone" }, true)
            if err then HET:SendCommMessage(COMM_PREFIX, err, "WHISPER", sender) end
            return
        end
        local out = Encode({ m = "data", d = payload }, true)
        if out then
            HET:SendCommMessage(COMM_PREFIX, out, "WHISPER", sender, "BULK")
        end
        return
    end

    if pl.m == "err" then
        if not awaiting[sender] then return end
        awaiting[sender] = nil
        TooltipSay(pl.e == "gone"
            and "That link has expired — ask them to link it again."
            or "They are not sharing that right now.")
        return
    end

    if pl.m == "data" then
        -- Unsolicited data from someone we never asked is dropped outright.
        if not awaiting[sender] then return end
        awaiting[sender] = nil
        local envelope, err = ValidatePayload(pl.d)
        if not envelope then
            TooltipSay(tostring(err))
            return
        end
        if ItemRefTooltip:IsShown() then ItemRefTooltip:Hide() end
        -- Funnels into exactly the ordinary import flow: same dialog, same
        -- validation, same combat rule.
        ns.ShowImportDialog(nil, sender)
        if importFrame then
            -- Hand the parsed envelope straight to the dialog rather than
            -- round-tripping it through the text box.
            local str = ns.PayloadToString(envelope)
            if str then
                for _, child in ipairs(importFrame.children or {}) do
                    if child.type == "MultiLineEditBox" then
                        child:SetText(str)
                        child:Fire("OnTextChanged", str)
                        break
                    end
                end
            end
        end
    end
end

-- Registered unconditionally at load, not on first panel open: chat links must
-- resolve with the options panel closed. Capability-checked the same way every
-- other shared-library hook in this addon is — a copy of AceComm that does not
-- embed cleanly costs us chat transfer, never a load error.
if AceComm and type(AceComm.Embed) == "function" then
    pcall(AceComm.Embed, AceComm, HET)
end
COMM_READY = type(HET.RegisterComm) == "function"
    and type(HET.SendCommMessage) == "function"
if COMM_READY then
    HET:RegisterComm(COMM_PREFIX, OnComm)
end
